Add workspace naming and discovery

Make concurrent workspaces easier to rediscover and resume without relying on opaque IDs alone.

Add optional workspace names, key/value labels, workspace list, and workspace update across the CLI, Python SDK, and MCP surface, and persist last_activity_at so list ordering reflects real mutating activity.

Update the stable contract, install/first-run docs, roadmap, and Python workspace example to teach the new discovery flow, and validate it with focused manager/CLI/API/server coverage plus uv lock, make check, make dist-check, and a real multi-workspace smoke for create, list, update, exec, reorder, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 23:16:10 -03:00
parent ab02ae46c7
commit 446f7fce04
21 changed files with 999 additions and 23 deletions

View file

@ -50,6 +50,8 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
assert "vm_run" in tool_names
assert "vm_create" in tool_names
assert "workspace_create" in tool_names
assert "workspace_list" in tool_names
assert "workspace_update" in tool_names
assert "workspace_start" in tool_names
assert "workspace_stop" in tool_names
assert "workspace_diff" in tool_names
@ -140,6 +142,14 @@ def test_pyro_workspace_network_policy_and_published_ports_delegate() -> None:
calls.append(("create_workspace", kwargs))
return {"workspace_id": "workspace-123"}
def list_workspaces(self) -> dict[str, Any]:
calls.append(("list_workspaces", {}))
return {"count": 1, "workspaces": [{"workspace_id": "workspace-123"}]}
def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
calls.append(("update_workspace", {"workspace_id": workspace_id, **kwargs}))
return {"workspace_id": workspace_id, "name": "repro-fix", "labels": {"owner": "codex"}}
def start_service(
self,
workspace_id: str,
@ -163,6 +173,15 @@ def test_pyro_workspace_network_policy_and_published_ports_delegate() -> None:
pyro.create_workspace(
environment="debian:12",
network_policy="egress+published-ports",
name="repro-fix",
labels={"issue": "123"},
)
pyro.list_workspaces()
pyro.update_workspace(
"workspace-123",
name="repro-fix",
labels={"owner": "codex"},
clear_labels=["issue"],
)
pyro.start_service(
"workspace-123",
@ -182,9 +201,25 @@ def test_pyro_workspace_network_policy_and_published_ports_delegate() -> None:
"allow_host_compat": False,
"seed_path": None,
"secrets": None,
"name": "repro-fix",
"labels": {"issue": "123"},
},
)
assert calls[1] == (
"list_workspaces",
{},
)
assert calls[2] == (
"update_workspace",
{
"workspace_id": "workspace-123",
"name": "repro-fix",
"clear_name": False,
"labels": {"owner": "codex"},
"clear_labels": ["issue"],
},
)
assert calls[3] == (
"start_service",
{
"workspace_id": "workspace-123",
@ -219,12 +254,20 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
environment="debian:12-base",
allow_host_compat=True,
seed_path=source_dir,
name="repro-fix",
labels={"issue": "123"},
secrets=[
{"name": "API_TOKEN", "value": "expected"},
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
],
)
workspace_id = str(created["workspace_id"])
listed_before = pyro.list_workspaces()
updated_metadata = pyro.update_workspace(
workspace_id,
labels={"owner": "codex"},
clear_labels=["issue"],
)
updated_dir = tmp_path / "updated"
updated_dir.mkdir()
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
@ -293,6 +336,11 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
{"name": "API_TOKEN", "source_kind": "literal"},
{"name": "FILE_TOKEN", "source_kind": "file"},
]
assert created["name"] == "repro-fix"
assert created["labels"] == {"issue": "123"}
assert listed_before["count"] == 1
assert listed_before["workspaces"][0]["name"] == "repro-fix"
assert updated_metadata["labels"] == {"owner": "codex"}
assert executed["stdout"] == "[REDACTED]\n"
assert any(entry["path"] == "/workspace/note.txt" for entry in listed_files["entries"])
assert file_read["content"] == "ok\n"

View file

