Add workspace export and baseline diff

Complete the 2.6.0 workspace milestone by adding explicit host-out export and immutable-baseline diff across the CLI, Python SDK, and MCP server.

Capture a baseline archive at workspace creation, export live /workspace paths through the guest agent, and compute structured whole-workspace diffs on the host without affecting command logs or shell state. The docs, roadmap, bundled guest agent, and workspace example now reflect the new create -> sync -> diff -> export workflow.

Validation: uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed Firecracker smoke covering workspace create, sync push, diff, export, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 03:15:45 -03:00
parent 3f8293ad24
commit 84a7e18d4d
26 changed files with 1492 additions and 43 deletions

View file

@ -215,6 +215,41 @@ def _print_workspace_sync_human(payload: dict[str, Any]) -> None:
)
def _print_workspace_export_human(payload: dict[str, Any]) -> None:
print(
"[workspace-export] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"workspace_path={str(payload.get('workspace_path', WORKSPACE_GUEST_PATH))} "
f"output_path={str(payload.get('output_path', 'unknown'))} "
f"artifact_type={str(payload.get('artifact_type', 'unknown'))} "
f"entry_count={int(payload.get('entry_count', 0))} "
f"bytes_written={int(payload.get('bytes_written', 0))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
)
def _print_workspace_diff_human(payload: dict[str, Any]) -> None:
if not bool(payload.get("changed")):
print("No workspace changes.")
return
summary = payload.get("summary")
if isinstance(summary, dict):
print(
"[workspace-diff] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"total={int(summary.get('total', 0))} "
f"added={int(summary.get('added', 0))} "
f"modified={int(summary.get('modified', 0))} "
f"deleted={int(summary.get('deleted', 0))} "
f"type_changed={int(summary.get('type_changed', 0))} "
f"text_patched={int(summary.get('text_patched', 0))} "
f"non_text={int(summary.get('non_text', 0))}"
)
patch = str(payload.get("patch", ""))
if patch != "":
print(patch, end="" if patch.endswith("\n") else "\n")
def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
entries = payload.get("entries")
if not isinstance(entries, list) or not entries:
@ -301,6 +336,8 @@ def _build_parser() -> argparse.ArgumentParser:
Need repeated commands in one workspace after that?
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID
Use `pyro mcp serve` only after the CLI validation path works.
@ -509,6 +546,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 diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID
pyro workspace logs WORKSPACE_ID
"""
@ -530,6 +569,7 @@ 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 diff WORKSPACE_ID
"""
),
formatter_class=_HelpFormatter,
@ -667,6 +707,57 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_export_parser = workspace_subparsers.add_parser(
"export",
help="Export one workspace path to the host.",
description="Export one file or directory from `/workspace` to an explicit host path.",
epilog="Example:\n pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt",
formatter_class=_HelpFormatter,
)
workspace_export_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_export_parser.add_argument(
"path",
metavar="PATH",
help="Workspace path to export. Relative values resolve inside `/workspace`.",
)
workspace_export_parser.add_argument(
"--output",
required=True,
help="Exact host path to create for the exported file or directory.",
)
workspace_export_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_diff_parser = workspace_subparsers.add_parser(
"diff",
help="Diff `/workspace` against the create-time baseline.",
description="Compare the current `/workspace` tree to the immutable workspace baseline.",
epilog=dedent(
"""
Example:
pyro workspace diff WORKSPACE_ID
Use `workspace export` to copy a changed file or directory back to the host.
"""
),
formatter_class=_HelpFormatter,
)
workspace_diff_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_diff_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.",
@ -1148,6 +1239,46 @@ def main() -> None:
raise SystemExit(1) from exc
_print_workspace_sync_human(payload)
return
if args.workspace_command == "export":
if bool(args.json):
try:
payload = pyro.export_workspace(
args.workspace_id,
args.path,
output_path=args.output,
)
except Exception as exc: # noqa: BLE001
_print_json({"ok": False, "error": str(exc)})
raise SystemExit(1) from exc
_print_json(payload)
else:
try:
payload = pyro.export_workspace(
args.workspace_id,
args.path,
output_path=args.output,
)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_workspace_export_human(payload)
return
if args.workspace_command == "diff":
if bool(args.json):
try:
payload = pyro.diff_workspace(args.workspace_id)
except Exception as exc: # noqa: BLE001
_print_json({"ok": False, "error": str(exc)})
raise SystemExit(1) from exc
_print_json(payload)
else:
try:
payload = pyro.diff_workspace(args.workspace_id)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_workspace_diff_human(payload)
return
if args.workspace_command == "shell":
if args.workspace_shell_command == "open":
try: