Add workspace naming and discovery

Make concurrent workspaces easier to rediscover and resume without relying on opaque IDs alone.

Add optional workspace names, key/value labels, workspace list, and workspace update across the CLI, Python SDK, and MCP surface, and persist last_activity_at so list ordering reflects real mutating activity.

Update the stable contract, install/first-run docs, roadmap, and Python workspace example to teach the new discovery flow, and validate it with focused manager/CLI/API/server coverage plus uv lock, make check, make dist-check, and a real multi-workspace smoke for create, list, update, exec, reorder, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 23:16:10 -03:00
parent ab02ae46c7
commit 446f7fce04
21 changed files with 999 additions and 23 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'])}")

View file

@ -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" }

View file

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

View file

@ -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", []))

View file

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

View file

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

View file

@ -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]]:

View file

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

View file

@ -68,6 +68,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
workspace_help = _subparser_choice(parser, "workspace").format_help()
assert "stable workspace contract" in workspace_help
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help
assert "pyro workspace list" in workspace_help
assert (
"pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex"
in workspace_help
)
assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
assert "pyro workspace diff WORKSPACE_ID" in workspace_help
@ -84,6 +90,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
_subparser_choice(parser, "workspace"),
"create",
).format_help()
assert "--name" in workspace_create_help
assert "--label" in workspace_create_help
assert "--seed-path" in workspace_create_help
assert "--secret" in workspace_create_help
assert "--secret-file" in workspace_create_help
@ -116,6 +124,19 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "--output" in workspace_export_help
assert "Export one file or directory from `/workspace`" in workspace_export_help
workspace_list_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "list"
).format_help()
assert "List persisted workspaces" in workspace_list_help
workspace_update_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "update"
).format_help()
assert "--name" in workspace_update_help
assert "--clear-name" in workspace_update_help
assert "--label" in workspace_update_help
assert "--clear-label" in workspace_update_help
workspace_file_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "file"
).format_help()
@ -544,6 +565,8 @@ def test_cli_workspace_create_prints_json(
assert kwargs["environment"] == "debian:12"
assert kwargs["seed_path"] == "./repo"
assert kwargs["network_policy"] == "egress"
assert kwargs["name"] == "repro-fix"
assert kwargs["labels"] == {"issue": "123"}
return {"workspace_id": "workspace-123", "state": "started"}
class StubParser:
@ -558,6 +581,10 @@ def test_cli_workspace_create_prints_json(
network_policy="egress",
allow_host_compat=False,
seed_path="./repo",
name="repro-fix",
label=["issue=123"],
secret=[],
secret_file=[],
json=True,
)
@ -576,10 +603,13 @@ def test_cli_workspace_create_prints_human(
del kwargs
return {
"workspace_id": "workspace-123",
"name": "repro-fix",
"labels": {"issue": "123"},
"environment": "debian:12",
"state": "started",
"network_policy": "off",
"workspace_path": "/workspace",
"last_activity_at": 123.0,
"workspace_seed": {
"mode": "directory",
"seed_path": "/tmp/repo",
@ -606,6 +636,10 @@ def test_cli_workspace_create_prints_human(
network_policy="off",
allow_host_compat=False,
seed_path="/tmp/repo",
name="repro-fix",
label=["issue=123"],
secret=[],
secret_file=[],
json=False,
)
@ -614,6 +648,8 @@ def test_cli_workspace_create_prints_human(
cli.main()
output = capsys.readouterr().out
assert "Workspace ID: workspace-123" in output
assert "Name: repro-fix" in output
assert "Labels: issue=123" in output
assert "Workspace: /workspace" in output
assert "Workspace seed: directory from /tmp/repo" in output
@ -669,6 +705,214 @@ def test_cli_workspace_exec_prints_human_output(
)
def test_print_workspace_summary_human_handles_last_command_and_secret_filtering(
capsys: pytest.CaptureFixture[str],
) -> None:
cli._print_workspace_summary_human(
{
"workspace_id": "workspace-123",
"labels": {"owner": "codex"},
"environment": "debian:12",
"state": "started",
"workspace_path": "/workspace",
"last_activity_at": 123.0,
"network_policy": "off",
"workspace_seed": {"mode": "directory", "seed_path": "/tmp/repo"},
"secrets": ["ignored", {"name": "API_TOKEN", "source_kind": "literal"}],
"execution_mode": "guest_vsock",
"vcpu_count": 1,
"mem_mib": 1024,
"command_count": 2,
"reset_count": 0,
"service_count": 1,
"running_service_count": 1,
"last_command": {"command": "pytest", "exit_code": 0},
},
action="Workspace",
)
output = capsys.readouterr().out
assert "Secrets: API_TOKEN (literal)" in output
assert "Last command: pytest (exit_code=0)" in output
def test_cli_workspace_list_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def list_workspaces(self) -> dict[str, Any]:
return {
"count": 1,
"workspaces": [
{
"workspace_id": "workspace-123",
"name": "repro-fix",
"labels": {"issue": "123", "owner": "codex"},
"environment": "debian:12",
"state": "started",
"created_at": 100.0,
"last_activity_at": 200.0,
"expires_at": 700.0,
"command_count": 2,
"service_count": 1,
"running_service_count": 1,
}
],
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="list",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "workspace_id=workspace-123" in output
assert "name='repro-fix'" in output
assert "labels=issue=123,owner=codex" in output
def test_print_workspace_list_human_skips_non_dict_entries(
capsys: pytest.CaptureFixture[str],
) -> None:
cli._print_workspace_list_human(
{
"workspaces": [
"ignored",
{
"workspace_id": "workspace-123",
"state": "started",
"environment": "debian:12",
"last_activity_at": 200.0,
"expires_at": 700.0,
"command_count": 2,
"service_count": 1,
"running_service_count": 1,
},
]
}
)
output = capsys.readouterr().out
assert "workspace_id=workspace-123" in output
assert "ignored" not in output
def test_cli_workspace_list_prints_empty_state(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def list_workspaces(self) -> dict[str, Any]:
return {"count": 0, "workspaces": []}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="list",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
assert capsys.readouterr().out.strip() == "No workspaces."
def test_cli_workspace_update_prints_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert kwargs["name"] == "retry-run"
assert kwargs["clear_name"] is False
assert kwargs["labels"] == {"issue": "124", "owner": "codex"}
assert kwargs["clear_labels"] == ["stale"]
return {
"workspace_id": workspace_id,
"name": "retry-run",
"labels": {"issue": "124", "owner": "codex"},
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="update",
workspace_id="workspace-123",
name="retry-run",
clear_name=False,
label=["issue=124", "owner=codex"],
clear_label=["stale"],
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = json.loads(capsys.readouterr().out)
assert output["workspace_id"] == "workspace-123"
def test_cli_workspace_update_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert kwargs["name"] is None
assert kwargs["clear_name"] is True
assert kwargs["labels"] == {"owner": "codex"}
assert kwargs["clear_labels"] is None
return {
"workspace_id": workspace_id,
"name": None,
"labels": {"owner": "codex"},
"environment": "debian:12",
"state": "started",
"workspace_path": "/workspace",
"last_activity_at": 123.0,
"network_policy": "off",
"workspace_seed": {"mode": "empty", "seed_path": None},
"execution_mode": "guest_vsock",
"vcpu_count": 1,
"mem_mib": 1024,
"command_count": 0,
"reset_count": 0,
"service_count": 0,
"running_service_count": 0,
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="update",
workspace_id="workspace-123",
name=None,
clear_name=True,
label=["owner=codex"],
clear_label=[],
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "Workspace ID: workspace-123" in output
assert "Labels: owner=codex" in output
assert "Last activity at: 123.0" in output
def test_cli_workspace_export_prints_human_output(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
@ -3286,6 +3530,8 @@ def test_cli_workspace_create_passes_secrets(
{"name": "API_TOKEN", "value": "expected"},
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
]
assert kwargs["name"] is None
assert kwargs["labels"] is None
return {"workspace_id": "ws-123"}
class StubParser:
@ -3297,9 +3543,11 @@ def test_cli_workspace_create_passes_secrets(
vcpu_count=1,
mem_mib=1024,
ttl_seconds=600,
network=False,
network_policy="off",
allow_host_compat=False,
seed_path="./repo",
name=None,
label=[],
secret=["API_TOKEN=expected"],
secret_file=[f"FILE_TOKEN={secret_file}"],
json=True,

View file

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

View file

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

View file

@ -505,6 +505,153 @@ def test_workspace_sync_push_rejects_destination_outside_workspace(tmp_path: Pat
manager.push_workspace_sync(workspace_id, source_path=source_dir, dest="../escape")
def test_workspace_metadata_list_update_and_last_activity(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
name="repro-fix",
labels={"issue": "123", "owner": "codex"},
)
workspace_id = str(created["workspace_id"])
assert created["name"] == "repro-fix"
assert created["labels"] == {"issue": "123", "owner": "codex"}
created_activity = float(created["last_activity_at"])
listed = manager.list_workspaces()
assert listed["count"] == 1
assert listed["workspaces"][0]["name"] == "repro-fix"
assert listed["workspaces"][0]["labels"] == {"issue": "123", "owner": "codex"}
time.sleep(0.01)
updated = manager.update_workspace(
workspace_id,
name="retry-run",
labels={"issue": "124"},
clear_labels=["owner"],
)
assert updated["name"] == "retry-run"
assert updated["labels"] == {"issue": "124"}
updated_activity = float(updated["last_activity_at"])
assert updated_activity >= created_activity
status_before_exec = manager.status_workspace(workspace_id)
time.sleep(0.01)
manager.exec_workspace(workspace_id, command="printf 'ok\\n'", timeout_seconds=30)
status_after_exec = manager.status_workspace(workspace_id)
assert float(status_before_exec["last_activity_at"]) == updated_activity
assert float(status_after_exec["last_activity_at"]) > updated_activity
reset = manager.reset_workspace(workspace_id)
assert reset["name"] == "retry-run"
assert reset["labels"] == {"issue": "124"}
def test_workspace_list_loads_legacy_records_without_metadata_fields(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)
workspace_id = str(created["workspace_id"])
record_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
payload = json.loads(record_path.read_text(encoding="utf-8"))
payload.pop("name", None)
payload.pop("labels", None)
payload.pop("last_activity_at", None)
record_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
listed = manager.list_workspaces()
assert listed["count"] == 1
listed_workspace = listed["workspaces"][0]
assert listed_workspace["workspace_id"] == workspace_id
assert listed_workspace["name"] is None
assert listed_workspace["labels"] == {}
assert float(listed_workspace["last_activity_at"]) == float(created["created_at"])
def test_workspace_list_sorts_by_last_activity_and_skips_invalid_payload(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
first = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
name="first",
)
second = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
name="second",
)
first_id = str(first["workspace_id"])
second_id = str(second["workspace_id"])
time.sleep(0.01)
manager.exec_workspace(second_id, command="printf 'ok\\n'", timeout_seconds=30)
invalid_dir = tmp_path / "vms" / "workspaces" / "invalid"
invalid_dir.mkdir(parents=True)
(invalid_dir / "workspace.json").write_text('"not-a-dict"', encoding="utf-8")
listed = manager.list_workspaces()
assert listed["count"] == 2
assert [item["workspace_id"] for item in listed["workspaces"]] == [second_id, first_id]
def test_workspace_update_clear_name_and_rejects_noop(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
name="repro-fix",
labels={"issue": "123"},
)
workspace_id = str(created["workspace_id"])
cleared = manager.update_workspace(
workspace_id,
clear_name=True,
clear_labels=["issue"],
)
assert cleared["name"] is None
assert cleared["labels"] == {}
with pytest.raises(ValueError, match="workspace update requested no effective metadata change"):
manager.update_workspace(workspace_id, clear_name=True)
with pytest.raises(ValueError, match="name and clear_name cannot be used together"):
manager.update_workspace(workspace_id, name="retry-run", clear_name=True)
def test_workspace_export_rejects_empty_output_path(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)
with pytest.raises(ValueError, match="output_path must not be empty"):
manager.export_workspace(str(created["workspace_id"]), path=".", output_path=" ")
def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None:
seed_dir = tmp_path / "seed"
seed_dir.mkdir()

2
uv.lock generated
View file

@ -706,7 +706,7 @@ crypto = [
[[package]]
name = "pyro-mcp"
version = "3.2.0"
version = "3.3.0"
source = { editable = "." }
dependencies = [
{ name = "mcp" },