Add model-native workspace file operations
Remove shell-escaped file mutation from the stable workspace flow by adding explicit file and patch tools across the CLI, SDK, and MCP surfaces. This adds workspace file list/read/write plus unified text patch application, backed by new guest and manager file primitives that stay scoped to started workspaces and /workspace only. Patch application is preflighted on the host, file writes stay text-only and bounded, and the existing diff/export/reset semantics remain intact. The milestone also updates the 3.2.0 roadmap, public contract, docs, examples, and versioning, and includes focused coverage for the new helper module and dispatch paths. Validation: - uv lock - UV_CACHE_DIR=.uv-cache make check - UV_CACHE_DIR=.uv-cache make dist-check - real guest-backed smoke for workspace file read, patch apply, exec, export, and delete
This commit is contained in:
parent
dbb71a3174
commit
ab02ae46c7
27 changed files with 3068 additions and 17 deletions
|
|
@ -22,6 +22,7 @@ from pyro_mcp.vm_manager import (
|
|||
DEFAULT_SERVICE_READY_TIMEOUT_SECONDS,
|
||||
DEFAULT_VCPU_COUNT,
|
||||
DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES,
|
||||
DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES,
|
||||
WORKSPACE_GUEST_PATH,
|
||||
WORKSPACE_SHELL_SIGNAL_NAMES,
|
||||
)
|
||||
|
|
@ -322,6 +323,72 @@ def _print_workspace_diff_human(payload: dict[str, Any]) -> None:
|
|||
print(patch, end="" if patch.endswith("\n") else "\n")
|
||||
|
||||
|
||||
def _print_workspace_file_list_human(payload: dict[str, Any]) -> None:
|
||||
print(
|
||||
f"Workspace path: {str(payload.get('path', WORKSPACE_GUEST_PATH))} "
|
||||
f"(recursive={'yes' if bool(payload.get('recursive')) else 'no'})"
|
||||
)
|
||||
entries = payload.get("entries")
|
||||
if not isinstance(entries, list) or not entries:
|
||||
print("No workspace entries found.")
|
||||
return
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
line = (
|
||||
f"{str(entry.get('path', 'unknown'))} "
|
||||
f"[{str(entry.get('artifact_type', 'unknown'))}] "
|
||||
f"size={int(entry.get('size_bytes', 0))}"
|
||||
)
|
||||
link_target = entry.get("link_target")
|
||||
if isinstance(link_target, str) and link_target != "":
|
||||
line += f" -> {link_target}"
|
||||
print(line)
|
||||
|
||||
|
||||
def _print_workspace_file_read_human(payload: dict[str, Any]) -> None:
|
||||
_write_stream(str(payload.get("content", "")), stream=sys.stdout)
|
||||
print(
|
||||
"[workspace-file-read] "
|
||||
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
|
||||
f"path={str(payload.get('path', 'unknown'))} "
|
||||
f"size_bytes={int(payload.get('size_bytes', 0))} "
|
||||
f"truncated={'yes' if bool(payload.get('truncated', False)) else 'no'}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _print_workspace_file_write_human(payload: dict[str, Any]) -> None:
|
||||
print(
|
||||
"[workspace-file-write] "
|
||||
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
|
||||
f"path={str(payload.get('path', 'unknown'))} "
|
||||
f"bytes_written={int(payload.get('bytes_written', 0))} "
|
||||
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
|
||||
)
|
||||
|
||||
|
||||
def _print_workspace_patch_human(payload: dict[str, Any]) -> None:
|
||||
summary = payload.get("summary")
|
||||
if isinstance(summary, dict):
|
||||
print(
|
||||
"[workspace-patch] "
|
||||
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"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
|
||||
)
|
||||
return
|
||||
print(
|
||||
"[workspace-patch] "
|
||||
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
|
||||
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
|
||||
)
|
||||
|
||||
|
||||
def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
|
||||
entries = payload.get("entries")
|
||||
if not isinstance(entries, list) or not entries:
|
||||
|
|
@ -733,6 +800,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace sync push WORKSPACE_ID ./repo --dest src
|
||||
pyro workspace file read WORKSPACE_ID src/app.py
|
||||
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
|
||||
pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt'
|
||||
pyro workspace stop WORKSPACE_ID
|
||||
pyro workspace disk list WORKSPACE_ID
|
||||
|
|
@ -996,6 +1065,145 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_file_parser = workspace_subparsers.add_parser(
|
||||
"file",
|
||||
help="List, read, and write workspace files without shell quoting.",
|
||||
description=(
|
||||
"Use workspace file operations for model-native tree inspection and text edits "
|
||||
"inside one started workspace."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace file list WORKSPACE_ID
|
||||
pyro workspace file read WORKSPACE_ID src/app.py
|
||||
pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")'
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_file_subparsers = workspace_file_parser.add_subparsers(
|
||||
dest="workspace_file_command",
|
||||
required=True,
|
||||
metavar="FILE",
|
||||
)
|
||||
workspace_file_list_parser = workspace_file_subparsers.add_parser(
|
||||
"list",
|
||||
help="List metadata for one live workspace path.",
|
||||
description="List files, directories, and symlinks under one started workspace path.",
|
||||
epilog="Example:\n pyro workspace file list WORKSPACE_ID src --recursive",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_file_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_file_list_parser.add_argument(
|
||||
"path",
|
||||
nargs="?",
|
||||
default=WORKSPACE_GUEST_PATH,
|
||||
metavar="PATH",
|
||||
help="Workspace path to inspect. Relative values resolve inside `/workspace`.",
|
||||
)
|
||||
workspace_file_list_parser.add_argument(
|
||||
"--recursive",
|
||||
action="store_true",
|
||||
help="Walk directories recursively.",
|
||||
)
|
||||
workspace_file_list_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_file_read_parser = workspace_file_subparsers.add_parser(
|
||||
"read",
|
||||
help="Read one regular text file from a started workspace.",
|
||||
description=(
|
||||
"Read one regular text file under `/workspace`. This is bounded and does not "
|
||||
"follow symlinks."
|
||||
),
|
||||
epilog="Example:\n pyro workspace file read WORKSPACE_ID src/app.py",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_file_read_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_file_read_parser.add_argument("path", metavar="PATH")
|
||||
workspace_file_read_parser.add_argument(
|
||||
"--max-bytes",
|
||||
type=int,
|
||||
default=DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES,
|
||||
help="Maximum number of bytes to return in the decoded text response.",
|
||||
)
|
||||
workspace_file_read_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_file_write_parser = workspace_file_subparsers.add_parser(
|
||||
"write",
|
||||
help="Create or replace one regular text file in a started workspace.",
|
||||
description=(
|
||||
"Write one UTF-8 text file under `/workspace`. Missing parent directories are "
|
||||
"created automatically."
|
||||
),
|
||||
epilog=(
|
||||
"Example:\n"
|
||||
" pyro workspace file write WORKSPACE_ID src/app.py --text 'print(\"hi\")'"
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_file_write_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_file_write_parser.add_argument("path", metavar="PATH")
|
||||
workspace_file_write_parser.add_argument(
|
||||
"--text",
|
||||
required=True,
|
||||
help="UTF-8 text content to write into the target file.",
|
||||
)
|
||||
workspace_file_write_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_patch_parser = workspace_subparsers.add_parser(
|
||||
"patch",
|
||||
help="Apply unified text patches inside a started workspace.",
|
||||
description=(
|
||||
"Apply add/modify/delete unified text patches under `/workspace` without shell "
|
||||
"editing tricks."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Example:
|
||||
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
|
||||
|
||||
Patch application is preflighted but not fully transactional. If an apply fails
|
||||
partway through, prefer `pyro workspace reset WORKSPACE_ID`.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_patch_subparsers = workspace_patch_parser.add_subparsers(
|
||||
dest="workspace_patch_command",
|
||||
required=True,
|
||||
metavar="PATCH",
|
||||
)
|
||||
workspace_patch_apply_parser = workspace_patch_subparsers.add_parser(
|
||||
"apply",
|
||||
help="Apply one unified text patch to a started workspace.",
|
||||
description=(
|
||||
"Apply one unified text patch for add, modify, and delete operations under "
|
||||
"`/workspace`."
|
||||
),
|
||||
epilog="Example:\n pyro workspace patch apply WORKSPACE_ID --patch \"$(cat fix.patch)\"",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_patch_apply_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_patch_apply_parser.add_argument(
|
||||
"--patch",
|
||||
required=True,
|
||||
help="Unified text patch to apply under `/workspace`.",
|
||||
)
|
||||
workspace_patch_apply_parser.add_argument(
|
||||
"--json",
|
||||
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.",
|
||||
|
|
@ -2005,6 +2213,78 @@ def main() -> None:
|
|||
raise SystemExit(1) from exc
|
||||
_print_workspace_diff_human(payload)
|
||||
return
|
||||
if args.workspace_command == "file":
|
||||
if args.workspace_file_command == "list":
|
||||
try:
|
||||
payload = pyro.list_workspace_files(
|
||||
args.workspace_id,
|
||||
path=args.path,
|
||||
recursive=bool(args.recursive),
|
||||
)
|
||||
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_file_list_human(payload)
|
||||
return
|
||||
if args.workspace_file_command == "read":
|
||||
try:
|
||||
payload = pyro.read_workspace_file(
|
||||
args.workspace_id,
|
||||
args.path,
|
||||
max_bytes=args.max_bytes,
|
||||
)
|
||||
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_file_read_human(payload)
|
||||
return
|
||||
if args.workspace_file_command == "write":
|
||||
try:
|
||||
payload = pyro.write_workspace_file(
|
||||
args.workspace_id,
|
||||
args.path,
|
||||
text=args.text,
|
||||
)
|
||||
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_file_write_human(payload)
|
||||
return
|
||||
if args.workspace_command == "patch" and args.workspace_patch_command == "apply":
|
||||
try:
|
||||
payload = pyro.apply_workspace_patch(
|
||||
args.workspace_id,
|
||||
patch=args.patch,
|
||||
)
|
||||
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_patch_human(payload)
|
||||
return
|
||||
if args.workspace_command == "snapshot":
|
||||
if args.workspace_snapshot_command == "create":
|
||||
try:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue