Add workspace naming and discovery
Make concurrent workspaces easier to rediscover and resume without relying on opaque IDs alone. Add optional workspace names, key/value labels, workspace list, and workspace update across the CLI, Python SDK, and MCP surface, and persist last_activity_at so list ordering reflects real mutating activity. Update the stable contract, install/first-run docs, roadmap, and Python workspace example to teach the new discovery flow, and validate it with focused manager/CLI/API/server coverage plus uv lock, make check, make dist-check, and a real multi-workspace smoke for create, list, update, exec, reorder, and delete.
This commit is contained in:
parent
ab02ae46c7
commit
446f7fce04
21 changed files with 999 additions and 23 deletions
12
CHANGELOG.md
12
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
|
||||
|
|
|
|||
15
README.md
15
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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'])}")
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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", []))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
|||
assert "vm_run" in tool_names
|
||||
assert "vm_create" in tool_names
|
||||
assert "workspace_create" in tool_names
|
||||
assert "workspace_list" in tool_names
|
||||
assert "workspace_update" in tool_names
|
||||
assert "workspace_start" in tool_names
|
||||
assert "workspace_stop" in tool_names
|
||||
assert "workspace_diff" in tool_names
|
||||
|
|
@ -140,6 +142,14 @@ def test_pyro_workspace_network_policy_and_published_ports_delegate() -> None:
|
|||
calls.append(("create_workspace", kwargs))
|
||||
return {"workspace_id": "workspace-123"}
|
||||
|
||||
def list_workspaces(self) -> dict[str, Any]:
|
||||
calls.append(("list_workspaces", {}))
|
||||
return {"count": 1, "workspaces": [{"workspace_id": "workspace-123"}]}
|
||||
|
||||
def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(("update_workspace", {"workspace_id": workspace_id, **kwargs}))
|
||||
return {"workspace_id": workspace_id, "name": "repro-fix", "labels": {"owner": "codex"}}
|
||||
|
||||
def start_service(
|
||||
self,
|
||||
workspace_id: str,
|
||||
|
|
@ -163,6 +173,15 @@ def test_pyro_workspace_network_policy_and_published_ports_delegate() -> None:
|
|||
pyro.create_workspace(
|
||||
environment="debian:12",
|
||||
network_policy="egress+published-ports",
|
||||
name="repro-fix",
|
||||
labels={"issue": "123"},
|
||||
)
|
||||
pyro.list_workspaces()
|
||||
pyro.update_workspace(
|
||||
"workspace-123",
|
||||
name="repro-fix",
|
||||
labels={"owner": "codex"},
|
||||
clear_labels=["issue"],
|
||||
)
|
||||
pyro.start_service(
|
||||
"workspace-123",
|
||||
|
|
@ -182,9 +201,25 @@ def test_pyro_workspace_network_policy_and_published_ports_delegate() -> None:
|
|||
"allow_host_compat": False,
|
||||
"seed_path": None,
|
||||
"secrets": None,
|
||||
"name": "repro-fix",
|
||||
"labels": {"issue": "123"},
|
||||
},
|
||||
)
|
||||
assert calls[1] == (
|
||||
"list_workspaces",
|
||||
{},
|
||||
)
|
||||
assert calls[2] == (
|
||||
"update_workspace",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"name": "repro-fix",
|
||||
"clear_name": False,
|
||||
"labels": {"owner": "codex"},
|
||||
"clear_labels": ["issue"],
|
||||
},
|
||||
)
|
||||
assert calls[3] == (
|
||||
"start_service",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
|
|
@ -219,12 +254,20 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
seed_path=source_dir,
|
||||
name="repro-fix",
|
||||
labels={"issue": "123"},
|
||||
secrets=[
|
||||
{"name": "API_TOKEN", "value": "expected"},
|
||||
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
|
||||
],
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
listed_before = pyro.list_workspaces()
|
||||
updated_metadata = pyro.update_workspace(
|
||||
workspace_id,
|
||||
labels={"owner": "codex"},
|
||||
clear_labels=["issue"],
|
||||
)
|
||||
updated_dir = tmp_path / "updated"
|
||||
updated_dir.mkdir()
|
||||
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
||||
|
|
@ -293,6 +336,11 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
{"name": "API_TOKEN", "source_kind": "literal"},
|
||||
{"name": "FILE_TOKEN", "source_kind": "file"},
|
||||
]
|
||||
assert created["name"] == "repro-fix"
|
||||
assert created["labels"] == {"issue": "123"}
|
||||
assert listed_before["count"] == 1
|
||||
assert listed_before["workspaces"][0]["name"] == "repro-fix"
|
||||
assert updated_metadata["labels"] == {"owner": "codex"}
|
||||
assert executed["stdout"] == "[REDACTED]\n"
|
||||
assert any(entry["path"] == "/workspace/note.txt" for entry in listed_files["entries"])
|
||||
assert file_read["content"] == "ok\n"
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
workspace_help = _subparser_choice(parser, "workspace").format_help()
|
||||
assert "stable workspace contract" in workspace_help
|
||||
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
|
||||
assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help
|
||||
assert "pyro workspace list" in workspace_help
|
||||
assert (
|
||||
"pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex"
|
||||
in workspace_help
|
||||
)
|
||||
assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help
|
||||
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace diff WORKSPACE_ID" in workspace_help
|
||||
|
|
@ -84,6 +90,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
_subparser_choice(parser, "workspace"),
|
||||
"create",
|
||||
).format_help()
|
||||
assert "--name" in workspace_create_help
|
||||
assert "--label" in workspace_create_help
|
||||
assert "--seed-path" in workspace_create_help
|
||||
assert "--secret" in workspace_create_help
|
||||
assert "--secret-file" in workspace_create_help
|
||||
|
|
@ -116,6 +124,19 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "--output" in workspace_export_help
|
||||
assert "Export one file or directory from `/workspace`" in workspace_export_help
|
||||
|
||||
workspace_list_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "list"
|
||||
).format_help()
|
||||
assert "List persisted workspaces" in workspace_list_help
|
||||
|
||||
workspace_update_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "update"
|
||||
).format_help()
|
||||
assert "--name" in workspace_update_help
|
||||
assert "--clear-name" in workspace_update_help
|
||||
assert "--label" in workspace_update_help
|
||||
assert "--clear-label" in workspace_update_help
|
||||
|
||||
workspace_file_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "file"
|
||||
).format_help()
|
||||
|
|
@ -544,6 +565,8 @@ def test_cli_workspace_create_prints_json(
|
|||
assert kwargs["environment"] == "debian:12"
|
||||
assert kwargs["seed_path"] == "./repo"
|
||||
assert kwargs["network_policy"] == "egress"
|
||||
assert kwargs["name"] == "repro-fix"
|
||||
assert kwargs["labels"] == {"issue": "123"}
|
||||
return {"workspace_id": "workspace-123", "state": "started"}
|
||||
|
||||
class StubParser:
|
||||
|
|
@ -558,6 +581,10 @@ def test_cli_workspace_create_prints_json(
|
|||
network_policy="egress",
|
||||
allow_host_compat=False,
|
||||
seed_path="./repo",
|
||||
name="repro-fix",
|
||||
label=["issue=123"],
|
||||
secret=[],
|
||||
secret_file=[],
|
||||
json=True,
|
||||
)
|
||||
|
||||
|
|
@ -576,10 +603,13 @@ def test_cli_workspace_create_prints_human(
|
|||
del kwargs
|
||||
return {
|
||||
"workspace_id": "workspace-123",
|
||||
"name": "repro-fix",
|
||||
"labels": {"issue": "123"},
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"network_policy": "off",
|
||||
"workspace_path": "/workspace",
|
||||
"last_activity_at": 123.0,
|
||||
"workspace_seed": {
|
||||
"mode": "directory",
|
||||
"seed_path": "/tmp/repo",
|
||||
|
|
@ -606,6 +636,10 @@ def test_cli_workspace_create_prints_human(
|
|||
network_policy="off",
|
||||
allow_host_compat=False,
|
||||
seed_path="/tmp/repo",
|
||||
name="repro-fix",
|
||||
label=["issue=123"],
|
||||
secret=[],
|
||||
secret_file=[],
|
||||
json=False,
|
||||
)
|
||||
|
||||
|
|
@ -614,6 +648,8 @@ def test_cli_workspace_create_prints_human(
|
|||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "Workspace ID: workspace-123" in output
|
||||
assert "Name: repro-fix" in output
|
||||
assert "Labels: issue=123" in output
|
||||
assert "Workspace: /workspace" in output
|
||||
assert "Workspace seed: directory from /tmp/repo" in output
|
||||
|
||||
|
|
@ -669,6 +705,214 @@ def test_cli_workspace_exec_prints_human_output(
|
|||
)
|
||||
|
||||
|
||||
def test_print_workspace_summary_human_handles_last_command_and_secret_filtering(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
cli._print_workspace_summary_human(
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"labels": {"owner": "codex"},
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"workspace_path": "/workspace",
|
||||
"last_activity_at": 123.0,
|
||||
"network_policy": "off",
|
||||
"workspace_seed": {"mode": "directory", "seed_path": "/tmp/repo"},
|
||||
"secrets": ["ignored", {"name": "API_TOKEN", "source_kind": "literal"}],
|
||||
"execution_mode": "guest_vsock",
|
||||
"vcpu_count": 1,
|
||||
"mem_mib": 1024,
|
||||
"command_count": 2,
|
||||
"reset_count": 0,
|
||||
"service_count": 1,
|
||||
"running_service_count": 1,
|
||||
"last_command": {"command": "pytest", "exit_code": 0},
|
||||
},
|
||||
action="Workspace",
|
||||
)
|
||||
output = capsys.readouterr().out
|
||||
assert "Secrets: API_TOKEN (literal)" in output
|
||||
assert "Last command: pytest (exit_code=0)" in output
|
||||
|
||||
|
||||
def test_cli_workspace_list_prints_human(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def list_workspaces(self) -> dict[str, Any]:
|
||||
return {
|
||||
"count": 1,
|
||||
"workspaces": [
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"name": "repro-fix",
|
||||
"labels": {"issue": "123", "owner": "codex"},
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"created_at": 100.0,
|
||||
"last_activity_at": 200.0,
|
||||
"expires_at": 700.0,
|
||||
"command_count": 2,
|
||||
"service_count": 1,
|
||||
"running_service_count": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="list",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "workspace_id=workspace-123" in output
|
||||
assert "name='repro-fix'" in output
|
||||
assert "labels=issue=123,owner=codex" in output
|
||||
|
||||
|
||||
def test_print_workspace_list_human_skips_non_dict_entries(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
cli._print_workspace_list_human(
|
||||
{
|
||||
"workspaces": [
|
||||
"ignored",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"state": "started",
|
||||
"environment": "debian:12",
|
||||
"last_activity_at": 200.0,
|
||||
"expires_at": 700.0,
|
||||
"command_count": 2,
|
||||
"service_count": 1,
|
||||
"running_service_count": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
output = capsys.readouterr().out
|
||||
assert "workspace_id=workspace-123" in output
|
||||
assert "ignored" not in output
|
||||
|
||||
|
||||
def test_cli_workspace_list_prints_empty_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def list_workspaces(self) -> dict[str, Any]:
|
||||
return {"count": 0, "workspaces": []}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="list",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
assert capsys.readouterr().out.strip() == "No workspaces."
|
||||
|
||||
|
||||
def test_cli_workspace_update_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert kwargs["name"] == "retry-run"
|
||||
assert kwargs["clear_name"] is False
|
||||
assert kwargs["labels"] == {"issue": "124", "owner": "codex"}
|
||||
assert kwargs["clear_labels"] == ["stale"]
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"name": "retry-run",
|
||||
"labels": {"issue": "124", "owner": "codex"},
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="update",
|
||||
workspace_id="workspace-123",
|
||||
name="retry-run",
|
||||
clear_name=False,
|
||||
label=["issue=124", "owner=codex"],
|
||||
clear_label=["stale"],
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["workspace_id"] == "workspace-123"
|
||||
|
||||
|
||||
def test_cli_workspace_update_prints_human(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert kwargs["name"] is None
|
||||
assert kwargs["clear_name"] is True
|
||||
assert kwargs["labels"] == {"owner": "codex"}
|
||||
assert kwargs["clear_labels"] is None
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"name": None,
|
||||
"labels": {"owner": "codex"},
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"workspace_path": "/workspace",
|
||||
"last_activity_at": 123.0,
|
||||
"network_policy": "off",
|
||||
"workspace_seed": {"mode": "empty", "seed_path": None},
|
||||
"execution_mode": "guest_vsock",
|
||||
"vcpu_count": 1,
|
||||
"mem_mib": 1024,
|
||||
"command_count": 0,
|
||||
"reset_count": 0,
|
||||
"service_count": 0,
|
||||
"running_service_count": 0,
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="update",
|
||||
workspace_id="workspace-123",
|
||||
name=None,
|
||||
clear_name=True,
|
||||
label=["owner=codex"],
|
||||
clear_label=[],
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "Workspace ID: workspace-123" in output
|
||||
assert "Labels: owner=codex" in output
|
||||
assert "Last activity at: 123.0" in output
|
||||
|
||||
|
||||
def test_cli_workspace_export_prints_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
|
@ -3286,6 +3530,8 @@ def test_cli_workspace_create_passes_secrets(
|
|||
{"name": "API_TOKEN", "value": "expected"},
|
||||
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
|
||||
]
|
||||
assert kwargs["name"] is None
|
||||
assert kwargs["labels"] is None
|
||||
return {"workspace_id": "ws-123"}
|
||||
|
||||
class StubParser:
|
||||
|
|
@ -3297,9 +3543,11 @@ def test_cli_workspace_create_passes_secrets(
|
|||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network=False,
|
||||
network_policy="off",
|
||||
allow_host_compat=False,
|
||||
seed_path="./repo",
|
||||
name=None,
|
||||
label=[],
|
||||
secret=["API_TOKEN=expected"],
|
||||
secret_file=[f"FILE_TOKEN={secret_file}"],
|
||||
json=True,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_FILE_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_LIST_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_PATCH_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_RESET_FLAGS,
|
||||
|
|
@ -52,6 +53,7 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
|
||||
PUBLIC_MCP_TOOLS,
|
||||
PUBLIC_SDK_METHODS,
|
||||
)
|
||||
|
|
@ -127,6 +129,16 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS:
|
||||
assert flag in workspace_export_help_text
|
||||
workspace_list_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "list"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_LIST_FLAGS:
|
||||
assert flag in workspace_list_help_text
|
||||
workspace_update_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "update"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS:
|
||||
assert flag in workspace_update_help_text
|
||||
workspace_file_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "file"
|
||||
).format_help()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
|||
assert "vm_run" in tool_names
|
||||
assert "vm_status" in tool_names
|
||||
assert "workspace_create" in tool_names
|
||||
assert "workspace_list" in tool_names
|
||||
assert "workspace_update" in tool_names
|
||||
assert "workspace_start" in tool_names
|
||||
assert "workspace_stop" in tool_names
|
||||
assert "workspace_diff" in tool_names
|
||||
|
|
@ -220,6 +222,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
"environment": "debian:12-base",
|
||||
"allow_host_compat": True,
|
||||
"seed_path": str(source_dir),
|
||||
"name": "repro-fix",
|
||||
"labels": {"issue": "123"},
|
||||
"secrets": [
|
||||
{"name": "API_TOKEN", "value": "expected"},
|
||||
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
|
||||
|
|
@ -228,6 +232,17 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
)
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
listed_before = _extract_structured(await server.call_tool("workspace_list", {}))
|
||||
updated = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_update",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"labels": {"owner": "codex"},
|
||||
"clear_labels": ["issue"],
|
||||
},
|
||||
)
|
||||
)
|
||||
update_dir = tmp_path / "update"
|
||||
update_dir.mkdir()
|
||||
(update_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
||||
|
|
@ -385,6 +400,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
)
|
||||
return (
|
||||
created,
|
||||
listed_before,
|
||||
updated,
|
||||
synced,
|
||||
executed,
|
||||
listed_files,
|
||||
|
|
@ -408,6 +425,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
|
||||
(
|
||||
created,
|
||||
listed_before,
|
||||
updated,
|
||||
synced,
|
||||
executed,
|
||||
listed_files,
|
||||
|
|
@ -429,6 +448,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
deleted,
|
||||
) = asyncio.run(_run())
|
||||
assert created["state"] == "started"
|
||||
assert created["name"] == "repro-fix"
|
||||
assert created["labels"] == {"issue": "123"}
|
||||
assert listed_before["count"] == 1
|
||||
assert listed_before["workspaces"][0]["name"] == "repro-fix"
|
||||
assert updated["labels"] == {"owner": "codex"}
|
||||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert created["secrets"] == [
|
||||
{"name": "API_TOKEN", "source_kind": "literal"},
|
||||
|
|
|
|||
|
|
@ -505,6 +505,153 @@ def test_workspace_sync_push_rejects_destination_outside_workspace(tmp_path: Pat
|
|||
manager.push_workspace_sync(workspace_id, source_path=source_dir, dest="../escape")
|
||||
|
||||
|
||||
def test_workspace_metadata_list_update_and_last_activity(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
name="repro-fix",
|
||||
labels={"issue": "123", "owner": "codex"},
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
assert created["name"] == "repro-fix"
|
||||
assert created["labels"] == {"issue": "123", "owner": "codex"}
|
||||
created_activity = float(created["last_activity_at"])
|
||||
|
||||
listed = manager.list_workspaces()
|
||||
assert listed["count"] == 1
|
||||
assert listed["workspaces"][0]["name"] == "repro-fix"
|
||||
assert listed["workspaces"][0]["labels"] == {"issue": "123", "owner": "codex"}
|
||||
|
||||
time.sleep(0.01)
|
||||
updated = manager.update_workspace(
|
||||
workspace_id,
|
||||
name="retry-run",
|
||||
labels={"issue": "124"},
|
||||
clear_labels=["owner"],
|
||||
)
|
||||
assert updated["name"] == "retry-run"
|
||||
assert updated["labels"] == {"issue": "124"}
|
||||
updated_activity = float(updated["last_activity_at"])
|
||||
assert updated_activity >= created_activity
|
||||
|
||||
status_before_exec = manager.status_workspace(workspace_id)
|
||||
time.sleep(0.01)
|
||||
manager.exec_workspace(workspace_id, command="printf 'ok\\n'", timeout_seconds=30)
|
||||
status_after_exec = manager.status_workspace(workspace_id)
|
||||
assert float(status_before_exec["last_activity_at"]) == updated_activity
|
||||
assert float(status_after_exec["last_activity_at"]) > updated_activity
|
||||
reset = manager.reset_workspace(workspace_id)
|
||||
assert reset["name"] == "retry-run"
|
||||
assert reset["labels"] == {"issue": "124"}
|
||||
|
||||
|
||||
def test_workspace_list_loads_legacy_records_without_metadata_fields(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
record_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
|
||||
payload = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
payload.pop("name", None)
|
||||
payload.pop("labels", None)
|
||||
payload.pop("last_activity_at", None)
|
||||
record_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
listed = manager.list_workspaces()
|
||||
assert listed["count"] == 1
|
||||
listed_workspace = listed["workspaces"][0]
|
||||
assert listed_workspace["workspace_id"] == workspace_id
|
||||
assert listed_workspace["name"] is None
|
||||
assert listed_workspace["labels"] == {}
|
||||
assert float(listed_workspace["last_activity_at"]) == float(created["created_at"])
|
||||
|
||||
|
||||
def test_workspace_list_sorts_by_last_activity_and_skips_invalid_payload(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
first = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
name="first",
|
||||
)
|
||||
second = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
name="second",
|
||||
)
|
||||
first_id = str(first["workspace_id"])
|
||||
second_id = str(second["workspace_id"])
|
||||
time.sleep(0.01)
|
||||
manager.exec_workspace(second_id, command="printf 'ok\\n'", timeout_seconds=30)
|
||||
|
||||
invalid_dir = tmp_path / "vms" / "workspaces" / "invalid"
|
||||
invalid_dir.mkdir(parents=True)
|
||||
(invalid_dir / "workspace.json").write_text('"not-a-dict"', encoding="utf-8")
|
||||
|
||||
listed = manager.list_workspaces()
|
||||
assert listed["count"] == 2
|
||||
assert [item["workspace_id"] for item in listed["workspaces"]] == [second_id, first_id]
|
||||
|
||||
|
||||
def test_workspace_update_clear_name_and_rejects_noop(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
name="repro-fix",
|
||||
labels={"issue": "123"},
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
|
||||
cleared = manager.update_workspace(
|
||||
workspace_id,
|
||||
clear_name=True,
|
||||
clear_labels=["issue"],
|
||||
)
|
||||
assert cleared["name"] is None
|
||||
assert cleared["labels"] == {}
|
||||
|
||||
with pytest.raises(ValueError, match="workspace update requested no effective metadata change"):
|
||||
manager.update_workspace(workspace_id, clear_name=True)
|
||||
|
||||
with pytest.raises(ValueError, match="name and clear_name cannot be used together"):
|
||||
manager.update_workspace(workspace_id, name="retry-run", clear_name=True)
|
||||
|
||||
|
||||
def test_workspace_export_rejects_empty_output_path(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="output_path must not be empty"):
|
||||
manager.export_workspace(str(created["workspace_id"]), path=".", output_path=" ")
|
||||
|
||||
|
||||
def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None:
|
||||
seed_dir = tmp_path / "seed"
|
||||
seed_dir.mkdir()
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -706,7 +706,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pyro-mcp"
|
||||
version = "3.2.0"
|
||||
version = "3.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue