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": ""}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue