diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f230fa..76f26bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 3.3.0 + +- Added first-class workspace naming and discovery across the CLI, Python SDK, and MCP server + with `pyro workspace create --name/--label`, `pyro workspace list`, `pyro workspace update`, + `Pyro.list_workspaces()`, `Pyro.update_workspace()`, and the matching `workspace_list` / + `workspace_update` MCP tools. +- Added persisted `name`, key/value `labels`, and `last_activity_at` metadata to workspace create, + status, reset, and update payloads, and surfaced compact workspace summaries from + `workspace list`. +- Tracked `last_activity_at` on real workspace mutations so humans and chat-driven agents can + resume the most recently used workspace without managing opaque IDs out of band. + ## 3.2.0 - Added model-native live workspace file operations across the CLI, Python SDK, and MCP server diff --git a/README.md b/README.md index e129c00..c6bbe92 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ It exposes the same runtime in three public forms: - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) -- What's new in 3.2.0: [CHANGELOG.md#320](CHANGELOG.md#320) +- What's new in 3.3.0: [CHANGELOG.md#330](CHANGELOG.md#330) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Integration targets: [docs/integrations.md](docs/integrations.md) - Public contract: [docs/public-contract.md](docs/public-contract.md) @@ -59,7 +59,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 3.2.0 +Catalog version: 3.3.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -86,7 +86,9 @@ for the published package, or `uv run pyro ...` from a source checkout. ```bash uv tool install pyro-mcp -WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --json | python -c 'import json,sys; print(json.load(sys.stdin)["workspace_id"])')" +WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --json | python -c 'import json,sys; print(json.load(sys.stdin)["workspace_id"])')" +pyro workspace list +pyro workspace update "$WORKSPACE_ID" --label owner=codex pyro workspace sync push "$WORKSPACE_ID" ./changes pyro workspace file read "$WORKSPACE_ID" note.txt pyro workspace patch apply "$WORKSPACE_ID" --patch "$(cat fix.patch)" @@ -103,6 +105,7 @@ pyro workspace delete "$WORKSPACE_ID" That stable workspace path gives you: - initial host-in seeding with `--seed-path` +- discovery metadata with `--name`, `--label`, `workspace list`, and `workspace update` - later host-in updates with `workspace sync push` - model-native file inspection and text edits with `workspace file *` and `workspace patch apply` - one-shot commands with `workspace exec` and persistent PTYs with `workspace shell *` @@ -117,6 +120,8 @@ After the quickstart works: - prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo` - create a persistent workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo` +- add a human-friendly workspace name with `uvx --from pyro-mcp pyro workspace create debian:12 --name repro-fix --label issue=123` +- rediscover or retag workspaces with `uvx --from pyro-mcp pyro workspace list` and `uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex` - update a live workspace from the host with `uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes` - enable outbound guest networking for one workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress` - add literal or file-backed secrets with `uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt` @@ -184,7 +189,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 3.2.0 +Catalog version: 3.3.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -300,7 +305,7 @@ Persistent workspaces start in `/workspace` and keep command history until you d machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import -later host-side changes into a started workspace. Sync is non-atomic in `3.2.0`; if it fails +later host-side changes into a started workspace. Sync is non-atomic in `3.3.0`; if it fails partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use diff --git a/docs/first-run.md b/docs/first-run.md index 51c6bb3..4d0f033 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 3.2.0 +Catalog version: 3.3.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -73,8 +73,10 @@ installed `pyro` binary by dropping the `uvx --from pyro-mcp` prefix, or with `u a source checkout. ```bash -$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --json | tee /tmp/pyro-workspace.json +$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --json | tee /tmp/pyro-workspace.json $ export WORKSPACE_ID="$(python -c 'import json,sys; print(json.load(sys.stdin)["workspace_id"])' < /tmp/pyro-workspace.json)" +$ uvx --from pyro-mcp pyro workspace list +$ uvx --from pyro-mcp pyro workspace update "$WORKSPACE_ID" --label owner=codex $ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes $ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt $ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch "$(cat fix.patch)" @@ -95,7 +97,9 @@ $ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID" ```bash $ uvx --from pyro-mcp pyro demo -$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo +$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 +$ uvx --from pyro-mcp pyro workspace list +$ uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex $ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes $ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive $ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py @@ -248,7 +252,7 @@ State: started Use `--seed-path` when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use `pyro workspace sync push` when you need to import later host-side changes into a started -workspace. Sync is non-atomic in `3.2.0`; if it fails partway through, prefer `pyro workspace reset` +workspace. Sync is non-atomic in `3.3.0`; if it fails partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current `/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the diff --git a/docs/install.md b/docs/install.md index 67a35cb..c1c1475 100644 --- a/docs/install.md +++ b/docs/install.md @@ -85,7 +85,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 3.2.0 +Catalog version: 3.3.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -140,7 +140,9 @@ for the published package, or `uv run pyro ...` from a source checkout. ```bash uv tool install pyro-mcp -WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --json | python -c 'import json,sys; print(json.load(sys.stdin)["workspace_id"])')" +WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --json | python -c 'import json,sys; print(json.load(sys.stdin)["workspace_id"])')" +pyro workspace list +pyro workspace update "$WORKSPACE_ID" --label owner=codex pyro workspace sync push "$WORKSPACE_ID" ./changes pyro workspace file read "$WORKSPACE_ID" note.txt pyro workspace patch apply "$WORKSPACE_ID" --patch "$(cat fix.patch)" @@ -155,6 +157,7 @@ pyro workspace delete "$WORKSPACE_ID" This is the stable persistent-workspace contract: - `workspace create` seeds `/workspace` +- `workspace create --name/--label`, `workspace list`, and `workspace update` make workspaces discoverable - `workspace sync push` imports later host-side changes - `workspace file *` and `workspace patch apply` cover model-native text inspection and edits - `workspace exec` and `workspace shell *` keep work inside one sandbox @@ -208,6 +211,8 @@ pyro run debian:12 -- git --version After the CLI path works, you can move on to: - persistent workspaces: `pyro workspace create debian:12 --seed-path ./repo` +- workspace discovery metadata: `pyro workspace create debian:12 --name repro-fix --label issue=123` +- workspace discovery commands: `pyro workspace list` and `pyro workspace update WORKSPACE_ID --label owner=codex` - live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes` - guest networking policy: `pyro workspace create debian:12 --network-policy egress` - workspace secrets: `pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt` @@ -269,7 +274,7 @@ Workspace commands default to the persistent `/workspace` directory inside the g the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path` when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync -is non-atomic in `3.2.0`; if it fails partway through, prefer `pyro workspace reset` to recover +is non-atomic in `3.3.0`; if it fails partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use diff --git a/docs/integrations.md b/docs/integrations.md index f047697..cceb5f1 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -31,6 +31,7 @@ Best when: Recommended surface: - `vm_run` +- `workspace_create(name=..., labels=...)` + `workspace_list` + `workspace_update` when the agent needs to rediscover or retag long-lived workspaces between turns - `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state - `workspace_file_list` / `workspace_file_read` / `workspace_file_write` / `workspace_patch_apply` when the agent needs model-native file inspection and text edits inside one live workspace - `workspace_create(..., secrets=...)` + `workspace_exec(..., secret_env=...)` when the workspace needs private tokens or authenticated setup @@ -73,6 +74,7 @@ Best when: Recommended default: - `Pyro.run_in_vm(...)` +- `Pyro.create_workspace(name=..., labels=...)` + `Pyro.list_workspaces()` + `Pyro.update_workspace(...)` when repeated workspaces need human-friendly discovery metadata - `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required - `Pyro.list_workspace_files(...)` / `Pyro.read_workspace_file(...)` / `Pyro.write_workspace_file(...)` / `Pyro.apply_workspace_patch(...)` when the agent needs model-native file inspection and text edits inside one live workspace - `Pyro.create_workspace(..., secrets=...)` + `Pyro.exec_workspace(..., secret_env=...)` when the workspace needs private tokens or authenticated setup @@ -88,6 +90,8 @@ Lifecycle note: that final exec - use `create_workspace(seed_path=...)` when the agent needs repeated commands in one persistent `/workspace` that starts from host content +- use `create_workspace(name=..., labels=...)`, `list_workspaces()`, and `update_workspace(...)` + when the agent or operator needs to rediscover the right workspace later without external notes - use `push_workspace_sync(...)` when later host-side changes need to be imported into that running workspace without recreating it - use `list_workspace_files(...)`, `read_workspace_file(...)`, `write_workspace_file(...)`, and diff --git a/docs/public-contract.md b/docs/public-contract.md index bb4770e..cd5167e 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -25,6 +25,7 @@ Top-level commands: - `pyro mcp serve` - `pyro run` - `pyro workspace create` +- `pyro workspace list` - `pyro workspace sync push` - `pyro workspace stop` - `pyro workspace start` @@ -53,6 +54,7 @@ Top-level commands: - `pyro workspace shell signal` - `pyro workspace shell close` - `pyro workspace status` +- `pyro workspace update` - `pyro workspace logs` - `pyro workspace delete` - `pyro doctor` @@ -78,8 +80,10 @@ Behavioral guarantees: - `pyro demo ollama` prints log lines plus a final summary line. - `pyro workspace create` auto-starts a persistent workspace. - `pyro workspace create --seed-path PATH` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. +- `pyro workspace create --name NAME --label KEY=VALUE` attaches human-oriented discovery metadata without changing the stable `workspace_id`. - `pyro workspace create --network-policy {off,egress,egress+published-ports}` controls workspace guest networking and whether services may publish localhost ports. - `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`. +- `pyro workspace list` returns persisted workspaces sorted by most recent `last_activity_at`. - `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace. - `pyro workspace stop WORKSPACE_ID` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. - `pyro workspace start WORKSPACE_ID` restarts one stopped workspace without resetting `/workspace`. @@ -103,11 +107,14 @@ Behavioral guarantees: - `pyro workspace shell open --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into the opened shell environment. - `pyro workspace shell *` manages persistent PTY sessions inside a started workspace. - `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`. +- `pyro workspace update` changes only discovery metadata such as `name` and key/value `labels`. - Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized. +- Workspace create/status/reset/update results expose `name`, `labels`, and `last_activity_at`. - Workspace create/status/reset results expose `network_policy`. - Workspace create/status/reset results expose `reset_count` and `last_reset_at`. - Workspace create/status/reset results expose safe `secrets` metadata with each secret name and source kind, but never the secret values. - `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields. +- `pyro workspace list` returns one summary row per persisted workspace with `workspace_id`, `name`, `labels`, `environment`, `state`, `created_at`, `last_activity_at`, `expires_at`, `command_count`, `service_count`, and `running_service_count`. - `pyro workspace service start`, `pyro workspace service list`, and `pyro workspace service status` expose published-port metadata when present. ## Python SDK Contract @@ -125,7 +132,8 @@ Supported public entrypoints: - `Pyro.inspect_environment(environment)` - `Pyro.prune_environments()` - `Pyro.create_vm(...)` -- `Pyro.create_workspace(..., network_policy="off", secrets=None)` +- `Pyro.create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)` +- `Pyro.list_workspaces()` - `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` - `Pyro.stop_workspace(workspace_id)` - `Pyro.start_workspace(workspace_id)` @@ -160,6 +168,7 @@ Supported public entrypoints: - `Pyro.delete_workspace(workspace_id)` - `Pyro.status_vm(vm_id)` - `Pyro.status_workspace(workspace_id)` +- `Pyro.update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)` - `Pyro.logs_workspace(workspace_id)` - `Pyro.network_info_vm(vm_id)` - `Pyro.reap_expired()` @@ -173,7 +182,8 @@ Stable public method names: - `inspect_environment(environment)` - `prune_environments()` - `create_vm(...)` -- `create_workspace(..., network_policy="off", secrets=None)` +- `create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)` +- `list_workspaces()` - `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` - `stop_workspace(workspace_id)` - `start_workspace(workspace_id)` @@ -208,6 +218,7 @@ Stable public method names: - `delete_workspace(workspace_id)` - `status_vm(vm_id)` - `status_workspace(workspace_id)` +- `update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)` - `logs_workspace(workspace_id)` - `network_info_vm(vm_id)` - `reap_expired()` @@ -220,8 +231,10 @@ Behavioral defaults: - `allow_host_compat` defaults to `False` on `create_vm(...)` and `run_in_vm(...)`. - `allow_host_compat` defaults to `False` on `create_workspace(...)`. - `Pyro.create_workspace(..., seed_path=...)` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. +- `Pyro.create_workspace(..., name=..., labels=...)` attaches human-oriented discovery metadata without changing the stable `workspace_id`. - `Pyro.create_workspace(..., network_policy="off"|"egress"|"egress+published-ports")` controls workspace guest networking and whether services may publish host ports. - `Pyro.create_workspace(..., secrets=...)` persists guest-only UTF-8 secrets outside `/workspace`. +- `Pyro.list_workspaces()` returns persisted workspace summaries sorted by most recent `last_activity_at`. - `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace. - `Pyro.stop_workspace(...)` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. - `Pyro.start_workspace(...)` restarts one stopped workspace without resetting `/workspace`. @@ -248,6 +261,7 @@ Behavioral defaults: - `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace. - `Pyro.read_shell(...)` reads merged text output from that shell by cursor. - `Pyro.write_shell(...)`, `Pyro.signal_shell(...)`, and `Pyro.close_shell(...)` operate on that persistent shell session. +- `Pyro.update_workspace(...)` changes only discovery metadata such as `name` and key/value `labels`. ## MCP Contract @@ -270,6 +284,7 @@ Advanced lifecycle tools: Persistent workspace tools: - `workspace_create` +- `workspace_list` - `workspace_sync_push` - `workspace_stop` - `workspace_start` @@ -298,6 +313,7 @@ Persistent workspace tools: - `shell_signal` - `shell_close` - `workspace_status` +- `workspace_update` - `workspace_logs` - `workspace_delete` @@ -308,8 +324,10 @@ Behavioral defaults: - `vm_run` and `vm_create` expose `allow_host_compat`, which defaults to `false`. - `workspace_create` exposes `allow_host_compat`, which defaults to `false`. - `workspace_create` accepts optional `seed_path` and seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. +- `workspace_create` accepts optional `name` and `labels` metadata for human discovery without changing the stable `workspace_id`. - `workspace_create` accepts `network_policy` with `off`, `egress`, or `egress+published-ports` to control workspace guest networking and service port publication. - `workspace_create` accepts optional `secrets` and persists guest-only UTF-8 secret material outside `/workspace`. +- `workspace_list` returns persisted workspace summaries sorted by most recent `last_activity_at`. - `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`. - `workspace_stop` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. - `workspace_start` restarts one stopped workspace without resetting `/workspace`. @@ -327,6 +345,7 @@ Behavioral defaults: - `service_start` accepts optional `published_ports` to expose guest TCP ports on `127.0.0.1` when the workspace network policy is `egress+published-ports`. - `vm_exec` runs one command and auto-cleans that VM after the exec completes. - `workspace_exec` accepts optional `secret_env` mappings for one exec call and leaves the workspace alive. +- `workspace_update` changes only discovery metadata such as `name` and key/value `labels`. - `service_start` accepts optional `secret_env` mappings for one service start call. - `shell_open` accepts optional `secret_env` mappings for the opened shell session. - `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace. diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 5dcd8cb..a7d98f4 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `3.2.0`: +Current baseline is `3.3.0`: - the stable workspace contract exists across CLI, SDK, and MCP - one-shot `pyro run` still exists as the narrow entrypoint @@ -46,7 +46,7 @@ More concretely, the model should not need to: ## Milestones 1. [`3.2.0` Model-Native Workspace File Ops](llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md) - Done -2. [`3.3.0` Workspace Naming And Discovery](llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md) +2. [`3.3.0` Workspace Naming And Discovery](llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md) - Done 3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) 4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md) 5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md) @@ -55,6 +55,9 @@ Completed so far: - `3.2.0` added model-native `workspace file *` and `workspace patch apply` so chat-driven agents can inspect and edit `/workspace` without shell-escaped file mutation flows. +- `3.3.0` added workspace names, key/value labels, `workspace list`, `workspace update`, and + `last_activity_at` tracking so humans and chat-driven agents can rediscover and resume the right + workspace without external notes. ## Expected Outcome diff --git a/docs/roadmap/llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md b/docs/roadmap/llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md index 355e413..5081196 100644 --- a/docs/roadmap/llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md +++ b/docs/roadmap/llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md @@ -1,6 +1,6 @@ # `3.3.0` Workspace Naming And Discovery -Status: Planned +Status: Done ## Goal @@ -50,3 +50,4 @@ Planned additions: - README and install docs that show parallel named workspaces - examples that demonstrate issue-oriented workspace naming - smoke coverage for at least one multi-workspace flow +- public contract, CLI help, and examples that show `workspace list` and `workspace update` diff --git a/examples/python_workspace.py b/examples/python_workspace.py index 019a1d6..79e338e 100644 --- a/examples/python_workspace.py +++ b/examples/python_workspace.py @@ -22,6 +22,8 @@ def main() -> None: created = pyro.create_workspace( environment="debian:12", seed_path=seed_dir, + name="repro-fix", + labels={"issue": "123"}, network_policy="egress+published-ports", secrets=[ {"name": "API_TOKEN", "value": "expected"}, @@ -30,6 +32,13 @@ def main() -> None: ) workspace_id = str(created["workspace_id"]) try: + listed = pyro.list_workspaces() + print(f"workspace_count={listed['count']}") + updated = pyro.update_workspace( + workspace_id, + labels={"owner": "codex"}, + ) + print(updated["labels"]["owner"]) pyro.push_workspace_sync(workspace_id, sync_dir) files = pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True) print(f"workspace_entries={len(files['entries'])}") diff --git a/pyproject.toml b/pyproject.toml index 6314939..81c0329 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "3.2.0" +version = "3.3.0" description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 3a63bc7..28ba252 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -89,6 +89,8 @@ class Pyro: allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, seed_path: str | Path | None = None, secrets: list[dict[str, str]] | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, ) -> dict[str, Any]: return self._manager.create_workspace( environment=environment, @@ -99,6 +101,28 @@ class Pyro: allow_host_compat=allow_host_compat, seed_path=seed_path, secrets=secrets, + name=name, + labels=labels, + ) + + def list_workspaces(self) -> dict[str, Any]: + return self._manager.list_workspaces() + + def update_workspace( + self, + workspace_id: str, + *, + name: str | None = None, + clear_name: bool = False, + labels: dict[str, str] | None = None, + clear_labels: list[str] | None = None, + ) -> dict[str, Any]: + return self._manager.update_workspace( + workspace_id, + name=name, + clear_name=clear_name, + labels=labels, + clear_labels=clear_labels, ) def exec_workspace( @@ -508,6 +532,8 @@ class Pyro: allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, seed_path: str | None = None, secrets: list[dict[str, str]] | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, ) -> dict[str, Any]: """Create and start a persistent workspace.""" return self.create_workspace( @@ -519,6 +545,30 @@ class Pyro: allow_host_compat=allow_host_compat, seed_path=seed_path, secrets=secrets, + name=name, + labels=labels, + ) + + @server.tool() + async def workspace_list() -> dict[str, Any]: + """List persisted workspaces with summary metadata.""" + return self.list_workspaces() + + @server.tool() + async def workspace_update( + workspace_id: str, + name: str | None = None, + clear_name: bool = False, + labels: dict[str, str] | None = None, + clear_labels: list[str] | None = None, + ) -> dict[str, Any]: + """Update optional workspace name and labels.""" + return self.update_workspace( + workspace_id, + name=name, + clear_name=clear_name, + labels=labels, + clear_labels=clear_labels, ) @server.tool() diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index ac4416f..f616f50 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -159,9 +159,21 @@ def _print_doctor_human(payload: dict[str, Any]) -> None: def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None: print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}") + name = payload.get("name") + if isinstance(name, str) and name != "": + print(f"Name: {name}") + labels = payload.get("labels") + if isinstance(labels, dict) and labels: + rendered_labels = ", ".join( + f"{str(key)}={str(value)}" for key, value in sorted(labels.items()) + ) + print(f"Labels: {rendered_labels}") print(f"Environment: {str(payload.get('environment', 'unknown'))}") print(f"State: {str(payload.get('state', 'unknown'))}") print(f"Workspace: {str(payload.get('workspace_path', '/workspace'))}") + last_activity_at = payload.get("last_activity_at") + if last_activity_at is not None: + print(f"Last activity at: {last_activity_at}") print(f"Network policy: {str(payload.get('network_policy', 'off'))}") workspace_seed = payload.get("workspace_seed") if isinstance(workspace_seed, dict): @@ -207,6 +219,39 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N ) +def _print_workspace_list_human(payload: dict[str, Any]) -> None: + workspaces = payload.get("workspaces") + if not isinstance(workspaces, list) or not workspaces: + print("No workspaces.") + return + for workspace in workspaces: + if not isinstance(workspace, dict): + continue + rendered_labels = "" + labels = workspace.get("labels") + if isinstance(labels, dict) and labels: + rendered_labels = " labels=" + ",".join( + f"{str(key)}={str(value)}" for key, value in sorted(labels.items()) + ) + rendered_name = "" + name = workspace.get("name") + if isinstance(name, str) and name != "": + rendered_name = f" name={name!r}" + print( + "[workspace] " + f"workspace_id={str(workspace.get('workspace_id', 'unknown'))}" + f"{rendered_name} " + f"state={str(workspace.get('state', 'unknown'))} " + f"environment={str(workspace.get('environment', 'unknown'))}" + f"{rendered_labels} " + f"last_activity_at={workspace.get('last_activity_at')} " + f"expires_at={workspace.get('expires_at')} " + f"commands={int(workspace.get('command_count', 0))} " + f"services={int(workspace.get('running_service_count', 0))}/" + f"{int(workspace.get('service_count', 0))}" + ) + + def _print_workspace_exec_human(payload: dict[str, Any]) -> None: stdout = str(payload.get("stdout", "")) stderr = str(payload.get("stderr", "")) @@ -799,6 +844,9 @@ def _build_parser() -> argparse.ArgumentParser: """ Examples: pyro workspace create debian:12 --seed-path ./repo + pyro workspace create debian:12 --name repro-fix --label issue=123 + pyro workspace list + pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex pyro workspace sync push WORKSPACE_ID ./repo --dest src pyro workspace file read WORKSPACE_ID src/app.py pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)" @@ -839,8 +887,11 @@ def _build_parser() -> argparse.ArgumentParser: Examples: pyro workspace create debian:12 pyro workspace create debian:12 --seed-path ./repo + pyro workspace create debian:12 --name repro-fix --label issue=123 pyro workspace create debian:12 --network-policy egress pyro workspace create debian:12 --secret API_TOKEN=expected + pyro workspace list + pyro workspace update WORKSPACE_ID --label owner=codex pyro workspace sync push WORKSPACE_ID ./changes pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint @@ -895,6 +946,17 @@ def _build_parser() -> argparse.ArgumentParser: "before the workspace is returned." ), ) + workspace_create_parser.add_argument( + "--name", + help="Optional human-friendly workspace name.", + ) + workspace_create_parser.add_argument( + "--label", + action="append", + default=[], + metavar="KEY=VALUE", + help="Attach one discovery label to the workspace. May be repeated.", + ) workspace_create_parser.add_argument( "--secret", action="append", @@ -1804,6 +1866,58 @@ while true; do sleep 60; done' action="store_true", help="Print structured JSON instead of human-readable output.", ) + workspace_list_parser = workspace_subparsers.add_parser( + "list", + help="List persisted workspaces.", + description="List persisted workspaces with names, labels, state, and activity ordering.", + epilog="Example:\n pyro workspace list", + formatter_class=_HelpFormatter, + ) + workspace_list_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_update_parser = workspace_subparsers.add_parser( + "update", + help="Update workspace name or labels.", + description="Update discovery metadata for one existing workspace.", + epilog=dedent( + """ + Examples: + pyro workspace update WORKSPACE_ID --name repro-fix + pyro workspace update WORKSPACE_ID --label owner=codex --label issue=123 + pyro workspace update WORKSPACE_ID --clear-label issue --clear-name + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_update_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_update_parser.add_argument("--name", help="Set or replace the workspace name.") + workspace_update_parser.add_argument( + "--clear-name", + action="store_true", + help="Clear the current workspace name.", + ) + workspace_update_parser.add_argument( + "--label", + action="append", + default=[], + metavar="KEY=VALUE", + help="Upsert one workspace label. May be repeated.", + ) + workspace_update_parser.add_argument( + "--clear-label", + action="append", + default=[], + metavar="KEY", + help="Remove one workspace label key. May be repeated.", + ) + workspace_update_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) workspace_status_parser = workspace_subparsers.add_parser( "status", help="Inspect one workspace.", @@ -1975,6 +2089,26 @@ def _parse_workspace_secret_env_options(values: list[str]) -> dict[str, str]: return parsed +def _parse_workspace_label_options(values: list[str]) -> dict[str, str]: + parsed: dict[str, str] = {} + for raw_value in values: + key, sep, label_value = raw_value.partition("=") + if sep == "" or key.strip() == "" or label_value.strip() == "": + raise ValueError("workspace labels must use KEY=VALUE") + parsed[key.strip()] = label_value.strip() + return parsed + + +def _parse_workspace_clear_label_options(values: list[str]) -> list[str]: + parsed: list[str] = [] + for raw_value in values: + label_key = raw_value.strip() + if label_key == "": + raise ValueError("workspace clear-label values must not be empty") + parsed.append(label_key) + return parsed + + def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int | None]]: parsed: list[dict[str, int | None]] = [] for raw_value in values: @@ -2103,6 +2237,7 @@ def main() -> None: for value in getattr(args, "secret_file", []) ), ] + labels = _parse_workspace_label_options(getattr(args, "label", [])) payload = pyro.create_workspace( environment=args.environment, vcpu_count=args.vcpu_count, @@ -2112,12 +2247,45 @@ def main() -> None: allow_host_compat=args.allow_host_compat, seed_path=args.seed_path, secrets=secrets or None, + name=args.name, + labels=labels or None, ) if bool(args.json): _print_json(payload) else: _print_workspace_summary_human(payload, action="Workspace") return + if args.workspace_command == "list": + payload = pyro.list_workspaces() + if bool(args.json): + _print_json(payload) + else: + _print_workspace_list_human(payload) + return + if args.workspace_command == "update": + labels = _parse_workspace_label_options(getattr(args, "label", [])) + clear_labels = _parse_workspace_clear_label_options( + getattr(args, "clear_label", []) + ) + try: + payload = pyro.update_workspace( + args.workspace_id, + name=args.name, + clear_name=bool(args.clear_name), + labels=labels or None, + clear_labels=clear_labels or None, + ) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + if bool(args.json): + _print_json(payload) + else: + _print_workspace_summary_human(payload, action="Workspace") + return if args.workspace_command == "exec": command = _require_command(args.command_args) secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", [])) diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index bcf117b..b224565 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -13,6 +13,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "exec", "export", "file", + "list", "logs", "patch", "reset", @@ -23,6 +24,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "status", "stop", "sync", + "update", ) PUBLIC_CLI_WORKSPACE_DISK_SUBCOMMANDS = ("export", "list", "read") PUBLIC_CLI_WORKSPACE_FILE_SUBCOMMANDS = ("list", "read", "write") @@ -38,6 +40,8 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = ( "--network-policy", "--allow-host-compat", "--seed-path", + "--name", + "--label", "--secret", "--secret-file", "--json", @@ -51,6 +55,7 @@ PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json") PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS = ("--recursive", "--json") PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS = ("--max-bytes", "--json") PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--json") +PUBLIC_CLI_WORKSPACE_LIST_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--json") PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json") PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",) @@ -87,6 +92,13 @@ PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_STOP_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS = ("--dest", "--json") +PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = ( + "--name", + "--clear-name", + "--label", + "--clear-label", + "--json", +) PUBLIC_CLI_RUN_FLAGS = ( "--vcpu-count", "--mem-mib", @@ -118,6 +130,7 @@ PUBLIC_SDK_METHODS = ( "list_snapshots", "list_workspace_disk", "list_workspace_files", + "list_workspaces", "logs_service", "logs_workspace", "network_info_vm", @@ -141,6 +154,7 @@ PUBLIC_SDK_METHODS = ( "stop_service", "stop_vm", "stop_workspace", + "update_workspace", "write_shell", "write_workspace_file", ) @@ -171,15 +185,16 @@ PUBLIC_MCP_TOOLS = ( "vm_stop", "workspace_create", "workspace_delete", + "workspace_diff", "workspace_disk_export", "workspace_disk_list", "workspace_disk_read", - "workspace_diff", "workspace_exec", "workspace_export", "workspace_file_list", "workspace_file_read", "workspace_file_write", + "workspace_list", "workspace_logs", "workspace_patch_apply", "workspace_reset", @@ -187,4 +202,5 @@ PUBLIC_MCP_TOOLS = ( "workspace_status", "workspace_stop", "workspace_sync_push", + "workspace_update", ) diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 51420b4..600cb57 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "3.2.0" +DEFAULT_CATALOG_VERSION = "3.3.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index c3561a7..c3fb329 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -78,7 +78,7 @@ DEFAULT_TIMEOUT_SECONDS = 30 DEFAULT_TTL_SECONDS = 600 DEFAULT_ALLOW_HOST_COMPAT = False -WORKSPACE_LAYOUT_VERSION = 7 +WORKSPACE_LAYOUT_VERSION = 8 WORKSPACE_BASELINE_DIRNAME = "baseline" WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar" WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots" @@ -109,6 +109,7 @@ WORKSPACE_SHELL_SIGNAL_NAMES = shell_signal_names() WORKSPACE_SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") WORKSPACE_SNAPSHOT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") WORKSPACE_SECRET_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$") +WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"] WorkspaceArtifactType = Literal["file", "directory", "symlink"] @@ -156,6 +157,9 @@ class WorkspaceRecord: last_error: str | None = None metadata: dict[str, str] = field(default_factory=dict) network: NetworkConfig | None = None + name: str | None = None + labels: dict[str, str] = field(default_factory=dict) + last_activity_at: float = 0.0 command_count: int = 0 last_command: dict[str, Any] | None = None workspace_seed: dict[str, Any] = field(default_factory=dict) @@ -173,6 +177,8 @@ class WorkspaceRecord: last_command: dict[str, Any] | None = None, workspace_seed: dict[str, Any] | None = None, secrets: list[WorkspaceSecretRecord] | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, ) -> WorkspaceRecord: return cls( workspace_id=instance.vm_id, @@ -189,6 +195,9 @@ class WorkspaceRecord: last_error=instance.last_error, metadata=dict(instance.metadata), network=instance.network, + name=name, + labels=dict(labels or {}), + last_activity_at=instance.created_at, command_count=command_count, last_command=last_command, workspace_seed=dict(workspace_seed or _empty_workspace_seed_payload()), @@ -233,6 +242,9 @@ class WorkspaceRecord: "last_error": self.last_error, "metadata": self.metadata, "network": _serialize_network(self.network), + "name": self.name, + "labels": self.labels, + "last_activity_at": self.last_activity_at, "command_count": self.command_count, "last_command": self.last_command, "workspace_seed": self.workspace_seed, @@ -258,6 +270,11 @@ class WorkspaceRecord: last_error=_optional_str(payload.get("last_error")), metadata=_string_dict(payload.get("metadata")), network=_deserialize_network(payload.get("network")), + name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True), + labels=_normalize_workspace_labels(payload.get("labels")), + last_activity_at=float( + payload.get("last_activity_at", float(payload["created_at"])) + ), command_count=int(payload.get("command_count", 0)), last_command=_optional_dict(payload.get("last_command")), workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")), @@ -1287,6 +1304,65 @@ def _normalize_workspace_service_name(service_name: str) -> str: return normalized +def _normalize_workspace_name( + name: str | None, + *, + allow_none: bool = False, +) -> str | None: + if name is None: + if allow_none: + return None + raise ValueError("name must not be empty") + normalized = name.strip() + if normalized == "": + if allow_none: + return None + raise ValueError("name must not be empty") + if len(normalized) > 120: + raise ValueError("name must be at most 120 characters") + return normalized + + +def _normalize_workspace_label_key(label_key: str) -> str: + normalized = label_key.strip() + if normalized == "": + raise ValueError("label key must not be empty") + if WORKSPACE_LABEL_KEY_RE.fullmatch(normalized) is None: + raise ValueError( + "label key must match " + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + ) + return normalized + + +def _normalize_workspace_label_value(label_key: str, label_value: str) -> str: + normalized = label_value.strip() + if normalized == "": + raise ValueError(f"label {label_key!r} must not be empty") + if len(normalized) > 120: + raise ValueError(f"label {label_key!r} must be at most 120 characters") + if "\n" in normalized or "\r" in normalized: + raise ValueError(f"label {label_key!r} must not contain newlines") + try: + normalized.encode("utf-8") + except UnicodeEncodeError as exc: + raise ValueError(f"label {label_key!r} must be valid UTF-8 text") from exc + return normalized + + +def _normalize_workspace_labels(value: object) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict): + raise ValueError("labels must be an object mapping keys to values") + normalized: dict[str, str] = {} + for raw_key, raw_value in value.items(): + key = _normalize_workspace_label_key(str(raw_key)) + label_value = _normalize_workspace_label_value(key, str(raw_value)) + normalized[key] = label_value + return dict(sorted(normalized.items())) + + def _normalize_workspace_snapshot_name( snapshot_name: str, *, @@ -3643,10 +3719,14 @@ class VmManager: allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, seed_path: str | Path | None = None, secrets: list[dict[str, str]] | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, ) -> dict[str, Any]: self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds) get_environment(environment, runtime_paths=self._runtime_paths) normalized_network_policy = _normalize_workspace_network_policy(str(network_policy)) + normalized_name = None if name is None else _normalize_workspace_name(name) + normalized_labels = _normalize_workspace_labels(labels) prepared_seed = self._prepare_workspace_seed(seed_path) now = time.time() workspace_id = uuid.uuid4().hex[:12] @@ -3709,6 +3789,8 @@ class VmManager: network_policy=normalized_network_policy, workspace_seed=prepared_seed.to_payload(), secrets=secret_records, + name=normalized_name, + labels=normalized_labels, ) if workspace.secrets: self._install_workspace_secrets_locked(workspace, instance) @@ -3787,6 +3869,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -3794,6 +3877,77 @@ class VmManager: "workspace_sync": workspace_sync, } + def list_workspaces(self) -> dict[str, Any]: + with self._lock: + now = time.time() + self._reap_expired_workspaces_locked(now) + workspaces: list[WorkspaceRecord] = [] + for metadata_path in self._workspaces_dir.glob("*/workspace.json"): + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + workspace = WorkspaceRecord.from_payload(payload) + self._refresh_workspace_liveness_locked(workspace) + self._refresh_workspace_service_counts_locked(workspace) + self._save_workspace_locked(workspace) + workspaces.append(workspace) + workspaces.sort( + key=lambda item: ( + -item.last_activity_at, + -item.created_at, + item.workspace_id, + ) + ) + return { + "count": len(workspaces), + "workspaces": [ + self._serialize_workspace_list_item(workspace) for workspace in workspaces + ], + } + + def update_workspace( + self, + workspace_id: str, + *, + name: str | None = None, + clear_name: bool = False, + labels: dict[str, str] | None = None, + clear_labels: list[str] | None = None, + ) -> dict[str, Any]: + if name is not None and clear_name: + raise ValueError("name and clear_name cannot be used together") + normalized_name = None if name is None else _normalize_workspace_name(name) + normalized_labels = None if labels is None else _normalize_workspace_labels(labels) + normalized_clear_labels = [ + _normalize_workspace_label_key(label_key) for label_key in (clear_labels or []) + ] + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + self._ensure_workspace_not_expired_locked(workspace, time.time()) + updated = False + if clear_name: + if workspace.name is not None: + workspace.name = None + updated = True + elif normalized_name is not None and workspace.name != normalized_name: + workspace.name = normalized_name + updated = True + if normalized_labels is not None: + for label_key, label_value in normalized_labels.items(): + if workspace.labels.get(label_key) != label_value: + workspace.labels[label_key] = label_value + updated = True + for label_key in normalized_clear_labels: + if label_key in workspace.labels: + del workspace.labels[label_key] + updated = True + workspace.labels = dict(sorted(workspace.labels.items())) + if not updated: + raise ValueError("workspace update requested no effective metadata change") + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + return self._serialize_workspace(workspace) + def export_workspace( self, workspace_id: str, @@ -3950,6 +4104,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4105,6 +4260,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4179,6 +4335,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_snapshot_locked(snapshot) return { @@ -4213,6 +4370,8 @@ class VmManager: self._workspace_baseline_snapshot_locked(workspace) self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name) self._delete_workspace_snapshot_locked(workspace_id, normalized_snapshot_name) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, "snapshot_name": normalized_snapshot_name, @@ -4278,6 +4437,7 @@ class VmManager: workspace.last_command = None workspace.reset_count += 1 workspace.last_reset_at = time.time() + self._touch_workspace_activity_locked(workspace, when=workspace.last_reset_at) self._save_workspace_locked(workspace) payload = self._serialize_workspace(workspace) except Exception: @@ -4425,6 +4585,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_shell_locked(shell) return self._serialize_workspace_shell(shell) @@ -4516,6 +4677,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_shell_locked(updated_shell) response = self._serialize_workspace_shell(updated_shell) @@ -4565,6 +4727,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_shell_locked(updated_shell) response = self._serialize_workspace_shell(updated_shell) @@ -4601,6 +4764,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._delete_workspace_shell_locked(workspace_id, shell_id) response = self._serialize_workspace_shell(closed_shell) @@ -4720,6 +4884,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_service_locked(service) return self._serialize_workspace_service(service) @@ -4799,6 +4964,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_service_locked(service) response = self._serialize_workspace_service(service) @@ -4897,8 +5063,10 @@ class VmManager: workspace.firecracker_pid = None workspace.last_error = str(exc) workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) raise + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) return self._serialize_workspace(workspace) @@ -4909,6 +5077,7 @@ class VmManager: self._refresh_workspace_liveness_locked(workspace) if workspace.state == "started": self._refresh_workspace_service_counts_locked(workspace) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) return self._serialize_workspace(workspace) instance = workspace.to_instance( @@ -4931,6 +5100,7 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = None workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) return self._serialize_workspace(workspace) except Exception as exc: @@ -4945,6 +5115,7 @@ class VmManager: workspace.firecracker_pid = None workspace.last_error = str(exc) workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) raise @@ -5081,12 +5252,15 @@ class VmManager: ) return { "workspace_id": workspace.workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), "environment": workspace.environment, "environment_version": workspace.metadata.get("environment_version"), "vcpu_count": workspace.vcpu_count, "mem_mib": workspace.mem_mib, "ttl_seconds": workspace.ttl_seconds, "created_at": workspace.created_at, + "last_activity_at": workspace.last_activity_at, "expires_at": workspace.expires_at, "state": workspace.state, "network_policy": workspace.network_policy, @@ -5109,6 +5283,24 @@ class VmManager: "metadata": workspace.metadata, } + def _serialize_workspace_list_item(self, workspace: WorkspaceRecord) -> dict[str, Any]: + service_count, running_service_count = self._workspace_service_counts_locked( + workspace.workspace_id + ) + return { + "workspace_id": workspace.workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "state": workspace.state, + "created_at": workspace.created_at, + "last_activity_at": workspace.last_activity_at, + "expires_at": workspace.expires_at, + "command_count": workspace.command_count, + "service_count": service_count, + "running_service_count": running_service_count, + } + def _serialize_workspace_shell(self, shell: WorkspaceShellRecord) -> dict[str, Any]: return { "workspace_id": shell.workspace_id, @@ -5258,6 +5450,14 @@ class VmManager: env_values[env_name] = secret_values[secret_name] return env_values + def _touch_workspace_activity_locked( + self, + workspace: WorkspaceRecord, + *, + when: float | None = None, + ) -> None: + workspace.last_activity_at = time.time() if when is None else when + def _install_workspace_secrets_locked( self, workspace: WorkspaceRecord, @@ -5626,6 +5826,7 @@ class VmManager: "duration_ms": exec_result.duration_ms, "execution_mode": execution_mode, } + self._touch_workspace_activity_locked(workspace) return entry def _read_workspace_logs_locked(self, workspace_id: str) -> list[dict[str, Any]]: diff --git a/tests/test_api.py b/tests/test_api.py index 7030b8d..533ad43 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7b669af..c853d75 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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, diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 406ce16..0add351 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -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() diff --git a/tests/test_server.py b/tests/test_server.py index 547f0d2..2f1b9e3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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"}, diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index e955ce9..baca81d 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -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() diff --git a/uv.lock b/uv.lock index b8da050..3944d11 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "3.2.0" +version = "3.3.0" source = { editable = "." } dependencies = [ { name = "mcp" },