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
|
|
@ -116,6 +116,37 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "--output" in workspace_export_help
|
||||
assert "Export one file or directory from `/workspace`" in workspace_export_help
|
||||
|
||||
workspace_file_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "file"
|
||||
).format_help()
|
||||
assert "model-native tree inspection and text edits" in workspace_file_help
|
||||
assert "pyro workspace file read WORKSPACE_ID src/app.py" in workspace_file_help
|
||||
|
||||
workspace_file_list_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "file"), "list"
|
||||
).format_help()
|
||||
assert "--recursive" in workspace_file_list_help
|
||||
|
||||
workspace_file_read_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "file"), "read"
|
||||
).format_help()
|
||||
assert "--max-bytes" in workspace_file_read_help
|
||||
|
||||
workspace_file_write_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "file"), "write"
|
||||
).format_help()
|
||||
assert "--text" in workspace_file_write_help
|
||||
|
||||
workspace_patch_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "patch"
|
||||
).format_help()
|
||||
assert "Apply add/modify/delete unified text patches" in workspace_patch_help
|
||||
|
||||
workspace_patch_apply_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "patch"), "apply"
|
||||
).format_help()
|
||||
assert "--patch" in workspace_patch_apply_help
|
||||
|
||||
workspace_stop_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "stop"
|
||||
).format_help()
|
||||
|
|
@ -682,6 +713,169 @@ def test_cli_workspace_export_prints_human_output(
|
|||
assert "artifact_type=file" in output
|
||||
|
||||
|
||||
def test_cli_workspace_file_commands_print_human_and_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def list_workspace_files(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
path: str,
|
||||
recursive: bool,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert path == "/workspace/src"
|
||||
assert recursive is True
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"path": path,
|
||||
"recursive": recursive,
|
||||
"entries": [
|
||||
{
|
||||
"path": "/workspace/src/app.py",
|
||||
"artifact_type": "file",
|
||||
"size_bytes": 14,
|
||||
"link_target": None,
|
||||
}
|
||||
],
|
||||
"execution_mode": "guest_vsock",
|
||||
}
|
||||
|
||||
def read_workspace_file(
|
||||
self,
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
*,
|
||||
max_bytes: int,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert path == "src/app.py"
|
||||
assert max_bytes == 4096
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"path": "/workspace/src/app.py",
|
||||
"size_bytes": 14,
|
||||
"max_bytes": max_bytes,
|
||||
"content": "print('hi')\n",
|
||||
"truncated": False,
|
||||
"execution_mode": "guest_vsock",
|
||||
}
|
||||
|
||||
def write_workspace_file(
|
||||
self,
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
*,
|
||||
text: str,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert path == "src/app.py"
|
||||
assert text == "print('hello')\n"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"path": "/workspace/src/app.py",
|
||||
"size_bytes": len(text.encode("utf-8")),
|
||||
"bytes_written": len(text.encode("utf-8")),
|
||||
"execution_mode": "guest_vsock",
|
||||
}
|
||||
|
||||
def apply_workspace_patch(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
patch: str,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert patch.startswith("--- a/src/app.py")
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"changed": True,
|
||||
"summary": {"total": 1, "added": 0, "modified": 1, "deleted": 0},
|
||||
"entries": [{"path": "/workspace/src/app.py", "status": "modified"}],
|
||||
"patch": patch,
|
||||
"execution_mode": "guest_vsock",
|
||||
}
|
||||
|
||||
class ListParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="file",
|
||||
workspace_file_command="list",
|
||||
workspace_id="workspace-123",
|
||||
path="/workspace/src",
|
||||
recursive=True,
|
||||
json=False,
|
||||
)
|
||||
|
||||
class ReadParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="file",
|
||||
workspace_file_command="read",
|
||||
workspace_id="workspace-123",
|
||||
path="src/app.py",
|
||||
max_bytes=4096,
|
||||
json=True,
|
||||
)
|
||||
|
||||
class WriteParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="file",
|
||||
workspace_file_command="write",
|
||||
workspace_id="workspace-123",
|
||||
path="src/app.py",
|
||||
text="print('hello')\n",
|
||||
json=False,
|
||||
)
|
||||
|
||||
class PatchParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="patch",
|
||||
workspace_patch_command="apply",
|
||||
workspace_id="workspace-123",
|
||||
patch=(
|
||||
"--- a/src/app.py\n"
|
||||
"+++ b/src/app.py\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-print('hi')\n"
|
||||
"+print('hello')\n"
|
||||
),
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: ListParser())
|
||||
cli.main()
|
||||
list_output = capsys.readouterr().out
|
||||
assert "Workspace path: /workspace/src (recursive=yes)" in list_output
|
||||
assert "/workspace/src/app.py [file]" in list_output
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: ReadParser())
|
||||
cli.main()
|
||||
read_payload = json.loads(capsys.readouterr().out)
|
||||
assert read_payload["path"] == "/workspace/src/app.py"
|
||||
assert read_payload["content"] == "print('hi')\n"
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: WriteParser())
|
||||
cli.main()
|
||||
write_output = capsys.readouterr().out
|
||||
assert "[workspace-file-write] workspace_id=workspace-123" in write_output
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: PatchParser())
|
||||
cli.main()
|
||||
patch_output = capsys.readouterr().out
|
||||
assert "[workspace-patch] workspace_id=workspace-123 total=1" in patch_output
|
||||
|
||||
|
||||
def test_cli_workspace_stop_and_start_print_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue