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:
Thales Maciel 2026-03-13 11:10:11 -03:00
parent 788fc4fad4
commit 7a0620fc0c
15 changed files with 466 additions and 79 deletions

View file

@ -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],