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

@ -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()