Add persistent workspace shell sessions
Let agents inhabit a workspace across separate calls instead of only submitting one-shot execs. Add workspace shell open/read/write/signal/close across the CLI, Python SDK, and MCP server, with persisted shell records, a local PTY-backed mock implementation, and guest-agent support for real Firecracker workspaces. Mark the 2.5.0 roadmap milestone done, refresh docs/examples and the release metadata, and verify with uv lock, UV_CACHE_DIR=.uv-cache make check, and UV_CACHE_DIR=.uv-cache make dist-check.
This commit is contained in:
parent
2de31306b6
commit
3f8293ad24
28 changed files with 3265 additions and 81 deletions
|
|
@ -64,6 +64,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
|
||||
assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help
|
||||
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace shell open WORKSPACE_ID" in workspace_help
|
||||
|
||||
workspace_create_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
|
|
@ -92,6 +93,41 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "--dest" in workspace_sync_push_help
|
||||
assert "Import host content into `/workspace`" in workspace_sync_push_help
|
||||
|
||||
workspace_shell_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"shell",
|
||||
).format_help()
|
||||
assert "pyro workspace shell open WORKSPACE_ID" in workspace_shell_help
|
||||
assert "Use `workspace exec` for one-shot commands." in workspace_shell_help
|
||||
|
||||
workspace_shell_open_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "open"
|
||||
).format_help()
|
||||
assert "--cwd" in workspace_shell_open_help
|
||||
assert "--cols" in workspace_shell_open_help
|
||||
assert "--rows" in workspace_shell_open_help
|
||||
|
||||
workspace_shell_read_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read"
|
||||
).format_help()
|
||||
assert "Shell output is written to stdout." in workspace_shell_read_help
|
||||
|
||||
workspace_shell_write_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "write"
|
||||
).format_help()
|
||||
assert "--input" in workspace_shell_write_help
|
||||
assert "--no-newline" in workspace_shell_write_help
|
||||
|
||||
workspace_shell_signal_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "signal"
|
||||
).format_help()
|
||||
assert "--signal" in workspace_shell_signal_help
|
||||
|
||||
workspace_shell_close_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "close"
|
||||
).format_help()
|
||||
assert "Close a persistent workspace shell" in workspace_shell_close_help
|
||||
|
||||
|
||||
def test_cli_run_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -681,6 +717,226 @@ def test_cli_workspace_status_and_delete_print_json(
|
|||
assert deleted["deleted"] is True
|
||||
|
||||
|
||||
def test_cli_workspace_shell_open_and_read_human(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def open_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert cwd == "/workspace"
|
||||
assert cols == 120
|
||||
assert rows == 30
|
||||
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",
|
||||
}
|
||||
|
||||
def read_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
*,
|
||||
cursor: int,
|
||||
max_chars: int,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert shell_id == "shell-123"
|
||||
assert cursor == 0
|
||||
assert max_chars == 1024
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"state": "running",
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "guest_vsock",
|
||||
"cursor": 0,
|
||||
"next_cursor": 14,
|
||||
"output": "pyro$ pwd\n",
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
class OpenParser:
|
||||
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,
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: OpenParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
|
||||
class ReadParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="shell",
|
||||
workspace_shell_command="read",
|
||||
workspace_id="workspace-123",
|
||||
shell_id="shell-123",
|
||||
cursor=0,
|
||||
max_chars=1024,
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: ReadParser())
|
||||
cli.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "pyro$ pwd\n" in captured.out
|
||||
assert "[workspace-shell-open] workspace_id=workspace-123 shell_id=shell-123" in captured.err
|
||||
assert "[workspace-shell-read] workspace_id=workspace-123 shell_id=shell-123" in captured.err
|
||||
|
||||
|
||||
def test_cli_workspace_shell_write_signal_close_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def write_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
*,
|
||||
input: str,
|
||||
append_newline: bool,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert shell_id == "shell-123"
|
||||
assert input == "pwd"
|
||||
assert append_newline is False
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"state": "running",
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "guest_vsock",
|
||||
"input_length": 3,
|
||||
"append_newline": False,
|
||||
}
|
||||
|
||||
def signal_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
*,
|
||||
signal_name: str,
|
||||
) -> dict[str, Any]:
|
||||
assert signal_name == "INT"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"state": "running",
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "guest_vsock",
|
||||
"signal": signal_name,
|
||||
}
|
||||
|
||||
def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"state": "stopped",
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"started_at": 1.0,
|
||||
"ended_at": 2.0,
|
||||
"exit_code": 0,
|
||||
"execution_mode": "guest_vsock",
|
||||
"closed": True,
|
||||
}
|
||||
|
||||
class WriteParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="shell",
|
||||
workspace_shell_command="write",
|
||||
workspace_id="workspace-123",
|
||||
shell_id="shell-123",
|
||||
input="pwd",
|
||||
no_newline=True,
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: WriteParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
written = json.loads(capsys.readouterr().out)
|
||||
assert written["append_newline"] is False
|
||||
|
||||
class SignalParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="shell",
|
||||
workspace_shell_command="signal",
|
||||
workspace_id="workspace-123",
|
||||
shell_id="shell-123",
|
||||
signal="INT",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SignalParser())
|
||||
cli.main()
|
||||
signaled = json.loads(capsys.readouterr().out)
|
||||
assert signaled["signal"] == "INT"
|
||||
|
||||
class CloseParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="shell",
|
||||
workspace_shell_command="close",
|
||||
workspace_id="workspace-123",
|
||||
shell_id="shell-123",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: CloseParser())
|
||||
cli.main()
|
||||
closed = json.loads(capsys.readouterr().out)
|
||||
assert closed["closed"] is True
|
||||
|
||||
|
||||
def test_cli_workspace_exec_json_error_exits_nonzero(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue