Add workspace handoff shortcuts and file-backed inputs
Remove the remaining shell glue from the canonical CLI workspace flows so users can hand off IDs and host-authored text files directly. Add --id-only on workspace create and shell open, plus --text-file and --patch-file for workspace file write and patch apply, while keeping the underlying SDK, MCP, and backend behavior unchanged. Update the top walkthroughs, contract docs, roadmap status, and use-case smoke runner to use the new shortcuts, and verify the milestone with uv lock, make check, make dist-check, focused CLI tests, and a real guest-backed smoke for create, file write, patch apply, and shell open/read.
This commit is contained in:
parent
788fc4fad4
commit
7a0620fc0c
15 changed files with 466 additions and 79 deletions
|
|
@ -72,6 +72,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
workspace_help = _subparser_choice(parser, "workspace").format_help()
|
||||
assert "stable workspace contract" in workspace_help
|
||||
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
|
||||
assert "--id-only" in workspace_help
|
||||
assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help
|
||||
assert "pyro workspace list" in workspace_help
|
||||
assert (
|
||||
|
|
@ -88,12 +89,13 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "pyro workspace start WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_help
|
||||
assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in workspace_help
|
||||
assert "pyro workspace shell open WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_help
|
||||
|
||||
workspace_create_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"create",
|
||||
).format_help()
|
||||
assert "--id-only" in workspace_create_help
|
||||
assert "--name" in workspace_create_help
|
||||
assert "--label" in workspace_create_help
|
||||
assert "--seed-path" in workspace_create_help
|
||||
|
|
@ -161,6 +163,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
_subparser_choice(_subparser_choice(parser, "workspace"), "file"), "write"
|
||||
).format_help()
|
||||
assert "--text" in workspace_file_write_help
|
||||
assert "--text-file" in workspace_file_write_help
|
||||
|
||||
workspace_patch_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "patch"
|
||||
|
|
@ -171,6 +174,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
_subparser_choice(_subparser_choice(parser, "workspace"), "patch"), "apply"
|
||||
).format_help()
|
||||
assert "--patch" in workspace_patch_apply_help
|
||||
assert "--patch-file" in workspace_patch_apply_help
|
||||
|
||||
workspace_stop_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "stop"
|
||||
|
|
@ -241,7 +245,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
_subparser_choice(parser, "workspace"),
|
||||
"shell",
|
||||
).format_help()
|
||||
assert "pyro workspace shell open WORKSPACE_ID" in workspace_shell_help
|
||||
assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_shell_help
|
||||
assert "Use `workspace exec` for one-shot commands." in workspace_shell_help
|
||||
|
||||
workspace_service_help = _subparser_choice(
|
||||
|
|
@ -269,6 +273,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
workspace_shell_open_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "open"
|
||||
).format_help()
|
||||
assert "--id-only" in workspace_shell_open_help
|
||||
assert "--cwd" in workspace_shell_open_help
|
||||
assert "--cols" in workspace_shell_open_help
|
||||
assert "--rows" in workspace_shell_open_help
|
||||
|
|
@ -563,6 +568,75 @@ def test_cli_requires_command_preserves_shell_argument_boundaries() -> None:
|
|||
assert command == 'sh -lc \'printf "hello from workspace\\n" > note.txt\''
|
||||
|
||||
|
||||
def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None:
|
||||
source_path = tmp_path / "bad.txt"
|
||||
source_path.write_bytes(b"\xff\xfe")
|
||||
|
||||
with pytest.raises(ValueError, match="must contain UTF-8 text"):
|
||||
cli._read_utf8_text_file(str(source_path), option_name="--text-file")
|
||||
|
||||
|
||||
def test_cli_read_utf8_text_file_rejects_empty_path() -> None:
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
cli._read_utf8_text_file("", option_name="--patch-file")
|
||||
|
||||
|
||||
def test_cli_shortcut_flags_are_mutually_exclusive() -> None:
|
||||
parser = cli._build_parser()
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(
|
||||
[
|
||||
"workspace",
|
||||
"create",
|
||||
"debian:12",
|
||||
"--json",
|
||||
"--id-only",
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(
|
||||
[
|
||||
"workspace",
|
||||
"shell",
|
||||
"open",
|
||||
"workspace-123",
|
||||
"--json",
|
||||
"--id-only",
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(
|
||||
[
|
||||
"workspace",
|
||||
"file",
|
||||
"write",
|
||||
"workspace-123",
|
||||
"src/app.py",
|
||||
"--text",
|
||||
"hello",
|
||||
"--text-file",
|
||||
"./app.py",
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(
|
||||
[
|
||||
"workspace",
|
||||
"patch",
|
||||
"apply",
|
||||
"workspace-123",
|
||||
"--patch",
|
||||
"--- a/app.py\n+++ b/app.py\n",
|
||||
"--patch-file",
|
||||
"./fix.patch",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_cli_workspace_create_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
|
|
@ -601,6 +675,42 @@ def test_cli_workspace_create_prints_json(
|
|||
assert output["workspace_id"] == "workspace-123"
|
||||
|
||||
|
||||
def test_cli_workspace_create_prints_id_only(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def create_workspace(self, **kwargs: Any) -> dict[str, Any]:
|
||||
assert kwargs["environment"] == "debian:12"
|
||||
return {"workspace_id": "workspace-123", "state": "started"}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="create",
|
||||
environment="debian:12",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network_policy="off",
|
||||
allow_host_compat=False,
|
||||
seed_path=None,
|
||||
name=None,
|
||||
label=[],
|
||||
secret=[],
|
||||
secret_file=[],
|
||||
json=False,
|
||||
id_only=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "workspace-123\n"
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_cli_workspace_create_prints_human(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
|
|
@ -1126,6 +1236,105 @@ def test_cli_workspace_file_commands_print_human_and_json(
|
|||
assert "[workspace-patch] workspace_id=workspace-123 total=1" in patch_output
|
||||
|
||||
|
||||
def test_cli_workspace_file_write_reads_text_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source_path = tmp_path / "app.py"
|
||||
source_path.write_text("print('from file')\n", encoding="utf-8")
|
||||
|
||||
class StubPyro:
|
||||
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('from file')\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",
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
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=None,
|
||||
text_file=str(source_path),
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "[workspace-file-write] workspace_id=workspace-123" in output
|
||||
|
||||
|
||||
def test_cli_workspace_patch_apply_reads_patch_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
patch_path = tmp_path / "fix.patch"
|
||||
patch_text = (
|
||||
"--- a/src/app.py\n"
|
||||
"+++ b/src/app.py\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-print('hi')\n"
|
||||
"+print('hello')\n"
|
||||
)
|
||||
patch_path.write_text(patch_text, encoding="utf-8")
|
||||
|
||||
class StubPyro:
|
||||
def apply_workspace_patch(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
patch: str,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert patch == patch_text
|
||||
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 StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="patch",
|
||||
workspace_patch_command="apply",
|
||||
workspace_id="workspace-123",
|
||||
patch=None,
|
||||
patch_file=str(patch_path),
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "[workspace-patch] workspace_id=workspace-123 total=1" in output
|
||||
|
||||
|
||||
def test_cli_workspace_stop_and_start_print_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
|
@ -2333,6 +2542,61 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
assert "wait_for_idle_ms=300" in captured.err
|
||||
|
||||
|
||||
def test_cli_workspace_shell_open_prints_id_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def open_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert cwd == "/workspace"
|
||||
assert cols == 120
|
||||
assert rows == 30
|
||||
assert secret_env is None
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": "shell-123",
|
||||
"state": "running",
|
||||
"cwd": cwd,
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "guest_vsock",
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="shell",
|
||||
workspace_shell_command="open",
|
||||
workspace_id="workspace-123",
|
||||
cwd="/workspace",
|
||||
cols=120,
|
||||
rows=30,
|
||||
secret_env=[],
|
||||
json=False,
|
||||
id_only=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "shell-123\n"
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_cli_workspace_shell_write_signal_close_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue