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:
parent
f57454bcb4
commit
48b82d8386
13 changed files with 743 additions and 618 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue