Add daily-loop prepare and readiness checks

Make the local chat-host loop explicit and cheap so users can warm the machine once instead of rediscovering environment and guest setup on every session.

Add cache-backed daily-loop manifests plus the new `pyro prepare` flow, extend `pyro doctor --environment` with warm/cold/stale readiness reporting, and add `make smoke-daily-loop` to prove the warmed repro-fix reset path end to end.

Also fix `python -m pyro_mcp.cli` to invoke `main()` so the new smoke and `dist-check` actually exercise the CLI module, and update the docs/roadmap to present `doctor -> prepare -> connect host -> reset` as the recommended daily path.

Validation: `uv lock`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check`, and `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make smoke-daily-loop`.
This commit is contained in:
Thales Maciel 2026-03-13 21:17:59 -03:00
parent d0cf6d8f21
commit 663241d5d2
26 changed files with 1592 additions and 199 deletions

View file

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