@ -68,6 +68,12 @@ 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 "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help
assert "pyro workspace list" in workspace_help
assert (
"pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex"
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 diff WORKSPACE_ID" in workspace_help
@ -84,6 +90,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
_subparser_choice(parser, "workspace"),
"create",
).format_help()
assert "--name" in workspace_create_help
assert "--label" in workspace_create_help
assert "--seed-path" in workspace_create_help
assert "--secret" in workspace_create_help
assert "--secret-file" in workspace_create_help
@ -116,6 +124,19 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "--output" in workspace_export_help
assert "Export one file or directory from `/workspace`" in workspace_export_help
workspace_list_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "list"
).format_help()
assert "List persisted workspaces" in workspace_list_help
workspace_update_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "update"
).format_help()
assert "--name" in workspace_update_help
assert "--clear-name" in workspace_update_help
assert "--label" in workspace_update_help
assert "--clear-label" in workspace_update_help
workspace_file_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "file"
).format_help()
@ -544,6 +565,8 @@ def test_cli_workspace_create_prints_json(
assert kwargs["environment"] == "debian:12"
assert kwargs["seed_path"] == "./repo"
assert kwargs["network_policy"] == "egress"
assert kwargs["name"] == "repro-fix"
assert kwargs["labels"] == {"issue": "123"}
return {"workspace_id": "workspace-123", "state": "started"}
class StubParser:
@ -558,6 +581,10 @@ def test_cli_workspace_create_prints_json(
network_policy="egress",
allow_host_compat=False,
seed_path="./repo",
name="repro-fix",
label=["issue=123"],
secret=[],
secret_file=[],
json=True,
)
@ -576,10 +603,13 @@ def test_cli_workspace_create_prints_human(
del kwargs
return {
"workspace_id": "workspace-123",
"name": "repro-fix",
"labels": {"issue": "123"},
"environment": "debian:12",
"state": "started",
"network_policy": "off",
"workspace_path": "/workspace",
"last_activity_at": 123.0,
"workspace_seed": {
"mode": "directory",
"seed_path": "/tmp/repo",
@ -606,6 +636,10 @@ def test_cli_workspace_create_prints_human(
network_policy="off",
allow_host_compat=False,
seed_path="/tmp/repo",
name="repro-fix",
label=["issue=123"],
secret=[],
secret_file=[],
json=False,
)
@ -614,6 +648,8 @@ def test_cli_workspace_create_prints_human(
cli.main()
output = capsys.readouterr().out
assert "Workspace ID: workspace-123" in output
assert "Name: repro-fix" in output
assert "Labels: issue=123" in output
assert "Workspace: /workspace" in output
assert "Workspace seed: directory from /tmp/repo" in output
@ -669,6 +705,214 @@ def test_cli_workspace_exec_prints_human_output(
)
def test_print_workspace_summary_human_handles_last_command_and_secret_filtering(
capsys: pytest.CaptureFixture[str],
) -> None:
cli._print_workspace_summary_human(
{
"workspace_id": "workspace-123",
"labels": {"owner": "codex"},
"environment": "debian:12",
"state": "started",
"workspace_path": "/workspace",
"last_activity_at": 123.0,
"network_policy": "off",
"workspace_seed": {"mode": "directory", "seed_path": "/tmp/repo"},
"secrets": ["ignored", {"name": "API_TOKEN", "source_kind": "literal"}],
"execution_mode": "guest_vsock",
"vcpu_count": 1,
"mem_mib": 1024,
"command_count": 2,
"reset_count": 0,
"service_count": 1,
"running_service_count": 1,
"last_command": {"command": "pytest", "exit_code": 0},
},
action="Workspace",
)
output = capsys.readouterr().out
assert "Secrets: API_TOKEN (literal)" in output
assert "Last command: pytest (exit_code=0)" in output
def test_cli_workspace_list_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def list_workspaces(self) -> dict[str, Any]:
return {
"count": 1,
"workspaces": [
{
"workspace_id": "workspace-123",
"name": "repro-fix",
"labels": {"issue": "123", "owner": "codex"},
"environment": "debian:12",
"state": "started",
"created_at": 100.0,
"last_activity_at": 200.0,
"expires_at": 700.0,
"command_count": 2,
"service_count": 1,
"running_service_count": 1,
}
],
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="list",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "workspace_id=workspace-123" in output
assert "name='repro-fix'" in output
assert "labels=issue=123,owner=codex" in output
def test_print_workspace_list_human_skips_non_dict_entries(
capsys: pytest.CaptureFixture[str],
) -> None:
cli._print_workspace_list_human(
{
"workspaces": [
"ignored",
{
"workspace_id": "workspace-123",
"state": "started",
"environment": "debian:12",
"last_activity_at": 200.0,
"expires_at": 700.0,
"command_count": 2,
"service_count": 1,
"running_service_count": 1,
},
]
}
)
output = capsys.readouterr().out
assert "workspace_id=workspace-123" in output
assert "ignored" not in output
def test_cli_workspace_list_prints_empty_state(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def list_workspaces(self) -> dict[str, Any]:
return {"count": 0, "workspaces": []}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="list",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
assert capsys.readouterr().out.strip() == "No workspaces."
def test_cli_workspace_update_prints_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert kwargs["name"] == "retry-run"
assert kwargs["clear_name"] is False
assert kwargs["labels"] == {"issue": "124", "owner": "codex"}
assert kwargs["clear_labels"] == ["stale"]
return {
"workspace_id": workspace_id,
"name": "retry-run",
"labels": {"issue": "124", "owner": "codex"},
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="update",
workspace_id="workspace-123",
name="retry-run",
clear_name=False,
label=["issue=124", "owner=codex"],
clear_label=["stale"],
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = json.loads(capsys.readouterr().out)
assert output["workspace_id"] == "workspace-123"
def test_cli_workspace_update_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert kwargs["name"] is None
assert kwargs["clear_name"] is True
assert kwargs["labels"] == {"owner": "codex"}
assert kwargs["clear_labels"] is None
return {
"workspace_id": workspace_id,
"name": None,
"labels": {"owner": "codex"},
"environment": "debian:12",
"state": "started",
"workspace_path": "/workspace",
"last_activity_at": 123.0,
"network_policy": "off",
"workspace_seed": {"mode": "empty", "seed_path": None},
"execution_mode": "guest_vsock",
"vcpu_count": 1,
"mem_mib": 1024,
"command_count": 0,
"reset_count": 0,
"service_count": 0,
"running_service_count": 0,
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="update",
workspace_id="workspace-123",
name=None,
clear_name=True,
label=["owner=codex"],
clear_label=[],
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "Workspace ID: workspace-123" in output
assert "Labels: owner=codex" in output
assert "Last activity at: 123.0" in output
def test_cli_workspace_export_prints_human_output(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
@ -3286,6 +3530,8 @@ def test_cli_workspace_create_passes_secrets(
{"name": "API_TOKEN", "value": "expected"},
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
]
assert kwargs["name"] is None
assert kwargs["labels"] is None
return {"workspace_id": "ws-123"}
class StubParser:
@ -3297,9 +3543,11 @@ def test_cli_workspace_create_passes_secrets(
vcpu_count=1,
mem_mib=1024,
ttl_seconds=600,
network=False,
network_policy="off",
allow_host_compat=False,
seed_path="./repo",
name=None,
label=[],
secret=["API_TOKEN=expected"],
secret_file=[f"FILE_TOKEN={secret_file}"],
json=True,

View file

@ -28,6 +28,7 @@ from pyro_mcp.contract import (
PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS,
PUBLIC_CLI_WORKSPACE_FILE_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS,
PUBLIC_CLI_WORKSPACE_LIST_FLAGS,
PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS,
PUBLIC_CLI_WORKSPACE_PATCH_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_RESET_FLAGS,
@ -52,6 +53,7 @@ from pyro_mcp.contract import (
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
PUBLIC_MCP_TOOLS,
PUBLIC_SDK_METHODS,
)
@ -127,6 +129,16 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS:
assert flag in workspace_export_help_text
workspace_list_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"), "list"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_LIST_FLAGS:
assert flag in workspace_list_help_text
workspace_update_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"), "update"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS:
assert flag in workspace_update_help_text
workspace_file_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"), "file"
).format_help()

View file

@ -32,6 +32,8 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
assert "vm_run" in tool_names
assert "vm_status" in tool_names
assert "workspace_create" in tool_names
assert "workspace_list" in tool_names
assert "workspace_update" in tool_names
assert "workspace_start" in tool_names
assert "workspace_stop" in tool_names
assert "workspace_diff" in tool_names
@ -220,6 +222,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
"environment": "debian:12-base",
"allow_host_compat": True,
"seed_path": str(source_dir),
"name": "repro-fix",
"labels": {"issue": "123"},
"secrets": [
{"name": "API_TOKEN", "value": "expected"},
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
@ -228,6 +232,17 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
)
)
workspace_id = str(created["workspace_id"])
listed_before = _extract_structured(await server.call_tool("workspace_list", {}))
updated = _extract_structured(
await server.call_tool(
"workspace_update",
{
"workspace_id": workspace_id,
"labels": {"owner": "codex"},
"clear_labels": ["issue"],
},
)
)
update_dir = tmp_path / "update"
update_dir.mkdir()
(update_dir / "more.txt").write_text("more\n", encoding="utf-8")
@ -385,6 +400,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
)
return (
created,
listed_before,
updated,
synced,
executed,
listed_files,
@ -408,6 +425,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
(
created,
listed_before,
updated,
synced,
executed,
listed_files,
@ -429,6 +448,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
deleted,
) = asyncio.run(_run())
assert created["state"] == "started"
assert created["name"] == "repro-fix"
assert created["labels"] == {"issue": "123"}
assert listed_before["count"] == 1
assert listed_before["workspaces"][0]["name"] == "repro-fix"
assert updated["labels"] == {"owner": "codex"}
assert created["workspace_seed"]["mode"] == "directory"
assert created["secrets"] == [
{"name": "API_TOKEN", "source_kind": "literal"},

View file

@ -505,6 +505,153 @@ def test_workspace_sync_push_rejects_destination_outside_workspace(tmp_path: Pat
manager.push_workspace_sync(workspace_id, source_path=source_dir, dest="../escape")
def test_workspace_metadata_list_update_and_last_activity(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
name="repro-fix",
labels={"issue": "123", "owner": "codex"},
)
workspace_id = str(created["workspace_id"])
assert created["name"] == "repro-fix"
assert created["labels"] == {"issue": "123", "owner": "codex"}
created_activity = float(created["last_activity_at"])
listed = manager.list_workspaces()
assert listed["count"] == 1
assert listed["workspaces"][0]["name"] == "repro-fix"
assert listed["workspaces"][0]["labels"] == {"issue": "123", "owner": "codex"}
time.sleep(0.01)
updated = manager.update_workspace(
workspace_id,
name="retry-run",
labels={"issue": "124"},
clear_labels=["owner"],
)
assert updated["name"] == "retry-run"
assert updated["labels"] == {"issue": "124"}
updated_activity = float(updated["last_activity_at"])
assert updated_activity >= created_activity
status_before_exec = manager.status_workspace(workspace_id)
time.sleep(0.01)
manager.exec_workspace(workspace_id, command="printf 'ok\\n'", timeout_seconds=30)
status_after_exec = manager.status_workspace(workspace_id)
assert float(status_before_exec["last_activity_at"]) == updated_activity
assert float(status_after_exec["last_activity_at"]) > updated_activity
reset = manager.reset_workspace(workspace_id)
assert reset["name"] == "retry-run"
assert reset["labels"] == {"issue": "124"}
def test_workspace_list_loads_legacy_records_without_metadata_fields(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)
workspace_id = str(created["workspace_id"])
record_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
payload = json.loads(record_path.read_text(encoding="utf-8"))
payload.pop("name", None)
payload.pop("labels", None)
payload.pop("last_activity_at", None)
record_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
listed = manager.list_workspaces()
assert listed["count"] == 1
listed_workspace = listed["workspaces"][0]
assert listed_workspace["workspace_id"] == workspace_id
assert listed_workspace["name"] is None
assert listed_workspace["labels"] == {}
assert float(listed_workspace["last_activity_at"]) == float(created["created_at"])
def test_workspace_list_sorts_by_last_activity_and_skips_invalid_payload(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
first = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
name="first",
)
second = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
name="second",
)
first_id = str(first["workspace_id"])
second_id = str(second["workspace_id"])
time.sleep(0.01)
manager.exec_workspace(second_id, command="printf 'ok\\n'", timeout_seconds=30)
invalid_dir = tmp_path / "vms" / "workspaces" / "invalid"
invalid_dir.mkdir(parents=True)
(invalid_dir / "workspace.json").write_text('"not-a-dict"', encoding="utf-8")
listed = manager.list_workspaces()
assert listed["count"] == 2
assert [item["workspace_id"] for item in listed["workspaces"]] == [second_id, first_id]
def test_workspace_update_clear_name_and_rejects_noop(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
name="repro-fix",
labels={"issue": "123"},
)
workspace_id = str(created["workspace_id"])
cleared = manager.update_workspace(
workspace_id,
clear_name=True,
clear_labels=["issue"],
)
assert cleared["name"] is None
assert cleared["labels"] == {}
with pytest.raises(ValueError, match="workspace update requested no effective metadata change"):
manager.update_workspace(workspace_id, clear_name=True)
with pytest.raises(ValueError, match="name and clear_name cannot be used together"):
manager.update_workspace(workspace_id, name="retry-run", clear_name=True)
def test_workspace_export_rejects_empty_output_path(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)
with pytest.raises(ValueError, match="output_path must not be empty"):
manager.export_workspace(str(created["workspace_id"]), path=".", output_path=" ")
def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None:
seed_dir = tmp_path / "seed"
seed_dir.mkdir()