Add workspace snapshots and full reset
Implement the 2.8.0 workspace milestone with named snapshots and full-sandbox reset across the CLI, Python SDK, and MCP server. Persist the immutable baseline plus named snapshot archives under each workspace, add workspace reset metadata, and make reset recreate the sandbox while clearing command history, shells, and services without changing the workspace identity or diff baseline. Refresh the 2.8.0 docs, roadmap, and Python example around reset-over-repair, then validate with uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed create/snapshot/reset/diff smoke test outside the sandbox.
This commit is contained in:
parent
f504f0a331
commit
18b8fd2a7d
20 changed files with 1429 additions and 29 deletions
|
|
@ -66,6 +66,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace diff WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt" in workspace_help
|
||||
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_help
|
||||
assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in workspace_help
|
||||
assert "pyro workspace shell open WORKSPACE_ID" in workspace_help
|
||||
|
||||
workspace_create_help = _subparser_choice(
|
||||
|
|
@ -107,6 +109,34 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "immutable workspace baseline" in workspace_diff_help
|
||||
assert "workspace export" in workspace_diff_help
|
||||
|
||||
workspace_snapshot_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"snapshot",
|
||||
).format_help()
|
||||
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_snapshot_help
|
||||
assert "baseline" in workspace_snapshot_help
|
||||
|
||||
workspace_snapshot_create_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), "create"
|
||||
).format_help()
|
||||
assert "Capture the current `/workspace` tree" in workspace_snapshot_create_help
|
||||
|
||||
workspace_snapshot_list_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), "list"
|
||||
).format_help()
|
||||
assert "baseline snapshot plus any named snapshots" in workspace_snapshot_list_help
|
||||
|
||||
workspace_snapshot_delete_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), "delete"
|
||||
).format_help()
|
||||
assert "leaving the implicit baseline intact" in workspace_snapshot_delete_help
|
||||
|
||||
workspace_reset_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "reset"
|
||||
).format_help()
|
||||
assert "--snapshot" in workspace_reset_help
|
||||
assert "reset over repair" in workspace_reset_help
|
||||
|
||||
workspace_shell_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"shell",
|
||||
|
|
@ -651,6 +681,359 @@ def test_cli_workspace_diff_prints_human_output(
|
|||
assert "--- a/note.txt" in output
|
||||
|
||||
|
||||
def test_cli_workspace_snapshot_create_list_delete_and_reset_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot_name == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"snapshot": {
|
||||
"snapshot_name": snapshot_name,
|
||||
"kind": "named",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
},
|
||||
}
|
||||
|
||||
def list_snapshots(self, workspace_id: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"count": 2,
|
||||
"snapshots": [
|
||||
{
|
||||
"snapshot_name": "baseline",
|
||||
"kind": "baseline",
|
||||
"entry_count": 1,
|
||||
"bytes_written": 10,
|
||||
"deletable": False,
|
||||
},
|
||||
{
|
||||
"snapshot_name": "checkpoint",
|
||||
"kind": "named",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
"deletable": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def reset_workspace(self, workspace_id: str, *, snapshot: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"state": "started",
|
||||
"workspace_path": "/workspace",
|
||||
"reset_count": 2,
|
||||
"workspace_reset": {
|
||||
"snapshot_name": snapshot,
|
||||
"kind": "named",
|
||||
"destination": "/workspace",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
},
|
||||
}
|
||||
|
||||
def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot_name == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"snapshot_name": snapshot_name,
|
||||
"deleted": True,
|
||||
}
|
||||
|
||||
class SnapshotCreateParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="create",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotCreateParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
created = json.loads(capsys.readouterr().out)
|
||||
assert created["snapshot"]["snapshot_name"] == "checkpoint"
|
||||
|
||||
class SnapshotListParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="list",
|
||||
workspace_id="workspace-123",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotListParser())
|
||||
cli.main()
|
||||
listed = json.loads(capsys.readouterr().out)
|
||||
assert listed["count"] == 2
|
||||
|
||||
class ResetParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="reset",
|
||||
workspace_id="workspace-123",
|
||||
snapshot="checkpoint",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: ResetParser())
|
||||
cli.main()
|
||||
reset = json.loads(capsys.readouterr().out)
|
||||
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||
|
||||
class SnapshotDeleteParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="delete",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotDeleteParser())
|
||||
cli.main()
|
||||
deleted = json.loads(capsys.readouterr().out)
|
||||
assert deleted["deleted"] is True
|
||||
|
||||
|
||||
def test_cli_workspace_reset_prints_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def reset_workspace(self, workspace_id: str, *, snapshot: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot == "baseline"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"state": "started",
|
||||
"environment": "debian:12",
|
||||
"workspace_path": "/workspace",
|
||||
"workspace_seed": {
|
||||
"mode": "directory",
|
||||
"seed_path": "/tmp/repo",
|
||||
"destination": "/workspace",
|
||||
"entry_count": 1,
|
||||
"bytes_written": 4,
|
||||
},
|
||||
"execution_mode": "guest_vsock",
|
||||
"command_count": 0,
|
||||
"service_count": 0,
|
||||
"running_service_count": 0,
|
||||
"reset_count": 3,
|
||||
"last_reset_at": 123.0,
|
||||
"workspace_reset": {
|
||||
"snapshot_name": "baseline",
|
||||
"kind": "baseline",
|
||||
"destination": "/workspace",
|
||||
"entry_count": 1,
|
||||
"bytes_written": 4,
|
||||
},
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="reset",
|
||||
workspace_id="workspace-123",
|
||||
snapshot="baseline",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "Reset source: baseline (baseline)" in output
|
||||
assert "Reset count: 3" in output
|
||||
|
||||
|
||||
def test_cli_workspace_snapshot_prints_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot_name == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"snapshot": {
|
||||
"snapshot_name": snapshot_name,
|
||||
"kind": "named",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
},
|
||||
}
|
||||
|
||||
def list_snapshots(self, workspace_id: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"count": 2,
|
||||
"snapshots": [
|
||||
{
|
||||
"snapshot_name": "baseline",
|
||||
"kind": "baseline",
|
||||
"entry_count": 1,
|
||||
"bytes_written": 10,
|
||||
"deletable": False,
|
||||
},
|
||||
{
|
||||
"snapshot_name": "checkpoint",
|
||||
"kind": "named",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
"deletable": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot_name == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"snapshot_name": snapshot_name,
|
||||
"deleted": True,
|
||||
}
|
||||
|
||||
class SnapshotCreateParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="create",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotCreateParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
create_output = capsys.readouterr().out
|
||||
assert "[workspace-snapshot-create] workspace_id=workspace-123" in create_output
|
||||
assert "snapshot_name=checkpoint kind=named" in create_output
|
||||
|
||||
class SnapshotListParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="list",
|
||||
workspace_id="workspace-123",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotListParser())
|
||||
cli.main()
|
||||
list_output = capsys.readouterr().out
|
||||
assert "baseline [baseline]" in list_output
|
||||
assert "checkpoint [named]" in list_output
|
||||
|
||||
class SnapshotDeleteParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="delete",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotDeleteParser())
|
||||
cli.main()
|
||||
delete_output = capsys.readouterr().out
|
||||
assert "Deleted workspace snapshot: checkpoint" in delete_output
|
||||
|
||||
|
||||
def test_cli_workspace_snapshot_error_paths(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
del workspace_id, snapshot_name
|
||||
raise RuntimeError("create boom")
|
||||
|
||||
def list_snapshots(self, workspace_id: str) -> dict[str, Any]:
|
||||
del workspace_id
|
||||
raise RuntimeError("list boom")
|
||||
|
||||
def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
del workspace_id, snapshot_name
|
||||
raise RuntimeError("delete boom")
|
||||
|
||||
def _run(args: argparse.Namespace) -> tuple[str, str]:
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return args
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
return captured.out, captured.err
|
||||
|
||||
out, err = _run(
|
||||
argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="create",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=True,
|
||||
)
|
||||
)
|
||||
assert json.loads(out)["error"] == "create boom"
|
||||
assert err == ""
|
||||
|
||||
out, err = _run(
|
||||
argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="list",
|
||||
workspace_id="workspace-123",
|
||||
json=False,
|
||||
)
|
||||
)
|
||||
assert out == ""
|
||||
assert "[error] list boom" in err
|
||||
|
||||
out, err = _run(
|
||||
argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="delete",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=False,
|
||||
)
|
||||
)
|
||||
assert out == ""
|
||||
assert "[error] delete boom" in err
|
||||
|
||||
|
||||
def test_cli_workspace_sync_push_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue