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

@ -267,48 +267,48 @@ def test_vm_manager_run_vm(tmp_path: Path) -> None:
assert str(result["stdout"]) == "ok\n"
def test_task_lifecycle_and_logs(tmp_path: Path) -> None:
def test_workspace_lifecycle_and_logs(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_task(
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)
task_id = str(created["task_id"])
workspace_id = str(created["workspace_id"])
assert created["state"] == "started"
assert created["workspace_path"] == "/workspace"
first = manager.exec_task(
task_id,
first = manager.exec_workspace(
workspace_id,
command="printf 'hello\\n' > note.txt",
timeout_seconds=30,
)
second = manager.exec_task(task_id, command="cat note.txt", timeout_seconds=30)
second = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30)
assert first["exit_code"] == 0
assert second["stdout"] == "hello\n"
status = manager.status_task(task_id)
status = manager.status_workspace(workspace_id)
assert status["command_count"] == 2
assert status["last_command"] is not None
logs = manager.logs_task(task_id)
logs = manager.logs_workspace(workspace_id)
assert logs["count"] == 2
entries = logs["entries"]
assert isinstance(entries, list)
assert entries[1]["stdout"] == "hello\n"
deleted = manager.delete_task(task_id)
deleted = manager.delete_workspace(workspace_id)
assert deleted["deleted"] is True
with pytest.raises(ValueError, match="does not exist"):
manager.status_task(task_id)
manager.status_workspace(workspace_id)
def test_task_create_seeds_directory_source_into_workspace(tmp_path: Path) -> None:
def test_workspace_create_seeds_directory_source_into_workspace(tmp_path: Path) -> None:
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
@ -319,25 +319,25 @@ def test_task_create_seeds_directory_source_into_workspace(tmp_path: Path) -> No
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_task(
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
source_path=source_dir,
seed_path=source_dir,
)
task_id = str(created["task_id"])
workspace_id = str(created["workspace_id"])
workspace_seed = created["workspace_seed"]
assert workspace_seed["mode"] == "directory"
assert workspace_seed["source_path"] == str(source_dir.resolve())
executed = manager.exec_task(task_id, command="cat note.txt", timeout_seconds=30)
assert workspace_seed["seed_path"] == str(source_dir.resolve())
executed = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30)
assert executed["stdout"] == "hello\n"
status = manager.status_task(task_id)
status = manager.status_workspace(workspace_id)
assert status["workspace_seed"]["mode"] == "directory"
assert status["workspace_seed"]["source_path"] == str(source_dir.resolve())
assert status["workspace_seed"]["seed_path"] == str(source_dir.resolve())
def test_task_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None:
def test_workspace_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None:
archive_path = tmp_path / "seed.tgz"
nested_dir = tmp_path / "src"
nested_dir.mkdir()
@ -351,19 +351,19 @@ def test_task_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None:
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_task(
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
source_path=archive_path,
seed_path=archive_path,
)
task_id = str(created["task_id"])
workspace_id = str(created["workspace_id"])
assert created["workspace_seed"]["mode"] == "tar_archive"
executed = manager.exec_task(task_id, command="cat note.txt", timeout_seconds=30)
executed = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30)
assert executed["stdout"] == "archive\n"
def test_task_sync_push_updates_started_workspace(tmp_path: Path) -> None:
def test_workspace_sync_push_updates_started_workspace(tmp_path: Path) -> None:
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
@ -377,26 +377,30 @@ def test_task_sync_push_updates_started_workspace(tmp_path: Path) -> None:
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_task(
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
source_path=source_dir,
seed_path=source_dir,
)
task_id = str(created["task_id"])
synced = manager.push_task_sync(task_id, source_path=update_dir, dest="subdir")
workspace_id = str(created["workspace_id"])
synced = manager.push_workspace_sync(workspace_id, source_path=update_dir, dest="subdir")
assert synced["workspace_sync"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
executed = manager.exec_task(task_id, command="cat subdir/more.txt", timeout_seconds=30)
executed = manager.exec_workspace(
workspace_id,
command="cat subdir/more.txt",
timeout_seconds=30,
)
assert executed["stdout"] == "more\n"
status = manager.status_task(task_id)
status = manager.status_workspace(workspace_id)
assert status["command_count"] == 1
assert status["workspace_seed"]["mode"] == "directory"
def test_task_sync_push_requires_started_task(tmp_path: Path) -> None:
def test_workspace_sync_push_requires_started_workspace(tmp_path: Path) -> None:
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
@ -410,22 +414,25 @@ def test_task_sync_push_requires_started_task(tmp_path: Path) -> None:
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_task(
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
source_path=source_dir,
seed_path=source_dir,
)
task_id = str(created["task_id"])
task_path = tmp_path / "vms" / "tasks" / task_id / "task.json"
payload = json.loads(task_path.read_text(encoding="utf-8"))
workspace_id = str(created["workspace_id"])
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
payload["state"] = "stopped"
task_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
with pytest.raises(RuntimeError, match="must be in 'started' state before task_sync_push"):
manager.push_task_sync(task_id, source_path=update_dir)
with pytest.raises(
RuntimeError,
match="must be in 'started' state before workspace_sync_push",
):
manager.push_workspace_sync(workspace_id, source_path=update_dir)
def test_task_sync_push_rejects_destination_outside_workspace(tmp_path: Path) -> None:
def test_workspace_sync_push_rejects_destination_outside_workspace(tmp_path: Path) -> None:
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
@ -436,18 +443,18 @@ def test_task_sync_push_rejects_destination_outside_workspace(tmp_path: Path) ->
network_manager=TapNetworkManager(enabled=False),
)
task_id = str(
manager.create_task(
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["task_id"]
)["workspace_id"]
)
with pytest.raises(ValueError, match="workspace destination must stay inside /workspace"):
manager.push_task_sync(task_id, source_path=source_dir, dest="../escape")
manager.push_workspace_sync(workspace_id, source_path=source_dir, dest="../escape")
def test_task_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
def test_workspace_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
archive_path = tmp_path / "bad.tgz"
with tarfile.open(archive_path, "w:gz") as archive:
payload = b"bad\n"
@ -462,15 +469,15 @@ def test_task_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
)
with pytest.raises(RuntimeError, match="unsafe archive member path"):
manager.create_task(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
source_path=archive_path,
seed_path=archive_path,
)
assert list((tmp_path / "vms" / "tasks").iterdir()) == []
assert list((tmp_path / "vms" / "workspaces").iterdir()) == []
def test_task_create_rejects_archive_that_writes_through_symlink(tmp_path: Path) -> None:
def test_workspace_create_rejects_archive_that_writes_through_symlink(tmp_path: Path) -> None:
archive_path = tmp_path / "bad-symlink.tgz"
with tarfile.open(archive_path, "w:gz") as archive:
symlink_info = tarfile.TarInfo(name="linked")
@ -490,14 +497,14 @@ def test_task_create_rejects_archive_that_writes_through_symlink(tmp_path: Path)
)
with pytest.raises(RuntimeError, match="traverse through a symlinked path"):
manager.create_task(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
source_path=archive_path,
seed_path=archive_path,
)
def test_task_create_cleans_up_on_seed_failure(
def test_workspace_create_cleans_up_on_seed_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source_dir = tmp_path / "seed"
@ -517,27 +524,27 @@ def test_task_create_cleans_up_on_seed_failure(
monkeypatch.setattr(manager._backend, "import_archive", _boom) # noqa: SLF001
with pytest.raises(RuntimeError, match="seed import failed"):
manager.create_task(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
source_path=source_dir,
seed_path=source_dir,
)
assert list((tmp_path / "vms" / "tasks").iterdir()) == []
assert list((tmp_path / "vms" / "workspaces").iterdir()) == []
def test_task_rehydrates_across_manager_processes(tmp_path: Path) -> None:
def test_workspace_rehydrates_across_manager_processes(tmp_path: Path) -> None:
base_dir = tmp_path / "vms"
manager = VmManager(
backend_name="mock",
base_dir=base_dir,
network_manager=TapNetworkManager(enabled=False),
)
task_id = str(
manager.create_task(
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["task_id"]
)["workspace_id"]
)
other = VmManager(
@ -545,33 +552,33 @@ def test_task_rehydrates_across_manager_processes(tmp_path: Path) -> None:
base_dir=base_dir,
network_manager=TapNetworkManager(enabled=False),
)
executed = other.exec_task(task_id, command="printf 'ok\\n'", timeout_seconds=30)
executed = other.exec_workspace(workspace_id, command="printf 'ok\\n'", timeout_seconds=30)
assert executed["exit_code"] == 0
assert executed["stdout"] == "ok\n"
logs = other.logs_task(task_id)
logs = other.logs_workspace(workspace_id)
assert logs["count"] == 1
def test_task_requires_started_state(tmp_path: Path) -> None:
def test_workspace_requires_started_state(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
task_id = str(
manager.create_task(
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["task_id"]
)["workspace_id"]
)
task_dir = tmp_path / "vms" / "tasks" / task_id / "task.json"
payload = json.loads(task_dir.read_text(encoding="utf-8"))
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
payload["state"] = "stopped"
task_dir.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
with pytest.raises(RuntimeError, match="must be in 'started' state"):
manager.exec_task(task_id, command="true", timeout_seconds=30)
manager.exec_workspace(workspace_id, command="true", timeout_seconds=30)
def test_vm_manager_firecracker_backend_path(
@ -708,7 +715,7 @@ def test_copy_rootfs_falls_back_to_copy2(
assert dest.read_text(encoding="utf-8") == "payload"
def test_task_create_cleans_up_on_start_failure(
def test_workspace_create_cleans_up_on_start_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
manager = VmManager(
@ -724,9 +731,9 @@ def test_task_create_cleans_up_on_start_failure(
monkeypatch.setattr(manager._backend, "start", _boom) # noqa: SLF001
with pytest.raises(RuntimeError, match="boom"):
manager.create_task(environment="debian:12-base", allow_host_compat=True)
manager.create_workspace(environment="debian:12-base", allow_host_compat=True)
assert list((tmp_path / "vms" / "tasks").iterdir()) == []
assert list((tmp_path / "vms" / "workspaces").iterdir()) == []
def test_exec_instance_wraps_guest_workspace_command(tmp_path: Path) -> None:
@ -786,53 +793,53 @@ def test_exec_instance_wraps_guest_workspace_command(tmp_path: Path) -> None:
assert captured["workdir"] is None
def test_status_task_marks_dead_backing_process_stopped(tmp_path: Path) -> None:
def test_status_workspace_marks_dead_backing_process_stopped(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
task_id = str(
manager.create_task(
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["task_id"]
)["workspace_id"]
)
task_path = tmp_path / "vms" / "tasks" / task_id / "task.json"
payload = json.loads(task_path.read_text(encoding="utf-8"))
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
payload["metadata"]["execution_mode"] = "guest_vsock"
payload["firecracker_pid"] = 999999
task_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
status = manager.status_task(task_id)
status = manager.status_workspace(workspace_id)
assert status["state"] == "stopped"
updated_payload = json.loads(task_path.read_text(encoding="utf-8"))
updated_payload = json.loads(workspace_path.read_text(encoding="utf-8"))
assert "backing guest process" in str(updated_payload.get("last_error", ""))
def test_reap_expired_tasks_removes_invalid_and_expired_records(tmp_path: Path) -> None:
def test_reap_expired_workspaces_removes_invalid_and_expired_records(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
invalid_dir = tmp_path / "vms" / "tasks" / "invalid"
invalid_dir = tmp_path / "vms" / "workspaces" / "invalid"
invalid_dir.mkdir(parents=True)
(invalid_dir / "task.json").write_text("[]", encoding="utf-8")
(invalid_dir / "workspace.json").write_text("[]", encoding="utf-8")
task_id = str(
manager.create_task(
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["task_id"]
)["workspace_id"]
)
task_path = tmp_path / "vms" / "tasks" / task_id / "task.json"
payload = json.loads(task_path.read_text(encoding="utf-8"))
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
payload["expires_at"] = 0.0
task_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
with manager._lock: # noqa: SLF001
manager._reap_expired_tasks_locked(time.time()) # noqa: SLF001
manager._reap_expired_workspaces_locked(time.time()) # noqa: SLF001
assert not invalid_dir.exists()
assert not (tmp_path / "vms" / "tasks" / task_id).exists()
assert not (tmp_path / "vms" / "workspaces" / workspace_id).exists()