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:
parent
ab02ae46c7
commit
446f7fce04
21 changed files with 999 additions and 23 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue