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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue