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

@ -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,