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
|
|
@ -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": ""}
|
||||
|
||||
|
|
|
|||
359
tests/test_daily_loop.py
Normal file
359
tests/test_daily_loop.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyro_mcp.daily_loop import (
|
||||
DailyLoopManifest,
|
||||
evaluate_daily_loop_status,
|
||||
load_prepare_manifest,
|
||||
prepare_manifest_path,
|
||||
prepare_request_is_satisfied,
|
||||
)
|
||||
from pyro_mcp.runtime import RuntimeCapabilities
|
||||
from pyro_mcp.vm_manager import VmManager
|
||||
|
||||
|
||||
def test_prepare_daily_loop_executes_then_reuses(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "state",
|
||||
cache_dir=tmp_path / "cache",
|
||||
)
|
||||
monkeypatch.setattr(manager, "_backend_name", "firecracker")
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_runtime_capabilities",
|
||||
RuntimeCapabilities(
|
||||
supports_vm_boot=True,
|
||||
supports_guest_exec=True,
|
||||
supports_guest_network=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"inspect_environment",
|
||||
lambda environment: {"installed": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager._environment_store,
|
||||
"ensure_installed",
|
||||
lambda environment: object(),
|
||||
)
|
||||
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
def fake_create_workspace(**kwargs: object) -> dict[str, object]:
|
||||
observed["network_policy"] = kwargs["network_policy"]
|
||||
return {"workspace_id": "ws-123"}
|
||||
|
||||
def fake_exec_workspace(
|
||||
workspace_id: str,
|
||||
*,
|
||||
command: str,
|
||||
timeout_seconds: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
observed["exec"] = {
|
||||
"workspace_id": workspace_id,
|
||||
"command": command,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"secret_env": secret_env,
|
||||
}
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"stdout": "/workspace\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"execution_mode": "guest_vsock",
|
||||
}
|
||||
|
||||
def fake_reset_workspace(
|
||||
workspace_id: str,
|
||||
*,
|
||||
snapshot: str = "baseline",
|
||||
) -> dict[str, object]:
|
||||
observed["reset"] = {"workspace_id": workspace_id, "snapshot": snapshot}
|
||||
return {"workspace_id": workspace_id}
|
||||
|
||||
def fake_delete_workspace(
|
||||
workspace_id: str,
|
||||
*,
|
||||
reason: str = "explicit_delete",
|
||||
) -> dict[str, object]:
|
||||
observed["delete"] = {"workspace_id": workspace_id, "reason": reason}
|
||||
return {"workspace_id": workspace_id, "deleted": True}
|
||||
|
||||
monkeypatch.setattr(manager, "create_workspace", fake_create_workspace)
|
||||
monkeypatch.setattr(manager, "exec_workspace", fake_exec_workspace)
|
||||
monkeypatch.setattr(manager, "reset_workspace", fake_reset_workspace)
|
||||
monkeypatch.setattr(manager, "delete_workspace", fake_delete_workspace)
|
||||
|
||||
first = manager.prepare_daily_loop("debian:12")
|
||||
assert first["prepared"] is True
|
||||
assert first["executed"] is True
|
||||
assert first["reused"] is False
|
||||
assert first["network_prepared"] is False
|
||||
assert first["execution_mode"] == "guest_vsock"
|
||||
assert observed["network_policy"] == "off"
|
||||
assert observed["exec"] == {
|
||||
"workspace_id": "ws-123",
|
||||
"command": "pwd",
|
||||
"timeout_seconds": 30,
|
||||
"secret_env": None,
|
||||
}
|
||||
assert observed["reset"] == {"workspace_id": "ws-123", "snapshot": "baseline"}
|
||||
assert observed["delete"] == {"workspace_id": "ws-123", "reason": "prepare_cleanup"}
|
||||
|
||||
second = manager.prepare_daily_loop("debian:12")
|
||||
assert second["prepared"] is True
|
||||
assert second["executed"] is False
|
||||
assert second["reused"] is True
|
||||
assert second["reason"] == "reused existing warm manifest"
|
||||
|
||||
|
||||
def test_prepare_daily_loop_force_and_network_upgrade_manifest(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "state",
|
||||
cache_dir=tmp_path / "cache",
|
||||
)
|
||||
monkeypatch.setattr(manager, "_backend_name", "firecracker")
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_runtime_capabilities",
|
||||
RuntimeCapabilities(
|
||||
supports_vm_boot=True,
|
||||
supports_guest_exec=True,
|
||||
supports_guest_network=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"inspect_environment",
|
||||
lambda environment: {"installed": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager._environment_store,
|
||||
"ensure_installed",
|
||||
lambda environment: object(),
|
||||
)
|
||||
|
||||
observed_policies: list[str] = []
|
||||
|
||||
def fake_create_workspace(**kwargs: object) -> dict[str, object]:
|
||||
observed_policies.append(str(kwargs["network_policy"]))
|
||||
return {"workspace_id": "ws-1"}
|
||||
|
||||
monkeypatch.setattr(manager, "create_workspace", fake_create_workspace)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"exec_workspace",
|
||||
lambda workspace_id, **kwargs: {
|
||||
"workspace_id": workspace_id,
|
||||
"stdout": "/workspace\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"execution_mode": "guest_vsock",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"reset_workspace",
|
||||
lambda workspace_id, **kwargs: {"workspace_id": workspace_id},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"delete_workspace",
|
||||
lambda workspace_id, **kwargs: {"workspace_id": workspace_id, "deleted": True},
|
||||
)
|
||||
|
||||
manager.prepare_daily_loop("debian:12")
|
||||
payload = manager.prepare_daily_loop("debian:12", network=True, force=True)
|
||||
assert payload["executed"] is True
|
||||
assert payload["network_prepared"] is True
|
||||
assert observed_policies == ["off", "egress"]
|
||||
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path / "cache",
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest, manifest_error = load_prepare_manifest(manifest_path)
|
||||
assert manifest_error is None
|
||||
if manifest is None:
|
||||
raise AssertionError("expected prepare manifest")
|
||||
assert manifest.network_prepared is True
|
||||
|
||||
|
||||
def test_prepare_daily_loop_requires_guest_capabilities(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "state",
|
||||
cache_dir=tmp_path / "cache",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="guest-backed runtime"):
|
||||
manager.prepare_daily_loop("debian:12")
|
||||
|
||||
|
||||
def test_load_prepare_manifest_reports_invalid_json(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text("{broken", encoding="utf-8")
|
||||
|
||||
manifest, error = load_prepare_manifest(manifest_path)
|
||||
assert manifest is None
|
||||
assert error is not None
|
||||
|
||||
|
||||
def test_prepare_manifest_round_trip(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest = DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
prepared_at=123.0,
|
||||
network_prepared=True,
|
||||
last_prepare_duration_ms=456,
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(json.dumps(manifest.to_payload()), encoding="utf-8")
|
||||
|
||||
loaded, error = load_prepare_manifest(manifest_path)
|
||||
assert error is None
|
||||
assert loaded == manifest
|
||||
|
||||
|
||||
def test_load_prepare_manifest_rejects_non_object(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text('["not-an-object"]', encoding="utf-8")
|
||||
|
||||
manifest, error = load_prepare_manifest(manifest_path)
|
||||
assert manifest is None
|
||||
assert error == "prepare manifest is not a JSON object"
|
||||
|
||||
|
||||
def test_load_prepare_manifest_rejects_invalid_payload(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(json.dumps({"environment": "debian:12"}), encoding="utf-8")
|
||||
|
||||
manifest, error = load_prepare_manifest(manifest_path)
|
||||
assert manifest is None
|
||||
assert error is not None
|
||||
assert "prepare manifest is invalid" in error
|
||||
|
||||
|
||||
def test_evaluate_daily_loop_status_edge_cases() -> None:
|
||||
manifest = DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
prepared_at=1.0,
|
||||
network_prepared=False,
|
||||
last_prepare_duration_ms=2,
|
||||
)
|
||||
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
manifest_error="broken manifest",
|
||||
) == ("stale", "broken manifest")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=False,
|
||||
manifest=manifest,
|
||||
) == ("stale", "environment install is missing")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12-build",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "prepare manifest environment does not match the selected environment")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="2.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "environment version changed since the last prepare run")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-aarch64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "platform changed since the last prepare run")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-2",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "runtime bundle version changed since the last prepare run")
|
||||
|
||||
|
||||
def test_prepare_request_is_satisfied_network_gate() -> None:
|
||||
manifest = DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
prepared_at=1.0,
|
||||
network_prepared=False,
|
||||
last_prepare_duration_ms=2,
|
||||
)
|
||||
|
||||
assert prepare_request_is_satisfied(None, require_network=False) is False
|
||||
assert prepare_request_is_satisfied(manifest, require_network=True) is False
|
||||
assert prepare_request_is_satisfied(manifest, require_network=False) is True
|
||||
138
tests/test_daily_loop_smoke.py
Normal file
138
tests/test_daily_loop_smoke.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import pyro_mcp.daily_loop_smoke as smoke_module
|
||||
|
||||
|
||||
class _FakePyro:
|
||||
def __init__(self) -> None:
|
||||
self.workspace_id = "ws-1"
|
||||
self.message = "broken\n"
|
||||
self.deleted = False
|
||||
|
||||
def create_workspace(
|
||||
self,
|
||||
*,
|
||||
environment: str,
|
||||
seed_path: Path,
|
||||
name: str | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
assert environment == "debian:12"
|
||||
assert seed_path.is_dir()
|
||||
assert name == "daily-loop"
|
||||
assert labels == {"suite": "daily-loop-smoke"}
|
||||
return {"workspace_id": self.workspace_id}
|
||||
|
||||
def exec_workspace(self, workspace_id: str, *, command: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
if command != "sh check.sh":
|
||||
raise AssertionError(f"unexpected command: {command}")
|
||||
if self.message == "fixed\n":
|
||||
return {"exit_code": 0, "stdout": "fixed\n"}
|
||||
return {"exit_code": 1, "stderr": "expected fixed got broken\n"}
|
||||
|
||||
def apply_workspace_patch(self, workspace_id: str, *, patch: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
assert "+fixed" in patch
|
||||
self.message = "fixed\n"
|
||||
return {"changed": True}
|
||||
|
||||
def export_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
*,
|
||||
output_path: Path,
|
||||
) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
assert path == "message.txt"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(self.message, encoding="utf-8")
|
||||
return {"artifact_type": "file"}
|
||||
|
||||
def reset_workspace(self, workspace_id: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
self.message = "broken\n"
|
||||
return {"reset_count": 1}
|
||||
|
||||
def read_workspace_file(self, workspace_id: str, path: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
assert path == "message.txt"
|
||||
return {"content": self.message}
|
||||
|
||||
def delete_workspace(self, workspace_id: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
self.deleted = True
|
||||
return {"workspace_id": workspace_id, "deleted": True}
|
||||
|
||||
|
||||
def test_run_prepare_parses_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"prepared": True}),
|
||||
stderr="",
|
||||
),
|
||||
)
|
||||
payload = smoke_module._run_prepare("debian:12")
|
||||
assert payload == {"prepared": True}
|
||||
|
||||
|
||||
def test_run_prepare_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: SimpleNamespace(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="prepare failed",
|
||||
),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="prepare failed"):
|
||||
smoke_module._run_prepare("debian:12")
|
||||
|
||||
|
||||
def test_run_daily_loop_smoke_happy_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
prepare_calls: list[str] = []
|
||||
fake_pyro = _FakePyro()
|
||||
|
||||
def fake_run_prepare(environment: str) -> dict[str, object]:
|
||||
prepare_calls.append(environment)
|
||||
return {"prepared": True, "reused": len(prepare_calls) > 1}
|
||||
|
||||
monkeypatch.setattr(smoke_module, "_run_prepare", fake_run_prepare)
|
||||
monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro)
|
||||
|
||||
smoke_module.run_daily_loop_smoke(environment="debian:12")
|
||||
|
||||
assert prepare_calls == ["debian:12", "debian:12"]
|
||||
assert fake_pyro.deleted is True
|
||||
|
||||
|
||||
def test_main_runs_selected_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
observed: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
smoke_module,
|
||||
"run_daily_loop_smoke",
|
||||
lambda *, environment: observed.append(environment),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
smoke_module,
|
||||
"build_arg_parser",
|
||||
lambda: SimpleNamespace(
|
||||
parse_args=lambda: SimpleNamespace(environment="debian:12-build")
|
||||
),
|
||||
)
|
||||
smoke_module.main()
|
||||
assert observed == ["debian:12-build"]
|
||||
|
|
@ -15,13 +15,18 @@ def test_doctor_main_prints_json(
|
|||
) -> None:
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(platform="linux-x86_64")
|
||||
return argparse.Namespace(platform="linux-x86_64", environment="debian:12")
|
||||
|
||||
monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(
|
||||
doctor_module,
|
||||
"doctor_report",
|
||||
lambda platform: {"platform": platform, "runtime_ok": True, "issues": []},
|
||||
lambda *, platform, environment: {
|
||||
"platform": platform,
|
||||
"environment": environment,
|
||||
"runtime_ok": True,
|
||||
"issues": [],
|
||||
},
|
||||
)
|
||||
doctor_module.main()
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
|
|
@ -32,3 +37,4 @@ def test_doctor_build_parser_defaults_platform() -> None:
|
|||
parser = doctor_module._build_parser()
|
||||
args = parser.parse_args([])
|
||||
assert args.platform == DEFAULT_PLATFORM
|
||||
assert args.environment == "debian:12"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue