Pivot persistent APIs to workspaces

Replace the public persistent-sandbox contract with workspace-first naming across CLI, SDK, MCP, payloads, and on-disk state.

Rename the task surface to workspace equivalents, switch create-time seeding to `seed_path`, and store records under `workspaces/<workspace_id>/workspace.json` without carrying legacy task aliases or migrating old local task state.

Keep `pyro run` and `vm_*` unchanged. Validation covered `uv lock`, focused public-contract/API/CLI/manager tests, `UV_CACHE_DIR=.uv-cache make check`, and `UV_CACHE_DIR=.uv-cache make dist-check`.
This commit is contained in:
Thales Maciel 2026-03-12 01:21:49 -03:00
parent f57454bcb4
commit 48b82d8386
13 changed files with 743 additions and 618 deletions

View file

@ -30,7 +30,7 @@ def test_cli_help_guides_first_run() -> None:
assert "pyro env list" in help_text
assert "pyro env pull debian:12" in help_text
assert "pyro run debian:12 -- git --version" in help_text
assert "pyro task sync push TASK_ID ./changes" in help_text
assert "pyro workspace sync push WORKSPACE_ID ./changes" in help_text
assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text
@ -60,28 +60,37 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "Expose pyro tools over stdio for an MCP client." in mcp_help
assert "Use this from an MCP client config after the CLI evaluation path works." in mcp_help
task_help = _subparser_choice(parser, "task").format_help()
assert "pyro task create debian:12 --source-path ./repo" in task_help
assert "pyro task sync push TASK_ID ./repo --dest src" in task_help
assert "pyro task exec TASK_ID" in task_help
workspace_help = _subparser_choice(parser, "workspace").format_help()
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
task_create_help = _subparser_choice(_subparser_choice(parser, "task"), "create").format_help()
assert "--source-path" in task_create_help
assert "seed into `/workspace`" in task_create_help
task_exec_help = _subparser_choice(_subparser_choice(parser, "task"), "exec").format_help()
assert "persistent `/workspace`" in task_exec_help
assert "pyro task exec TASK_ID -- cat note.txt" in task_exec_help
task_sync_help = _subparser_choice(_subparser_choice(parser, "task"), "sync").format_help()
assert "Sync is non-atomic." in task_sync_help
assert "pyro task sync push TASK_ID ./repo" in task_sync_help
task_sync_push_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "task"), "sync"), "push"
workspace_create_help = _subparser_choice(
_subparser_choice(parser, "workspace"),
"create",
).format_help()
assert "--dest" in task_sync_push_help
assert "Import host content into `/workspace`" in task_sync_push_help
assert "--seed-path" in workspace_create_help
assert "seed into `/workspace`" in workspace_create_help
workspace_exec_help = _subparser_choice(
_subparser_choice(parser, "workspace"),
"exec",
).format_help()
assert "persistent `/workspace`" in workspace_exec_help
assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in workspace_exec_help
workspace_sync_help = _subparser_choice(
_subparser_choice(parser, "workspace"),
"sync",
).format_help()
assert "Sync is non-atomic." in workspace_sync_help
assert "pyro workspace sync push WORKSPACE_ID ./repo" in workspace_sync_help
workspace_sync_push_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "sync"), "push"
).format_help()
assert "--dest" in workspace_sync_push_help
assert "Import host content into `/workspace`" in workspace_sync_push_help
def test_cli_run_prints_json(
@ -344,32 +353,32 @@ def test_cli_requires_run_command() -> None:
def test_cli_requires_command_preserves_shell_argument_boundaries() -> None:
command = cli._require_command(
["--", "sh", "-lc", 'printf "hello from task\\n" > note.txt']
["--", "sh", "-lc", 'printf "hello from workspace\\n" > note.txt']
)
assert command == 'sh -lc \'printf "hello from task\\n" > note.txt\''
assert command == 'sh -lc \'printf "hello from workspace\\n" > note.txt\''
def test_cli_task_create_prints_json(
def test_cli_workspace_create_prints_json(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
class StubPyro:
def create_task(self, **kwargs: Any) -> dict[str, Any]:
def create_workspace(self, **kwargs: Any) -> dict[str, Any]:
assert kwargs["environment"] == "debian:12"
assert kwargs["source_path"] == "./repo"
return {"task_id": "task-123", "state": "started"}
assert kwargs["seed_path"] == "./repo"
return {"workspace_id": "workspace-123", "state": "started"}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="create",
command="workspace",
workspace_command="create",
environment="debian:12",
vcpu_count=1,
mem_mib=1024,
ttl_seconds=600,
network=False,
allow_host_compat=False,
source_path="./repo",
seed_path="./repo",
json=True,
)
@ -377,23 +386,23 @@ def test_cli_task_create_prints_json(
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = json.loads(capsys.readouterr().out)
assert output["task_id"] == "task-123"
assert output["workspace_id"] == "workspace-123"
def test_cli_task_create_prints_human(
def test_cli_workspace_create_prints_human(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
class StubPyro:
def create_task(self, **kwargs: Any) -> dict[str, Any]:
def create_workspace(self, **kwargs: Any) -> dict[str, Any]:
del kwargs
return {
"task_id": "task-123",
"workspace_id": "workspace-123",
"environment": "debian:12",
"state": "started",
"workspace_path": "/workspace",
"workspace_seed": {
"mode": "directory",
"source_path": "/tmp/repo",
"seed_path": "/tmp/repo",
"destination": "/workspace",
"entry_count": 1,
"bytes_written": 6,
@ -408,15 +417,15 @@ def test_cli_task_create_prints_human(
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="create",
command="workspace",
workspace_command="create",
environment="debian:12",
vcpu_count=1,
mem_mib=1024,
ttl_seconds=600,
network=False,
allow_host_compat=False,
source_path="/tmp/repo",
seed_path="/tmp/repo",
json=False,
)
@ -424,22 +433,28 @@ def test_cli_task_create_prints_human(
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "Task: task-123" in output
assert "Workspace ID: workspace-123" in output
assert "Workspace: /workspace" in output
assert "Workspace seed: directory from /tmp/repo" in output
def test_cli_task_exec_prints_human_output(
def test_cli_workspace_exec_prints_human_output(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def exec_task(self, task_id: str, *, command: str, timeout_seconds: int) -> dict[str, Any]:
assert task_id == "task-123"
def exec_workspace(
self,
workspace_id: str,
*,
command: str,
timeout_seconds: int,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert command == "cat note.txt"
assert timeout_seconds == 30
return {
"task_id": task_id,
"workspace_id": workspace_id,
"sequence": 2,
"cwd": "/workspace",
"execution_mode": "guest_vsock",
@ -452,9 +467,9 @@ def test_cli_task_exec_prints_human_output(
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="exec",
task_id="task-123",
command="workspace",
workspace_command="exec",
workspace_id="workspace-123",
timeout_seconds=30,
json=False,
command_args=["--", "cat", "note.txt"],
@ -465,19 +480,28 @@ def test_cli_task_exec_prints_human_output(
cli.main()
captured = capsys.readouterr()
assert captured.out == "hello\n"
assert "[task-exec] task_id=task-123 sequence=2 cwd=/workspace" in captured.err
assert (
"[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace"
in captured.err
)
def test_cli_task_sync_push_prints_json(
def test_cli_workspace_sync_push_prints_json(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
class StubPyro:
def push_task_sync(self, task_id: str, source_path: str, *, dest: str) -> dict[str, Any]:
assert task_id == "task-123"
def push_workspace_sync(
self,
workspace_id: str,
source_path: str,
*,
dest: str,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert source_path == "./repo"
assert dest == "src"
return {
"task_id": task_id,
"workspace_id": workspace_id,
"execution_mode": "guest_vsock",
"workspace_sync": {
"mode": "directory",
@ -491,10 +515,10 @@ def test_cli_task_sync_push_prints_json(
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="sync",
task_sync_command="push",
task_id="task-123",
command="workspace",
workspace_command="sync",
workspace_sync_command="push",
workspace_id="workspace-123",
source_path="./repo",
dest="src",
json=True,
@ -507,17 +531,23 @@ def test_cli_task_sync_push_prints_json(
assert output["workspace_sync"]["destination"] == "/workspace/src"
def test_cli_task_sync_push_prints_human(
def test_cli_workspace_sync_push_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def push_task_sync(self, task_id: str, source_path: str, *, dest: str) -> dict[str, Any]:
assert task_id == "task-123"
def push_workspace_sync(
self,
workspace_id: str,
source_path: str,
*,
dest: str,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert source_path == "./repo"
assert dest == "/workspace"
return {
"task_id": task_id,
"workspace_id": workspace_id,
"execution_mode": "guest_vsock",
"workspace_sync": {
"mode": "directory",
@ -531,10 +561,10 @@ def test_cli_task_sync_push_prints_human(
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="sync",
task_sync_command="push",
task_id="task-123",
command="workspace",
workspace_command="sync",
workspace_sync_command="push",
workspace_id="workspace-123",
source_path="./repo",
dest="/workspace",
json=False,
@ -544,22 +574,22 @@ def test_cli_task_sync_push_prints_human(
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "[task-sync] task_id=task-123 mode=directory source=/tmp/repo" in output
assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output
assert (
"destination=/workspace entry_count=2 bytes_written=12 "
"execution_mode=guest_vsock"
) in output
def test_cli_task_logs_and_delete_print_human(
def test_cli_workspace_logs_and_delete_print_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def logs_task(self, task_id: str) -> dict[str, Any]:
assert task_id == "task-123"
def logs_workspace(self, workspace_id: str) -> dict[str, Any]:
assert workspace_id == "workspace-123"
return {
"task_id": task_id,
"workspace_id": workspace_id,
"count": 1,
"entries": [
{
@ -574,16 +604,16 @@ def test_cli_task_logs_and_delete_print_human(
],
}
def delete_task(self, task_id: str) -> dict[str, Any]:
assert task_id == "task-123"
return {"task_id": task_id, "deleted": True}
def delete_workspace(self, workspace_id: str) -> dict[str, Any]:
assert workspace_id == "workspace-123"
return {"workspace_id": workspace_id, "deleted": True}
class LogsParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="logs",
task_id="task-123",
command="workspace",
workspace_command="logs",
workspace_id="workspace-123",
json=False,
)
@ -594,9 +624,9 @@ def test_cli_task_logs_and_delete_print_human(
class DeleteParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="delete",
task_id="task-123",
command="workspace",
workspace_command="delete",
workspace_id="workspace-123",
json=False,
)
@ -605,28 +635,28 @@ def test_cli_task_logs_and_delete_print_human(
output = capsys.readouterr().out
assert "#1 exit_code=0 duration_ms=2 cwd=/workspace" in output
assert "Deleted task: task-123" in output
assert "Deleted workspace: workspace-123" in output
def test_cli_task_status_and_delete_print_json(
def test_cli_workspace_status_and_delete_print_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def status_task(self, task_id: str) -> dict[str, Any]:
assert task_id == "task-123"
return {"task_id": task_id, "state": "started"}
def status_workspace(self, workspace_id: str) -> dict[str, Any]:
assert workspace_id == "workspace-123"
return {"workspace_id": workspace_id, "state": "started"}
def delete_task(self, task_id: str) -> dict[str, Any]:
assert task_id == "task-123"
return {"task_id": task_id, "deleted": True}
def delete_workspace(self, workspace_id: str) -> dict[str, Any]:
assert workspace_id == "workspace-123"
return {"workspace_id": workspace_id, "deleted": True}
class StatusParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="status",
task_id="task-123",
command="workspace",
workspace_command="status",
workspace_id="workspace-123",
json=True,
)
@ -639,9 +669,9 @@ def test_cli_task_status_and_delete_print_json(
class DeleteParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="delete",
task_id="task-123",
command="workspace",
workspace_command="delete",
workspace_id="workspace-123",
json=True,
)
@ -651,20 +681,26 @@ def test_cli_task_status_and_delete_print_json(
assert deleted["deleted"] is True
def test_cli_task_exec_json_error_exits_nonzero(
def test_cli_workspace_exec_json_error_exits_nonzero(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
class StubPyro:
def exec_task(self, task_id: str, *, command: str, timeout_seconds: int) -> dict[str, Any]:
del task_id, command, timeout_seconds
raise RuntimeError("task is unavailable")
def exec_workspace(
self,
workspace_id: str,
*,
command: str,
timeout_seconds: int,
) -> dict[str, Any]:
del workspace_id, command, timeout_seconds
raise RuntimeError("workspace is unavailable")
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="exec",
task_id="task-123",
command="workspace",
workspace_command="exec",
workspace_id="workspace-123",
timeout_seconds=30,
json=True,
command_args=["--", "true"],