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:
Thales Maciel 2026-03-12 12:41:11 -03:00
parent f504f0a331
commit 18b8fd2a7d
20 changed files with 1429 additions and 29 deletions

View file

@ -174,6 +174,10 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
f"{int(payload.get('mem_mib', 0))} MiB"
)
print(f"Command count: {int(payload.get('command_count', 0))}")
print(f"Reset count: {int(payload.get('reset_count', 0))}")
last_reset_at = payload.get("last_reset_at")
if last_reset_at is not None:
print(f"Last reset at: {last_reset_at}")
print(
"Services: "
f"{int(payload.get('running_service_count', 0))}/"
@ -281,6 +285,55 @@ def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None:
snapshot = payload.get("snapshot")
if not isinstance(snapshot, dict):
print(f"[{prefix}] workspace_id={str(payload.get('workspace_id', 'unknown'))}")
return
print(
f"[{prefix}] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"snapshot_name={str(snapshot.get('snapshot_name', 'unknown'))} "
f"kind={str(snapshot.get('kind', 'unknown'))} "
f"entry_count={int(snapshot.get('entry_count', 0))} "
f"bytes_written={int(snapshot.get('bytes_written', 0))}"
)
def _print_workspace_snapshot_list_human(payload: dict[str, Any]) -> None:
snapshots = payload.get("snapshots")
if not isinstance(snapshots, list) or not snapshots:
print("No workspace snapshots found.")
return
for snapshot in snapshots:
if not isinstance(snapshot, dict):
continue
print(
f"{str(snapshot.get('snapshot_name', 'unknown'))} "
f"[{str(snapshot.get('kind', 'unknown'))}] "
f"entry_count={int(snapshot.get('entry_count', 0))} "
f"bytes_written={int(snapshot.get('bytes_written', 0))} "
f"deletable={'yes' if bool(snapshot.get('deletable', False)) else 'no'}"
)
def _print_workspace_reset_human(payload: dict[str, Any]) -> None:
_print_workspace_summary_human(payload, action="Reset workspace")
workspace_reset = payload.get("workspace_reset")
if isinstance(workspace_reset, dict):
print(
"Reset source: "
f"{str(workspace_reset.get('snapshot_name', 'unknown'))} "
f"({str(workspace_reset.get('kind', 'unknown'))})"
)
print(
"Reset restore: "
f"destination={str(workspace_reset.get('destination', WORKSPACE_GUEST_PATH))} "
f"entry_count={int(workspace_reset.get('entry_count', 0))} "
f"bytes_written={int(workspace_reset.get('bytes_written', 0))}"
)
def _print_workspace_shell_summary_human(payload: dict[str, Any], *, prefix: str) -> None:
print(
f"[{prefix}] "
@ -592,6 +645,8 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./repo --dest src
pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID
@ -617,6 +672,8 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace create debian:12
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace diff WORKSPACE_ID
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
@ -720,7 +777,8 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace sync push WORKSPACE_ID ./repo
pyro workspace sync push WORKSPACE_ID ./patches --dest src
Sync is non-atomic. If a sync fails partway through, delete and recreate the workspace.
Sync is non-atomic. If a sync fails partway through, prefer reset over repair with
`pyro workspace reset WORKSPACE_ID`.
"""
),
formatter_class=_HelpFormatter,
@ -808,6 +866,100 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_snapshot_parser = workspace_subparsers.add_parser(
"snapshot",
help="Create, list, and delete workspace snapshots.",
description=(
"Manage explicit named snapshots in addition to the implicit create-time baseline."
),
epilog=dedent(
"""
Examples:
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace snapshot list WORKSPACE_ID
pyro workspace snapshot delete WORKSPACE_ID checkpoint
Use `workspace reset` to restore `/workspace` from `baseline` or one named snapshot.
"""
),
formatter_class=_HelpFormatter,
)
workspace_snapshot_subparsers = workspace_snapshot_parser.add_subparsers(
dest="workspace_snapshot_command",
required=True,
metavar="SNAPSHOT",
)
workspace_snapshot_create_parser = workspace_snapshot_subparsers.add_parser(
"create",
help="Create one named snapshot from the current workspace.",
description="Capture the current `/workspace` tree as one named snapshot.",
epilog="Example:\n pyro workspace snapshot create WORKSPACE_ID checkpoint",
formatter_class=_HelpFormatter,
)
workspace_snapshot_create_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_snapshot_create_parser.add_argument("snapshot_name", metavar="SNAPSHOT_NAME")
workspace_snapshot_create_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_snapshot_list_parser = workspace_snapshot_subparsers.add_parser(
"list",
help="List the baseline plus named snapshots.",
description="List the implicit baseline snapshot plus any named snapshots for a workspace.",
epilog="Example:\n pyro workspace snapshot list WORKSPACE_ID",
formatter_class=_HelpFormatter,
)
workspace_snapshot_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_snapshot_list_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_snapshot_delete_parser = workspace_snapshot_subparsers.add_parser(
"delete",
help="Delete one named snapshot.",
description="Delete one named snapshot while leaving the implicit baseline intact.",
epilog="Example:\n pyro workspace snapshot delete WORKSPACE_ID checkpoint",
formatter_class=_HelpFormatter,
)
workspace_snapshot_delete_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_snapshot_delete_parser.add_argument("snapshot_name", metavar="SNAPSHOT_NAME")
workspace_snapshot_delete_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_reset_parser = workspace_subparsers.add_parser(
"reset",
help="Recreate a workspace from baseline or one named snapshot.",
description=(
"Recreate the full sandbox and restore `/workspace` from the baseline "
"or one named snapshot."
),
epilog=dedent(
"""
Examples:
pyro workspace reset WORKSPACE_ID
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
Prefer reset over repair: reset clears command history, shells, and services so the
workspace comes back clean from `baseline` or one named snapshot.
"""
),
formatter_class=_HelpFormatter,
)
workspace_reset_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_reset_parser.add_argument(
"--snapshot",
default="baseline",
help="Snapshot name to restore. Defaults to the implicit `baseline` snapshot.",
)
workspace_reset_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_shell_parser = workspace_subparsers.add_parser(
"shell",
help="Open and manage persistent interactive shells.",
@ -1483,6 +1635,72 @@ def main() -> None:
raise SystemExit(1) from exc
_print_workspace_diff_human(payload)
return
if args.workspace_command == "snapshot":
if args.workspace_snapshot_command == "create":
try:
payload = pyro.create_snapshot(args.workspace_id, args.snapshot_name)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_snapshot_human(
payload,
prefix="workspace-snapshot-create",
)
return
if args.workspace_snapshot_command == "list":
try:
payload = pyro.list_snapshots(args.workspace_id)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_snapshot_list_human(payload)
return
if args.workspace_snapshot_command == "delete":
try:
payload = pyro.delete_snapshot(args.workspace_id, args.snapshot_name)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
print(
"Deleted workspace snapshot: "
f"{str(payload.get('snapshot_name', 'unknown'))}"
)
return
if args.workspace_command == "reset":
try:
payload = pyro.reset_workspace(
args.workspace_id,
snapshot=args.snapshot,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_reset_human(payload)
return
if args.workspace_command == "shell":
if args.workspace_shell_command == "open":
try: