Add daily-loop prepare and readiness checks
Make the local chat-host loop explicit and cheap so users can warm the machine once instead of rediscovering environment and guest setup on every session. Add cache-backed daily-loop manifests plus the new `pyro prepare` flow, extend `pyro doctor --environment` with warm/cold/stale readiness reporting, and add `make smoke-daily-loop` to prove the warmed repro-fix reset path end to end. Also fix `python -m pyro_mcp.cli` to invoke `main()` so the new smoke and `dist-check` actually exercise the CLI module, and update the docs/roadmap to present `doctor -> prepare -> connect host -> reset` as the recommended daily path. Validation: `uv lock`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check`, and `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make smoke-daily-loop`.
This commit is contained in:
parent
d0cf6d8f21
commit
663241d5d2
26 changed files with 1592 additions and 199 deletions
359
tests/test_daily_loop.py
Normal file
359
tests/test_daily_loop.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyro_mcp.daily_loop import (
|
||||
DailyLoopManifest,
|
||||
evaluate_daily_loop_status,
|
||||
load_prepare_manifest,
|
||||
prepare_manifest_path,
|
||||
prepare_request_is_satisfied,
|
||||
)
|
||||
from pyro_mcp.runtime import RuntimeCapabilities
|
||||
from pyro_mcp.vm_manager import VmManager
|
||||
|
||||
|
||||
def test_prepare_daily_loop_executes_then_reuses(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "state",
|
||||
cache_dir=tmp_path / "cache",
|
||||
)
|
||||
monkeypatch.setattr(manager, "_backend_name", "firecracker")
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_runtime_capabilities",
|
||||
RuntimeCapabilities(
|
||||
supports_vm_boot=True,
|
||||
supports_guest_exec=True,
|
||||
supports_guest_network=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"inspect_environment",
|
||||
lambda environment: {"installed": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager._environment_store,
|
||||
"ensure_installed",
|
||||
lambda environment: object(),
|
||||
)
|
||||
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
def fake_create_workspace(**kwargs: object) -> dict[str, object]:
|
||||
observed["network_policy"] = kwargs["network_policy"]
|
||||
return {"workspace_id": "ws-123"}
|
||||
|
||||
def fake_exec_workspace(
|
||||
workspace_id: str,
|
||||
*,
|
||||
command: str,
|
||||
timeout_seconds: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
observed["exec"] = {
|
||||
"workspace_id": workspace_id,
|
||||
"command": command,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"secret_env": secret_env,
|
||||
}
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"stdout": "/workspace\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"execution_mode": "guest_vsock",
|
||||
}
|
||||
|
||||
def fake_reset_workspace(
|
||||
workspace_id: str,
|
||||
*,
|
||||
snapshot: str = "baseline",
|
||||
) -> dict[str, object]:
|
||||
observed["reset"] = {"workspace_id": workspace_id, "snapshot": snapshot}
|
||||
return {"workspace_id": workspace_id}
|
||||
|
||||
def fake_delete_workspace(
|
||||
workspace_id: str,
|
||||
*,
|
||||
reason: str = "explicit_delete",
|
||||
) -> dict[str, object]:
|
||||
observed["delete"] = {"workspace_id": workspace_id, "reason": reason}
|
||||
return {"workspace_id": workspace_id, "deleted": True}
|
||||
|
||||
monkeypatch.setattr(manager, "create_workspace", fake_create_workspace)
|
||||
monkeypatch.setattr(manager, "exec_workspace", fake_exec_workspace)
|
||||
monkeypatch.setattr(manager, "reset_workspace", fake_reset_workspace)
|
||||
monkeypatch.setattr(manager, "delete_workspace", fake_delete_workspace)
|
||||
|
||||
first = manager.prepare_daily_loop("debian:12")
|
||||
assert first["prepared"] is True
|
||||
assert first["executed"] is True
|
||||
assert first["reused"] is False
|
||||
assert first["network_prepared"] is False
|
||||
assert first["execution_mode"] == "guest_vsock"
|
||||
assert observed["network_policy"] == "off"
|
||||
assert observed["exec"] == {
|
||||
"workspace_id": "ws-123",
|
||||
"command": "pwd",
|
||||
"timeout_seconds": 30,
|
||||
"secret_env": None,
|
||||
}
|
||||
assert observed["reset"] == {"workspace_id": "ws-123", "snapshot": "baseline"}
|
||||
assert observed["delete"] == {"workspace_id": "ws-123", "reason": "prepare_cleanup"}
|
||||
|
||||
second = manager.prepare_daily_loop("debian:12")
|
||||
assert second["prepared"] is True
|
||||
assert second["executed"] is False
|
||||
assert second["reused"] is True
|
||||
assert second["reason"] == "reused existing warm manifest"
|
||||
|
||||
|
||||
def test_prepare_daily_loop_force_and_network_upgrade_manifest(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "state",
|
||||
cache_dir=tmp_path / "cache",
|
||||
)
|
||||
monkeypatch.setattr(manager, "_backend_name", "firecracker")
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_runtime_capabilities",
|
||||
RuntimeCapabilities(
|
||||
supports_vm_boot=True,
|
||||
supports_guest_exec=True,
|
||||
supports_guest_network=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"inspect_environment",
|
||||
lambda environment: {"installed": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager._environment_store,
|
||||
"ensure_installed",
|
||||
lambda environment: object(),
|
||||
)
|
||||
|
||||
observed_policies: list[str] = []
|
||||
|
||||
def fake_create_workspace(**kwargs: object) -> dict[str, object]:
|
||||
observed_policies.append(str(kwargs["network_policy"]))
|
||||
return {"workspace_id": "ws-1"}
|
||||
|
||||
monkeypatch.setattr(manager, "create_workspace", fake_create_workspace)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"exec_workspace",
|
||||
lambda workspace_id, **kwargs: {
|
||||
"workspace_id": workspace_id,
|
||||
"stdout": "/workspace\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"execution_mode": "guest_vsock",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"reset_workspace",
|
||||
lambda workspace_id, **kwargs: {"workspace_id": workspace_id},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"delete_workspace",
|
||||
lambda workspace_id, **kwargs: {"workspace_id": workspace_id, "deleted": True},
|
||||
)
|
||||
|
||||
manager.prepare_daily_loop("debian:12")
|
||||
payload = manager.prepare_daily_loop("debian:12", network=True, force=True)
|
||||
assert payload["executed"] is True
|
||||
assert payload["network_prepared"] is True
|
||||
assert observed_policies == ["off", "egress"]
|
||||
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path / "cache",
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest, manifest_error = load_prepare_manifest(manifest_path)
|
||||
assert manifest_error is None
|
||||
if manifest is None:
|
||||
raise AssertionError("expected prepare manifest")
|
||||
assert manifest.network_prepared is True
|
||||
|
||||
|
||||
def test_prepare_daily_loop_requires_guest_capabilities(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "state",
|
||||
cache_dir=tmp_path / "cache",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="guest-backed runtime"):
|
||||
manager.prepare_daily_loop("debian:12")
|
||||
|
||||
|
||||
def test_load_prepare_manifest_reports_invalid_json(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text("{broken", encoding="utf-8")
|
||||
|
||||
manifest, error = load_prepare_manifest(manifest_path)
|
||||
assert manifest is None
|
||||
assert error is not None
|
||||
|
||||
|
||||
def test_prepare_manifest_round_trip(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest = DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
prepared_at=123.0,
|
||||
network_prepared=True,
|
||||
last_prepare_duration_ms=456,
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(json.dumps(manifest.to_payload()), encoding="utf-8")
|
||||
|
||||
loaded, error = load_prepare_manifest(manifest_path)
|
||||
assert error is None
|
||||
assert loaded == manifest
|
||||
|
||||
|
||||
def test_load_prepare_manifest_rejects_non_object(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text('["not-an-object"]', encoding="utf-8")
|
||||
|
||||
manifest, error = load_prepare_manifest(manifest_path)
|
||||
assert manifest is None
|
||||
assert error == "prepare manifest is not a JSON object"
|
||||
|
||||
|
||||
def test_load_prepare_manifest_rejects_invalid_payload(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(json.dumps({"environment": "debian:12"}), encoding="utf-8")
|
||||
|
||||
manifest, error = load_prepare_manifest(manifest_path)
|
||||
assert manifest is None
|
||||
assert error is not None
|
||||
assert "prepare manifest is invalid" in error
|
||||
|
||||
|
||||
def test_evaluate_daily_loop_status_edge_cases() -> None:
|
||||
manifest = DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
prepared_at=1.0,
|
||||
network_prepared=False,
|
||||
last_prepare_duration_ms=2,
|
||||
)
|
||||
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
manifest_error="broken manifest",
|
||||
) == ("stale", "broken manifest")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=False,
|
||||
manifest=manifest,
|
||||
) == ("stale", "environment install is missing")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12-build",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "prepare manifest environment does not match the selected environment")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="2.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "environment version changed since the last prepare run")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-aarch64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "platform changed since the last prepare run")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-2",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "runtime bundle version changed since the last prepare run")
|
||||
|
||||
|
||||
def test_prepare_request_is_satisfied_network_gate() -> None:
|
||||
manifest = DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
prepared_at=1.0,
|
||||
network_prepared=False,
|
||||
last_prepare_duration_ms=2,
|
||||
)
|
||||
|
||||
assert prepare_request_is_satisfied(None, require_network=False) is False
|
||||
assert prepare_request_is_satisfied(manifest, require_network=True) is False
|
||||
assert prepare_request_is_satisfied(manifest, require_network=False) is True
|
||||
Loading…
Add table
Add a link
Reference in a new issue