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:
parent
3f8293ad24
commit
84a7e18d4d
26 changed files with 1492 additions and 43 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue