Add project-aware chat startup defaults
Make repo-root chat startup native by letting MCP servers carry a default project source for workspace creation. When a chat host starts from a Git checkout, workspace_create can now omit seed_path and inherit the server startup source; explicit --project-path and clean-clone --repo-url/--repo-ref paths are supported as fallbacks. Add project startup resolution and materialization, surface origin_kind/origin_ref in workspace_seed, update chat-host docs and the repro/fix smoke to use project-aware workspace creation, and switch dist-check to uv run pyro so verification stays stable after uv reinstalls. Validated with uv lock, focused startup/server/CLI pytest coverage, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and real guest-backed smokes for both explicit project_path and bare repo-root auto-detection.
This commit is contained in:
parent
9b9b83ebeb
commit
535efc6919
28 changed files with 968 additions and 67 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -2,6 +2,18 @@
|
||||||
|
|
||||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
All notable user-visible changes to `pyro-mcp` are documented here.
|
||||||
|
|
||||||
|
## 4.1.0
|
||||||
|
|
||||||
|
- Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can
|
||||||
|
auto-detect the current Git checkout and let `workspace_create` omit
|
||||||
|
`seed_path` safely.
|
||||||
|
- Added explicit fallback startup flags for chat hosts that do not preserve the
|
||||||
|
server working directory: `--project-path`, `--repo-url`, `--repo-ref`, and
|
||||||
|
`--no-project-source`.
|
||||||
|
- Extended workspace seed metadata with startup origin fields so chat-facing
|
||||||
|
workspace creation can show whether a workspace came from a manual seed path,
|
||||||
|
the current project, or a clean cloned repo source.
|
||||||
|
|
||||||
## 4.0.0
|
## 4.0.0
|
||||||
|
|
||||||
- Flipped the default MCP/server profile from `workspace-full` to
|
- Flipped the default MCP/server profile from `workspace-full` to
|
||||||
|
|
|
||||||
14
Makefile
14
Makefile
|
|
@ -82,13 +82,13 @@ test:
|
||||||
check: lint typecheck test
|
check: lint typecheck test
|
||||||
|
|
||||||
dist-check:
|
dist-check:
|
||||||
.venv/bin/pyro --version
|
uv run pyro --version
|
||||||
.venv/bin/pyro --help >/dev/null
|
uv run pyro --help >/dev/null
|
||||||
.venv/bin/pyro mcp --help >/dev/null
|
uv run pyro mcp --help >/dev/null
|
||||||
.venv/bin/pyro run --help >/dev/null
|
uv run pyro run --help >/dev/null
|
||||||
.venv/bin/pyro env list >/dev/null
|
uv run pyro env list >/dev/null
|
||||||
.venv/bin/pyro env inspect debian:12 >/dev/null
|
uv run pyro env inspect debian:12 >/dev/null
|
||||||
.venv/bin/pyro doctor >/dev/null
|
uv run pyro doctor >/dev/null
|
||||||
|
|
||||||
pypi-publish:
|
pypi-publish:
|
||||||
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -30,7 +30,7 @@ SDK-first platform.
|
||||||
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
||||||
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
|
- 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)
|
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
|
||||||
- What's new in 4.0.0: [CHANGELOG.md#400](CHANGELOG.md#400)
|
- What's new in 4.1.0: [CHANGELOG.md#410](CHANGELOG.md#410)
|
||||||
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
||||||
|
|
||||||
## Who It's For
|
## Who It's For
|
||||||
|
|
@ -76,7 +76,7 @@ What success looks like:
|
||||||
```bash
|
```bash
|
||||||
Platform: linux-x86_64
|
Platform: linux-x86_64
|
||||||
Runtime: PASS
|
Runtime: PASS
|
||||||
Catalog version: 4.0.0
|
Catalog version: 4.1.0
|
||||||
...
|
...
|
||||||
[pull] phase=install environment=debian:12
|
[pull] phase=install environment=debian:12
|
||||||
[pull] phase=ready environment=debian:12
|
[pull] phase=ready environment=debian:12
|
||||||
|
|
@ -96,13 +96,26 @@ for the guest image.
|
||||||
## Chat Host Quickstart
|
## Chat Host Quickstart
|
||||||
|
|
||||||
After the quickstart works, the intended next step is to connect a chat host.
|
After the quickstart works, the intended next step is to connect a chat host.
|
||||||
Bare `pyro mcp serve` starts `workspace-core`, which is the default product
|
From a repo root, bare `pyro mcp serve` starts `workspace-core`, auto-detects
|
||||||
path.
|
the current Git checkout, and lets the first `workspace_create` omit
|
||||||
|
`seed_path`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from pyro-mcp pyro mcp serve
|
uvx --from pyro-mcp pyro mcp serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the host does not preserve the server working directory, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are starting outside a local checkout, use a clean clone source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
|
||||||
|
```
|
||||||
|
|
||||||
Copy-paste host-specific starts:
|
Copy-paste host-specific starts:
|
||||||
|
|
||||||
- Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
|
- Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
|
||||||
|
|
@ -136,6 +149,10 @@ OpenCode `opencode.json` snippet:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If OpenCode launches the server from an unexpected cwd, add
|
||||||
|
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
|
||||||
|
array.
|
||||||
|
|
||||||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||||
`pyro` in the same command or config shape.
|
`pyro` in the same command or config shape.
|
||||||
|
|
||||||
|
|
@ -146,7 +163,9 @@ snapshots, secrets, network policy, or disk tools.
|
||||||
|
|
||||||
1. Validate the host with `pyro doctor`.
|
1. Validate the host with `pyro doctor`.
|
||||||
2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`.
|
2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`.
|
||||||
3. Connect Claude Code, Codex, or OpenCode with `pyro mcp serve`.
|
3. Connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo
|
||||||
|
root, or use `--project-path` / `--repo-url` when cwd is not the source of
|
||||||
|
truth.
|
||||||
4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
|
4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
|
||||||
`repro-fix-loop` is the shortest chat-first story.
|
`repro-fix-loop` is the shortest chat-first story.
|
||||||
5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
|
5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uvx --from pyro-mcp pyro env list
|
$ uvx --from pyro-mcp pyro env list
|
||||||
Catalog version: 4.0.0
|
Catalog version: 4.1.0
|
||||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
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-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.
|
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||||
|
|
@ -71,13 +71,26 @@ streams, so they may appear in either order in terminals or capture tools. Use
|
||||||
|
|
||||||
## 5. Start the MCP server
|
## 5. Start the MCP server
|
||||||
|
|
||||||
Bare `pyro mcp serve` now starts `workspace-core`, which is the intended chat
|
Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also
|
||||||
path:
|
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||||
|
`seed_path`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uvx --from pyro-mcp pyro mcp serve
|
$ uvx --from pyro-mcp pyro mcp serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the host does not preserve the server working directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are outside a local checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
|
||||||
|
```
|
||||||
|
|
||||||
## 6. Connect a chat host
|
## 6. Connect a chat host
|
||||||
|
|
||||||
Claude Code:
|
Claude Code:
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ uvx --from pyro-mcp pyro env list
|
||||||
Expected output:
|
Expected output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
Catalog version: 4.0.0
|
Catalog version: 4.1.0
|
||||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
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-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.
|
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||||
|
|
@ -140,13 +140,26 @@ deterministic structured result.
|
||||||
|
|
||||||
## 5. Connect a chat host
|
## 5. Connect a chat host
|
||||||
|
|
||||||
Bare `pyro mcp serve` now starts `workspace-core`, which is the default
|
Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also
|
||||||
product path.
|
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||||
|
`seed_path`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from pyro-mcp pyro mcp serve
|
uvx --from pyro-mcp pyro mcp serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the host does not preserve the server working directory, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are starting outside a local checkout, use a clean clone source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
|
||||||
|
```
|
||||||
|
|
||||||
Copy-paste host-specific starts:
|
Copy-paste host-specific starts:
|
||||||
|
|
||||||
- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||||
|
|
@ -182,7 +195,8 @@ The intended user journey is:
|
||||||
1. validate the host with `pyro doctor`
|
1. validate the host with `pyro doctor`
|
||||||
2. pull `debian:12`
|
2. pull `debian:12`
|
||||||
3. prove guest execution with `pyro run debian:12 -- git --version`
|
3. prove guest execution with `pyro run debian:12 -- git --version`
|
||||||
4. connect Claude Code, Codex, or OpenCode with `pyro mcp serve`
|
4. connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo
|
||||||
|
root, or use `--project-path` / `--repo-url` when needed
|
||||||
5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
|
5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
|
||||||
6. trust but verify with `make smoke-use-cases`
|
6. trust but verify with `make smoke-use-cases`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,26 @@ through [install.md](install.md) or [first-run.md](first-run.md).
|
||||||
|
|
||||||
## Recommended Default
|
## Recommended Default
|
||||||
|
|
||||||
Bare `pyro mcp serve` starts `workspace-core`. That is the product path.
|
Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also
|
||||||
|
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||||
|
`seed_path`. That is the product path.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pyro mcp serve
|
pyro mcp serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the host does not preserve cwd, fall back to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pyro mcp serve --project-path /abs/path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are outside a repo checkout entirely, start from a clean clone source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pyro mcp serve --repo-url https://github.com/example/project.git
|
||||||
|
```
|
||||||
|
|
||||||
Use `--profile workspace-full` only when the chat truly needs shells, services,
|
Use `--profile workspace-full` only when the chat truly needs shells, services,
|
||||||
snapshots, secrets, network policy, or disk tools.
|
snapshots, secrets, network policy, or disk tools.
|
||||||
|
|
||||||
|
|
@ -33,6 +47,12 @@ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||||
claude mcp list
|
claude mcp list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If Claude Code launches the server from an unexpected cwd, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
Already installed:
|
Already installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -53,6 +73,12 @@ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||||
codex mcp list
|
codex mcp list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If Codex launches the server from an unexpected cwd, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
Already installed:
|
Already installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -87,6 +113,10 @@ Minimal `opencode.json` snippet:
|
||||||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||||
`pyro` in the same config shape.
|
`pyro` in the same config shape.
|
||||||
|
|
||||||
|
If OpenCode launches the server from an unexpected cwd, add
|
||||||
|
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
|
||||||
|
array.
|
||||||
|
|
||||||
## Generic MCP Fallback
|
## Generic MCP Fallback
|
||||||
|
|
||||||
Use this only when the host expects a plain `mcpServers` JSON config and does
|
Use this only when the host expects a plain `mcpServers` JSON config and does
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,16 @@ pyro mcp serve
|
||||||
What to expect:
|
What to expect:
|
||||||
|
|
||||||
- bare `pyro mcp serve` starts `workspace-core`
|
- bare `pyro mcp serve` starts `workspace-core`
|
||||||
|
- from a repo root, bare `pyro mcp serve` also auto-detects the current Git
|
||||||
|
checkout so `workspace_create` can omit `seed_path`
|
||||||
- `workspace-core` is the default product path for chat hosts
|
- `workspace-core` is the default product path for chat hosts
|
||||||
- `pyro mcp serve --profile workspace-full` explicitly opts into the larger
|
- `pyro mcp serve --profile workspace-full` explicitly opts into the larger
|
||||||
tool surface
|
tool surface
|
||||||
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface
|
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface
|
||||||
|
- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the
|
||||||
|
host does not preserve cwd
|
||||||
|
- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone
|
||||||
|
source instead of a local checkout
|
||||||
|
|
||||||
Host-specific setup docs:
|
Host-specific setup docs:
|
||||||
|
|
||||||
|
|
@ -111,7 +117,8 @@ Host-specific setup docs:
|
||||||
|
|
||||||
That is enough for the normal persistent editing loop:
|
That is enough for the normal persistent editing loop:
|
||||||
|
|
||||||
- create one workspace
|
- create one workspace, often without `seed_path` when the server already has a
|
||||||
|
project source
|
||||||
- sync or seed repo content
|
- sync or seed repo content
|
||||||
- inspect and edit files without shell quoting
|
- inspect and edit files without shell quoting
|
||||||
- run commands repeatedly in one sandbox
|
- run commands repeatedly in one sandbox
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ goal:
|
||||||
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
||||||
interface.
|
interface.
|
||||||
|
|
||||||
Current baseline is `4.0.0`:
|
Current baseline is `4.1.0`:
|
||||||
|
|
||||||
- `pyro mcp serve` is now the default product entrypoint
|
- `pyro mcp serve` is now the default product entrypoint
|
||||||
- `workspace-core` is now the default MCP profile
|
- `workspace-core` is now the default MCP profile
|
||||||
|
|
@ -79,7 +79,7 @@ capability gaps:
|
||||||
9. [`3.10.0` Use-Case Smoke Trust And Recipe Fidelity](llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md) - Done
|
9. [`3.10.0` Use-Case Smoke Trust And Recipe Fidelity](llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md) - Done
|
||||||
10. [`3.11.0` Host-Specific MCP Onramps](llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md) - Done
|
10. [`3.11.0` Host-Specific MCP Onramps](llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md) - Done
|
||||||
11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done
|
11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done
|
||||||
12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Planned
|
12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done
|
||||||
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Planned
|
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Planned
|
||||||
14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Planned
|
14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Planned
|
||||||
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Planned
|
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Planned
|
||||||
|
|
@ -114,10 +114,12 @@ Completed so far:
|
||||||
config manually.
|
config manually.
|
||||||
- `4.0.0` flipped the default MCP/server profile to `workspace-core`, so the bare entrypoint now
|
- `4.0.0` flipped the default MCP/server profile to `workspace-core`, so the bare entrypoint now
|
||||||
matches the recommended narrow chat-host profile across CLI, SDK, and package-level factories.
|
matches the recommended narrow chat-host profile across CLI, SDK, and package-level factories.
|
||||||
|
- `4.1.0` made repo-root startup native for chat hosts, so bare `pyro mcp serve` can auto-detect
|
||||||
|
the current Git checkout and let the first `workspace_create` omit `seed_path`, with explicit
|
||||||
|
`--project-path` and `--repo-url` fallbacks when cwd is not the source of truth.
|
||||||
|
|
||||||
Planned next:
|
Planned next:
|
||||||
|
|
||||||
- [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md)
|
|
||||||
- [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md)
|
- [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md)
|
||||||
- [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md)
|
- [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md)
|
||||||
- [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md)
|
- [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# `4.1.0` Project-Aware Chat Startup
|
# `4.1.0` Project-Aware Chat Startup
|
||||||
|
|
||||||
Status: Planned
|
Status: Done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,16 @@ reset back to baseline.
|
||||||
|
|
||||||
Chat-host recipe:
|
Chat-host recipe:
|
||||||
|
|
||||||
1. Create one workspace from the broken repro seed.
|
1. Start the server from the repo root with bare `pyro mcp serve`, or use
|
||||||
2. Run the failing command.
|
`--project-path` if the host does not preserve cwd.
|
||||||
3. Inspect the broken file with structured file reads.
|
2. Create one workspace from that project-aware server without manually passing
|
||||||
4. Apply the fix with `workspace_patch_apply`.
|
`seed_path`.
|
||||||
5. Rerun the failing command in the same workspace.
|
3. Run the failing command.
|
||||||
6. Diff and export the changed result.
|
4. Inspect the broken file with structured file reads.
|
||||||
7. Reset to baseline and delete the workspace.
|
5. Apply the fix with `workspace_patch_apply`.
|
||||||
|
6. Rerun the failing command in the same workspace.
|
||||||
|
7. Diff and export the changed result.
|
||||||
|
8. Reset to baseline and delete the workspace.
|
||||||
|
|
||||||
This is the main `workspace-core` story: model-native file ops, repeatable exec,
|
This is the main `workspace-core` story: model-native file ops, repeatable exec,
|
||||||
structured diff, explicit export, and reset-over-repair.
|
structured diff, explicit export, and reset-over-repair.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||||
claude mcp list
|
claude mcp list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run that from the repo root when you want the first `workspace_create` to start
|
||||||
|
from the current checkout automatically.
|
||||||
|
|
||||||
Already installed:
|
Already installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -16,6 +19,13 @@ claude mcp add pyro -- pyro mcp serve
|
||||||
claude mcp list
|
claude mcp list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If Claude Code launches the server from an unexpected cwd, pin the project
|
||||||
|
explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
Move to `workspace-full` only when the chat truly needs shells, services,
|
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||||
snapshots, secrets, network policy, or disk tools:
|
snapshots, secrets, network policy, or disk tools:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||||
codex mcp list
|
codex mcp list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run that from the repo root when you want the first `workspace_create` to start
|
||||||
|
from the current checkout automatically.
|
||||||
|
|
||||||
Already installed:
|
Already installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -16,6 +19,13 @@ codex mcp add pyro -- pyro mcp serve
|
||||||
codex mcp list
|
codex mcp list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If Codex launches the server from an unexpected cwd, pin the project
|
||||||
|
explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
Move to `workspace-full` only when the chat truly needs shells, services,
|
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||||
snapshots, secrets, network policy, or disk tools:
|
snapshots, secrets, network policy, or disk tools:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the host does not preserve the server working directory and you want the
|
||||||
|
first `workspace_create` to start from a specific checkout, add
|
||||||
|
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list.
|
||||||
|
|
||||||
Profile progression:
|
Profile progression:
|
||||||
|
|
||||||
- `workspace-core`: the default and recommended first persistent chat profile
|
- `workspace-core`: the default and recommended first persistent chat profile
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "pyro-mcp"
|
name = "pyro-mcp"
|
||||||
version = "4.0.0"
|
version = "4.1.0"
|
||||||
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
|
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ from pyro_mcp.contract import (
|
||||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||||
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
||||||
)
|
)
|
||||||
|
from pyro_mcp.project_startup import (
|
||||||
|
ProjectStartupSource,
|
||||||
|
describe_project_startup_source,
|
||||||
|
materialize_project_startup_source,
|
||||||
|
resolve_project_startup_source,
|
||||||
|
)
|
||||||
from pyro_mcp.vm_manager import (
|
from pyro_mcp.vm_manager import (
|
||||||
DEFAULT_ALLOW_HOST_COMPAT,
|
DEFAULT_ALLOW_HOST_COMPAT,
|
||||||
DEFAULT_MEM_MIB,
|
DEFAULT_MEM_MIB,
|
||||||
|
|
@ -39,6 +45,18 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
|
||||||
return cast(McpToolProfile, profile)
|
return cast(McpToolProfile, profile)
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_create_description(startup_source: ProjectStartupSource | None) -> str:
|
||||||
|
if startup_source is None:
|
||||||
|
return "Create and start a persistent workspace."
|
||||||
|
described_source = describe_project_startup_source(startup_source)
|
||||||
|
if described_source is None:
|
||||||
|
return "Create and start a persistent workspace."
|
||||||
|
return (
|
||||||
|
"Create and start a persistent workspace. If `seed_path` is omitted, "
|
||||||
|
f"the server seeds from {described_source}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Pyro:
|
class Pyro:
|
||||||
"""High-level facade over the ephemeral VM runtime."""
|
"""High-level facade over the ephemeral VM runtime."""
|
||||||
|
|
||||||
|
|
@ -462,14 +480,31 @@ class Pyro:
|
||||||
allow_host_compat=allow_host_compat,
|
allow_host_compat=allow_host_compat,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_server(self, *, profile: McpToolProfile = "workspace-core") -> FastMCP:
|
def create_server(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
profile: McpToolProfile = "workspace-core",
|
||||||
|
project_path: str | Path | None = None,
|
||||||
|
repo_url: str | None = None,
|
||||||
|
repo_ref: str | None = None,
|
||||||
|
no_project_source: bool = False,
|
||||||
|
) -> FastMCP:
|
||||||
"""Create an MCP server for one of the stable public tool profiles.
|
"""Create an MCP server for one of the stable public tool profiles.
|
||||||
|
|
||||||
`workspace-core` is the default stable chat-host profile in 4.x. Use
|
`workspace-core` is the default stable chat-host profile in 4.x. Use
|
||||||
`profile="workspace-full"` only when the host truly needs the full
|
`profile="workspace-full"` only when the host truly needs the full
|
||||||
advanced workspace surface.
|
advanced workspace surface. By default, the server auto-detects the
|
||||||
|
nearest Git worktree root from its current working directory and uses
|
||||||
|
that source when `workspace_create` omits `seed_path`. `project_path`,
|
||||||
|
`repo_url`, and `no_project_source` override that behavior explicitly.
|
||||||
"""
|
"""
|
||||||
normalized_profile = _validate_mcp_profile(profile)
|
normalized_profile = _validate_mcp_profile(profile)
|
||||||
|
startup_source = resolve_project_startup_source(
|
||||||
|
project_path=project_path,
|
||||||
|
repo_url=repo_url,
|
||||||
|
repo_ref=repo_ref,
|
||||||
|
no_project_source=no_project_source,
|
||||||
|
)
|
||||||
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
|
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
|
||||||
server = FastMCP(name="pyro_mcp")
|
server = FastMCP(name="pyro_mcp")
|
||||||
|
|
||||||
|
|
@ -583,9 +618,56 @@ class Pyro:
|
||||||
return self.reap_expired()
|
return self.reap_expired()
|
||||||
|
|
||||||
if _enabled("workspace_create"):
|
if _enabled("workspace_create"):
|
||||||
|
workspace_create_description = _workspace_create_description(startup_source)
|
||||||
|
|
||||||
|
def _create_workspace_from_server_defaults(
|
||||||
|
*,
|
||||||
|
environment: str,
|
||||||
|
vcpu_count: int,
|
||||||
|
mem_mib: int,
|
||||||
|
ttl_seconds: int,
|
||||||
|
network_policy: str,
|
||||||
|
allow_host_compat: bool,
|
||||||
|
seed_path: str | None,
|
||||||
|
secrets: list[dict[str, str]] | None,
|
||||||
|
name: str | None,
|
||||||
|
labels: dict[str, str] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if seed_path is not None or startup_source is None:
|
||||||
|
return self.create_workspace(
|
||||||
|
environment=environment,
|
||||||
|
vcpu_count=vcpu_count,
|
||||||
|
mem_mib=mem_mib,
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
network_policy=network_policy,
|
||||||
|
allow_host_compat=allow_host_compat,
|
||||||
|
seed_path=seed_path,
|
||||||
|
secrets=secrets,
|
||||||
|
name=name,
|
||||||
|
labels=labels,
|
||||||
|
)
|
||||||
|
with materialize_project_startup_source(startup_source) as resolved_seed_path:
|
||||||
|
prepared_seed = self._manager._prepare_workspace_seed( # noqa: SLF001
|
||||||
|
resolved_seed_path,
|
||||||
|
origin_kind=startup_source.kind,
|
||||||
|
origin_ref=startup_source.origin_ref,
|
||||||
|
)
|
||||||
|
return self._manager.create_workspace(
|
||||||
|
environment=environment,
|
||||||
|
vcpu_count=vcpu_count,
|
||||||
|
mem_mib=mem_mib,
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
network_policy=network_policy,
|
||||||
|
allow_host_compat=allow_host_compat,
|
||||||
|
secrets=secrets,
|
||||||
|
name=name,
|
||||||
|
labels=labels,
|
||||||
|
_prepared_seed=prepared_seed,
|
||||||
|
)
|
||||||
|
|
||||||
if normalized_profile == "workspace-core":
|
if normalized_profile == "workspace-core":
|
||||||
|
|
||||||
@server.tool(name="workspace_create")
|
@server.tool(name="workspace_create", description=workspace_create_description)
|
||||||
async def workspace_create_core(
|
async def workspace_create_core(
|
||||||
environment: str,
|
environment: str,
|
||||||
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||||
|
|
@ -596,8 +678,7 @@ class Pyro:
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
labels: dict[str, str] | None = None,
|
labels: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create and start a persistent workspace."""
|
return _create_workspace_from_server_defaults(
|
||||||
return self.create_workspace(
|
|
||||||
environment=environment,
|
environment=environment,
|
||||||
vcpu_count=vcpu_count,
|
vcpu_count=vcpu_count,
|
||||||
mem_mib=mem_mib,
|
mem_mib=mem_mib,
|
||||||
|
|
@ -612,7 +693,7 @@ class Pyro:
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
@server.tool(name="workspace_create")
|
@server.tool(name="workspace_create", description=workspace_create_description)
|
||||||
async def workspace_create_full(
|
async def workspace_create_full(
|
||||||
environment: str,
|
environment: str,
|
||||||
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||||
|
|
@ -625,8 +706,7 @@ class Pyro:
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
labels: dict[str, str] | None = None,
|
labels: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create and start a persistent workspace."""
|
return _create_workspace_from_server_defaults(
|
||||||
return self.create_workspace(
|
|
||||||
environment=environment,
|
environment=environment,
|
||||||
vcpu_count=vcpu_count,
|
vcpu_count=vcpu_count,
|
||||||
mem_mib=mem_mib,
|
mem_mib=mem_mib,
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,16 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
|
||||||
if isinstance(workspace_seed, dict):
|
if isinstance(workspace_seed, dict):
|
||||||
mode = str(workspace_seed.get("mode", "empty"))
|
mode = str(workspace_seed.get("mode", "empty"))
|
||||||
seed_path = workspace_seed.get("seed_path")
|
seed_path = workspace_seed.get("seed_path")
|
||||||
if isinstance(seed_path, str) and seed_path != "":
|
origin_kind = workspace_seed.get("origin_kind")
|
||||||
|
origin_ref = workspace_seed.get("origin_ref")
|
||||||
|
if isinstance(origin_kind, str) and isinstance(origin_ref, str) and origin_ref != "":
|
||||||
|
if origin_kind == "project_path":
|
||||||
|
print(f"Workspace seed: {mode} from project {origin_ref}")
|
||||||
|
elif origin_kind == "repo_url":
|
||||||
|
print(f"Workspace seed: {mode} from clean clone {origin_ref}")
|
||||||
|
else:
|
||||||
|
print(f"Workspace seed: {mode} from {origin_ref}")
|
||||||
|
elif isinstance(seed_path, str) and seed_path != "":
|
||||||
print(f"Workspace seed: {mode} from {seed_path}")
|
print(f"Workspace seed: {mode} from {seed_path}")
|
||||||
else:
|
else:
|
||||||
print(f"Workspace seed: {mode}")
|
print(f"Workspace seed: {mode}")
|
||||||
|
|
@ -770,6 +779,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"""
|
"""
|
||||||
Examples:
|
Examples:
|
||||||
pyro mcp serve
|
pyro mcp serve
|
||||||
|
pyro mcp serve --project-path .
|
||||||
|
pyro mcp serve --repo-url https://github.com/example/project.git
|
||||||
pyro mcp serve --profile vm-run
|
pyro mcp serve --profile vm-run
|
||||||
pyro mcp serve --profile workspace-full
|
pyro mcp serve --profile workspace-full
|
||||||
"""
|
"""
|
||||||
|
|
@ -783,12 +794,15 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
description=(
|
description=(
|
||||||
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
|
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
|
||||||
"serve` now starts `workspace-core`, the recommended first profile "
|
"serve` now starts `workspace-core`, the recommended first profile "
|
||||||
"for most chat hosts."
|
"for most chat hosts. When launched from inside a Git checkout, it "
|
||||||
|
"also seeds the first workspace from that repo by default."
|
||||||
),
|
),
|
||||||
epilog=dedent(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
Default and recommended first start:
|
Default and recommended first start:
|
||||||
pyro mcp serve
|
pyro mcp serve
|
||||||
|
pyro mcp serve --project-path .
|
||||||
|
pyro mcp serve --repo-url https://github.com/example/project.git
|
||||||
|
|
||||||
Profiles:
|
Profiles:
|
||||||
workspace-core: default for normal persistent chat editing
|
workspace-core: default for normal persistent chat editing
|
||||||
|
|
@ -796,6 +810,12 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
workspace-full: larger opt-in surface for shells, services,
|
workspace-full: larger opt-in surface for shells, services,
|
||||||
snapshots, secrets, network policy, and disk tools
|
snapshots, secrets, network policy, and disk tools
|
||||||
|
|
||||||
|
Project-aware startup:
|
||||||
|
- bare `pyro mcp serve` auto-detects the nearest Git checkout
|
||||||
|
from the current working directory
|
||||||
|
- use --project-path when the host does not preserve cwd
|
||||||
|
- use --repo-url for a clean-clone source outside a local checkout
|
||||||
|
|
||||||
Use --profile workspace-full only when the host truly needs those
|
Use --profile workspace-full only when the host truly needs those
|
||||||
extra workspace capabilities.
|
extra workspace capabilities.
|
||||||
"""
|
"""
|
||||||
|
|
@ -812,6 +832,33 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"`workspace-full` is the larger opt-in profile."
|
"`workspace-full` is the larger opt-in profile."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group()
|
||||||
|
mcp_source_group.add_argument(
|
||||||
|
"--project-path",
|
||||||
|
help=(
|
||||||
|
"Seed default workspaces from this local project path. If the path "
|
||||||
|
"is inside a Git checkout, pyro uses that repo root."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mcp_source_group.add_argument(
|
||||||
|
"--repo-url",
|
||||||
|
help=(
|
||||||
|
"Seed default workspaces from a clean host-side clone of this repo URL "
|
||||||
|
"when `workspace_create` omits `seed_path`."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mcp_serve_parser.add_argument(
|
||||||
|
"--repo-ref",
|
||||||
|
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
|
||||||
|
)
|
||||||
|
mcp_serve_parser.add_argument(
|
||||||
|
"--no-project-source",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Disable automatic Git checkout detection from the current working "
|
||||||
|
"directory."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
run_parser = subparsers.add_parser(
|
run_parser = subparsers.add_parser(
|
||||||
"run",
|
"run",
|
||||||
|
|
@ -2304,7 +2351,13 @@ def main() -> None:
|
||||||
_print_prune_human(prune_payload)
|
_print_prune_human(prune_payload)
|
||||||
return
|
return
|
||||||
if args.command == "mcp":
|
if args.command == "mcp":
|
||||||
pyro.create_server(profile=args.profile).run(transport="stdio")
|
pyro.create_server(
|
||||||
|
profile=args.profile,
|
||||||
|
project_path=args.project_path,
|
||||||
|
repo_url=args.repo_url,
|
||||||
|
repo_ref=args.repo_ref,
|
||||||
|
no_project_source=bool(args.no_project_source),
|
||||||
|
).run(transport="stdio")
|
||||||
return
|
return
|
||||||
if args.command == "run":
|
if args.command == "run":
|
||||||
command = _require_command(args.command_args)
|
command = _require_command(args.command_args)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@ PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
|
||||||
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
||||||
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
||||||
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
|
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
|
||||||
PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",)
|
PUBLIC_CLI_MCP_SERVE_FLAGS = (
|
||||||
|
"--profile",
|
||||||
|
"--project-path",
|
||||||
|
"--repo-url",
|
||||||
|
"--repo-ref",
|
||||||
|
"--no-project-source",
|
||||||
|
)
|
||||||
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
||||||
"create",
|
"create",
|
||||||
"delete",
|
"delete",
|
||||||
|
|
|
||||||
149
src/pyro_mcp/project_startup.py
Normal file
149
src/pyro_mcp/project_startup.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""Server-scoped project startup source helpers for MCP chat flows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, Literal
|
||||||
|
|
||||||
|
ProjectStartupSourceKind = Literal["project_path", "repo_url"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectStartupSource:
|
||||||
|
"""Server-scoped default source for workspace creation."""
|
||||||
|
|
||||||
|
kind: ProjectStartupSourceKind
|
||||||
|
origin_ref: str
|
||||||
|
resolved_path: Path | None = None
|
||||||
|
repo_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run( # noqa: S603
|
||||||
|
command,
|
||||||
|
cwd=str(cwd) if cwd is not None else None,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_git_root(start_dir: Path) -> Path | None:
|
||||||
|
result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
stdout = result.stdout.strip()
|
||||||
|
if stdout == "":
|
||||||
|
return None
|
||||||
|
return Path(stdout).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path:
|
||||||
|
resolved = Path(project_path).expanduser()
|
||||||
|
if not resolved.is_absolute():
|
||||||
|
resolved = (cwd / resolved).resolve()
|
||||||
|
else:
|
||||||
|
resolved = resolved.resolve()
|
||||||
|
if not resolved.exists():
|
||||||
|
raise ValueError(f"project_path {resolved} does not exist")
|
||||||
|
if not resolved.is_dir():
|
||||||
|
raise ValueError(f"project_path {resolved} must be a directory")
|
||||||
|
git_root = _detect_git_root(resolved)
|
||||||
|
if git_root is not None:
|
||||||
|
return git_root
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_project_startup_source(
|
||||||
|
*,
|
||||||
|
project_path: str | Path | None = None,
|
||||||
|
repo_url: str | None = None,
|
||||||
|
repo_ref: str | None = None,
|
||||||
|
no_project_source: bool = False,
|
||||||
|
cwd: Path | None = None,
|
||||||
|
) -> ProjectStartupSource | None:
|
||||||
|
working_dir = Path.cwd() if cwd is None else cwd.resolve()
|
||||||
|
if no_project_source:
|
||||||
|
if project_path is not None or repo_url is not None or repo_ref is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"--no-project-source cannot be combined with --project-path, "
|
||||||
|
"--repo-url, or --repo-ref"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if project_path is not None and repo_url is not None:
|
||||||
|
raise ValueError("--project-path and --repo-url are mutually exclusive")
|
||||||
|
if repo_ref is not None and repo_url is None:
|
||||||
|
raise ValueError("--repo-ref requires --repo-url")
|
||||||
|
if project_path is not None:
|
||||||
|
resolved_path = _resolve_project_path(project_path, cwd=working_dir)
|
||||||
|
return ProjectStartupSource(
|
||||||
|
kind="project_path",
|
||||||
|
origin_ref=str(resolved_path),
|
||||||
|
resolved_path=resolved_path,
|
||||||
|
)
|
||||||
|
if repo_url is not None:
|
||||||
|
normalized_repo_url = repo_url.strip()
|
||||||
|
if normalized_repo_url == "":
|
||||||
|
raise ValueError("--repo-url must not be empty")
|
||||||
|
normalized_repo_ref = None if repo_ref is None else repo_ref.strip()
|
||||||
|
if normalized_repo_ref == "":
|
||||||
|
raise ValueError("--repo-ref must not be empty")
|
||||||
|
return ProjectStartupSource(
|
||||||
|
kind="repo_url",
|
||||||
|
origin_ref=normalized_repo_url,
|
||||||
|
repo_ref=normalized_repo_ref,
|
||||||
|
)
|
||||||
|
detected_root = _detect_git_root(working_dir)
|
||||||
|
if detected_root is None:
|
||||||
|
return None
|
||||||
|
return ProjectStartupSource(
|
||||||
|
kind="project_path",
|
||||||
|
origin_ref=str(detected_root),
|
||||||
|
resolved_path=detected_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]:
|
||||||
|
if source.kind == "project_path":
|
||||||
|
if source.resolved_path is None:
|
||||||
|
raise RuntimeError("project_path source is missing a resolved path")
|
||||||
|
yield source.resolved_path
|
||||||
|
return
|
||||||
|
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-"))
|
||||||
|
clone_dir = temp_dir / "clone"
|
||||||
|
try:
|
||||||
|
clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)])
|
||||||
|
if clone_result.returncode != 0:
|
||||||
|
stderr = clone_result.stderr.strip() or "git clone failed"
|
||||||
|
raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}")
|
||||||
|
if source.repo_ref is not None:
|
||||||
|
checkout_result = _run_git(
|
||||||
|
["git", "checkout", "--quiet", source.repo_ref],
|
||||||
|
cwd=clone_dir,
|
||||||
|
)
|
||||||
|
if checkout_result.returncode != 0:
|
||||||
|
stderr = checkout_result.stderr.strip() or "git checkout failed"
|
||||||
|
raise RuntimeError(
|
||||||
|
f"failed to checkout repo_ref {source.repo_ref!r} for "
|
||||||
|
f"repo_url {source.origin_ref!r}: {stderr}"
|
||||||
|
)
|
||||||
|
yield clone_dir
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None:
|
||||||
|
if source is None:
|
||||||
|
return None
|
||||||
|
if source.kind == "project_path":
|
||||||
|
return f"the current project at {source.origin_ref}"
|
||||||
|
if source.repo_ref is None:
|
||||||
|
return f"the clean clone source {source.origin_ref}"
|
||||||
|
return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}"
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from pyro_mcp.api import McpToolProfile, Pyro
|
from pyro_mcp.api import McpToolProfile, Pyro
|
||||||
|
|
@ -12,14 +14,26 @@ def create_server(
|
||||||
manager: VmManager | None = None,
|
manager: VmManager | None = None,
|
||||||
*,
|
*,
|
||||||
profile: McpToolProfile = "workspace-core",
|
profile: McpToolProfile = "workspace-core",
|
||||||
|
project_path: str | Path | None = None,
|
||||||
|
repo_url: str | None = None,
|
||||||
|
repo_ref: str | None = None,
|
||||||
|
no_project_source: bool = False,
|
||||||
) -> FastMCP:
|
) -> FastMCP:
|
||||||
"""Create and return a configured MCP server instance.
|
"""Create and return a configured MCP server instance.
|
||||||
|
|
||||||
`workspace-core` is the default stable chat-host profile in 4.x. Use
|
`workspace-core` is the default stable chat-host profile in 4.x. Use
|
||||||
`profile="workspace-full"` only when the host truly needs the full
|
`profile="workspace-full"` only when the host truly needs the full
|
||||||
advanced workspace surface.
|
advanced workspace surface. By default, the server auto-detects the
|
||||||
|
nearest Git worktree root from its current working directory for
|
||||||
|
project-aware `workspace_create` calls.
|
||||||
"""
|
"""
|
||||||
return Pyro(manager=manager).create_server(profile=profile)
|
return Pyro(manager=manager).create_server(
|
||||||
|
profile=profile,
|
||||||
|
project_path=project_path,
|
||||||
|
repo_url=repo_url,
|
||||||
|
repo_ref=repo_ref,
|
||||||
|
no_project_source=no_project_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
||||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||||
|
|
||||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||||
DEFAULT_CATALOG_VERSION = "4.0.0"
|
DEFAULT_CATALOG_VERSION = "4.1.0"
|
||||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||||
(
|
(
|
||||||
"application/vnd.oci.image.index.v1+json",
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
|
@ -48,7 +48,7 @@ class VmEnvironment:
|
||||||
oci_repository: str | None = None
|
oci_repository: str | None = None
|
||||||
oci_reference: str | None = None
|
oci_reference: str | None = None
|
||||||
source_digest: str | None = None
|
source_digest: str | None = None
|
||||||
compatibility: str = ">=4.0.0,<5.0.0"
|
compatibility: str = ">=4.1.0,<5.0.0"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ 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}$")
|
WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||||
|
|
||||||
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
|
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
|
||||||
|
WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"]
|
||||||
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
|
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
|
||||||
WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"]
|
WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"]
|
||||||
WorkspaceSnapshotKind = Literal["baseline", "named"]
|
WorkspaceSnapshotKind = Literal["baseline", "named"]
|
||||||
|
|
@ -524,6 +525,8 @@ class PreparedWorkspaceSeed:
|
||||||
|
|
||||||
mode: WorkspaceSeedMode
|
mode: WorkspaceSeedMode
|
||||||
source_path: str | None
|
source_path: str | None
|
||||||
|
origin_kind: WorkspaceSeedOriginKind = "empty"
|
||||||
|
origin_ref: str | None = None
|
||||||
archive_path: Path | None = None
|
archive_path: Path | None = None
|
||||||
entry_count: int = 0
|
entry_count: int = 0
|
||||||
bytes_written: int = 0
|
bytes_written: int = 0
|
||||||
|
|
@ -534,14 +537,19 @@ class PreparedWorkspaceSeed:
|
||||||
*,
|
*,
|
||||||
destination: str = WORKSPACE_GUEST_PATH,
|
destination: str = WORKSPACE_GUEST_PATH,
|
||||||
path_key: str = "seed_path",
|
path_key: str = "seed_path",
|
||||||
|
include_origin: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return {
|
payload = {
|
||||||
"mode": self.mode,
|
"mode": self.mode,
|
||||||
path_key: self.source_path,
|
path_key: self.source_path,
|
||||||
"destination": destination,
|
"destination": destination,
|
||||||
"entry_count": self.entry_count,
|
"entry_count": self.entry_count,
|
||||||
"bytes_written": self.bytes_written,
|
"bytes_written": self.bytes_written,
|
||||||
}
|
}
|
||||||
|
if include_origin:
|
||||||
|
payload["origin_kind"] = self.origin_kind
|
||||||
|
payload["origin_ref"] = self.origin_ref
|
||||||
|
return payload
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
if self.cleanup_dir is not None:
|
if self.cleanup_dir is not None:
|
||||||
|
|
@ -614,6 +622,8 @@ def _empty_workspace_seed_payload() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"mode": "empty",
|
"mode": "empty",
|
||||||
"seed_path": None,
|
"seed_path": None,
|
||||||
|
"origin_kind": "empty",
|
||||||
|
"origin_ref": None,
|
||||||
"destination": WORKSPACE_GUEST_PATH,
|
"destination": WORKSPACE_GUEST_PATH,
|
||||||
"entry_count": 0,
|
"entry_count": 0,
|
||||||
"bytes_written": 0,
|
"bytes_written": 0,
|
||||||
|
|
@ -628,6 +638,8 @@ def _workspace_seed_dict(value: object) -> dict[str, Any]:
|
||||||
{
|
{
|
||||||
"mode": str(value.get("mode", payload["mode"])),
|
"mode": str(value.get("mode", payload["mode"])),
|
||||||
"seed_path": _optional_str(value.get("seed_path")),
|
"seed_path": _optional_str(value.get("seed_path")),
|
||||||
|
"origin_kind": str(value.get("origin_kind", payload["origin_kind"])),
|
||||||
|
"origin_ref": _optional_str(value.get("origin_ref")),
|
||||||
"destination": str(value.get("destination", payload["destination"])),
|
"destination": str(value.get("destination", payload["destination"])),
|
||||||
"entry_count": int(value.get("entry_count", payload["entry_count"])),
|
"entry_count": int(value.get("entry_count", payload["entry_count"])),
|
||||||
"bytes_written": int(value.get("bytes_written", payload["bytes_written"])),
|
"bytes_written": int(value.get("bytes_written", payload["bytes_written"])),
|
||||||
|
|
@ -3747,13 +3759,16 @@ class VmManager:
|
||||||
secrets: list[dict[str, str]] | None = None,
|
secrets: list[dict[str, str]] | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
labels: dict[str, str] | None = None,
|
labels: dict[str, str] | None = None,
|
||||||
|
_prepared_seed: PreparedWorkspaceSeed | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds)
|
self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds)
|
||||||
get_environment(environment, runtime_paths=self._runtime_paths)
|
get_environment(environment, runtime_paths=self._runtime_paths)
|
||||||
normalized_network_policy = _normalize_workspace_network_policy(str(network_policy))
|
normalized_network_policy = _normalize_workspace_network_policy(str(network_policy))
|
||||||
normalized_name = None if name is None else _normalize_workspace_name(name)
|
normalized_name = None if name is None else _normalize_workspace_name(name)
|
||||||
normalized_labels = _normalize_workspace_labels(labels)
|
normalized_labels = _normalize_workspace_labels(labels)
|
||||||
prepared_seed = self._prepare_workspace_seed(seed_path)
|
if _prepared_seed is not None and seed_path is not None:
|
||||||
|
raise ValueError("_prepared_seed and seed_path are mutually exclusive")
|
||||||
|
prepared_seed = _prepared_seed or self._prepare_workspace_seed(seed_path)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
workspace_id = uuid.uuid4().hex[:12]
|
workspace_id = uuid.uuid4().hex[:12]
|
||||||
workspace_dir = self._workspace_dir(workspace_id)
|
workspace_dir = self._workspace_dir(workspace_id)
|
||||||
|
|
@ -3885,6 +3900,7 @@ class VmManager:
|
||||||
workspace_sync = prepared_seed.to_payload(
|
workspace_sync = prepared_seed.to_payload(
|
||||||
destination=normalized_destination,
|
destination=normalized_destination,
|
||||||
path_key="source_path",
|
path_key="source_path",
|
||||||
|
include_origin=False,
|
||||||
)
|
)
|
||||||
workspace_sync["entry_count"] = int(import_summary["entry_count"])
|
workspace_sync["entry_count"] = int(import_summary["entry_count"])
|
||||||
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
|
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
|
||||||
|
|
@ -5663,12 +5679,30 @@ class VmManager:
|
||||||
execution_mode = instance.metadata.get("execution_mode", "unknown")
|
execution_mode = instance.metadata.get("execution_mode", "unknown")
|
||||||
return exec_result, execution_mode
|
return exec_result, execution_mode
|
||||||
|
|
||||||
def _prepare_workspace_seed(self, seed_path: str | Path | None) -> PreparedWorkspaceSeed:
|
def _prepare_workspace_seed(
|
||||||
|
self,
|
||||||
|
seed_path: str | Path | None,
|
||||||
|
*,
|
||||||
|
origin_kind: WorkspaceSeedOriginKind | None = None,
|
||||||
|
origin_ref: str | None = None,
|
||||||
|
) -> PreparedWorkspaceSeed:
|
||||||
if seed_path is None:
|
if seed_path is None:
|
||||||
return PreparedWorkspaceSeed(mode="empty", source_path=None)
|
return PreparedWorkspaceSeed(
|
||||||
|
mode="empty",
|
||||||
|
source_path=None,
|
||||||
|
origin_kind="empty" if origin_kind is None else origin_kind,
|
||||||
|
origin_ref=origin_ref,
|
||||||
|
)
|
||||||
resolved_source_path = Path(seed_path).expanduser().resolve()
|
resolved_source_path = Path(seed_path).expanduser().resolve()
|
||||||
if not resolved_source_path.exists():
|
if not resolved_source_path.exists():
|
||||||
raise ValueError(f"seed_path {resolved_source_path} does not exist")
|
raise ValueError(f"seed_path {resolved_source_path} does not exist")
|
||||||
|
effective_origin_kind: WorkspaceSeedOriginKind = (
|
||||||
|
"manual_seed_path" if origin_kind is None else origin_kind
|
||||||
|
)
|
||||||
|
effective_origin_ref = str(resolved_source_path) if origin_ref is None else origin_ref
|
||||||
|
public_source_path = (
|
||||||
|
None if effective_origin_kind == "repo_url" else str(resolved_source_path)
|
||||||
|
)
|
||||||
if resolved_source_path.is_dir():
|
if resolved_source_path.is_dir():
|
||||||
cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-"))
|
cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-"))
|
||||||
archive_path = cleanup_dir / "workspace-seed.tar"
|
archive_path = cleanup_dir / "workspace-seed.tar"
|
||||||
|
|
@ -5680,7 +5714,9 @@ class VmManager:
|
||||||
raise
|
raise
|
||||||
return PreparedWorkspaceSeed(
|
return PreparedWorkspaceSeed(
|
||||||
mode="directory",
|
mode="directory",
|
||||||
source_path=str(resolved_source_path),
|
source_path=public_source_path,
|
||||||
|
origin_kind=effective_origin_kind,
|
||||||
|
origin_ref=effective_origin_ref,
|
||||||
archive_path=archive_path,
|
archive_path=archive_path,
|
||||||
entry_count=entry_count,
|
entry_count=entry_count,
|
||||||
bytes_written=bytes_written,
|
bytes_written=bytes_written,
|
||||||
|
|
@ -5696,7 +5732,9 @@ class VmManager:
|
||||||
entry_count, bytes_written = _inspect_seed_archive(resolved_source_path)
|
entry_count, bytes_written = _inspect_seed_archive(resolved_source_path)
|
||||||
return PreparedWorkspaceSeed(
|
return PreparedWorkspaceSeed(
|
||||||
mode="tar_archive",
|
mode="tar_archive",
|
||||||
source_path=str(resolved_source_path),
|
source_path=public_source_path,
|
||||||
|
origin_kind=effective_origin_kind,
|
||||||
|
origin_ref=effective_origin_ref,
|
||||||
archive_path=resolved_source_path,
|
archive_path=resolved_source_path,
|
||||||
entry_count=entry_count,
|
entry_count=entry_count,
|
||||||
bytes_written=bytes_written,
|
bytes_written=bytes_written,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -107,6 +108,15 @@ def _log(message: str) -> None:
|
||||||
print(f"[smoke] {message}", flush=True)
|
print(f"[smoke] {message}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_structured_tool_result(raw_result: object) -> dict[str, object]:
|
||||||
|
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||||
|
raise TypeError("unexpected MCP tool result shape")
|
||||||
|
_, structured = raw_result
|
||||||
|
if not isinstance(structured, dict):
|
||||||
|
raise TypeError("expected structured dictionary result")
|
||||||
|
return structured
|
||||||
|
|
||||||
|
|
||||||
def _create_workspace(
|
def _create_workspace(
|
||||||
pyro: Pyro,
|
pyro: Pyro,
|
||||||
*,
|
*,
|
||||||
|
|
@ -126,6 +136,30 @@ def _create_workspace(
|
||||||
return str(created["workspace_id"])
|
return str(created["workspace_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _create_project_aware_workspace(
|
||||||
|
pyro: Pyro,
|
||||||
|
*,
|
||||||
|
environment: str,
|
||||||
|
project_path: Path,
|
||||||
|
name: str,
|
||||||
|
labels: dict[str, str],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
async def _run() -> dict[str, object]:
|
||||||
|
server = pyro.create_server(profile="workspace-core", project_path=project_path)
|
||||||
|
return _extract_structured_tool_result(
|
||||||
|
await server.call_tool(
|
||||||
|
"workspace_create",
|
||||||
|
{
|
||||||
|
"environment": environment,
|
||||||
|
"name": name,
|
||||||
|
"labels": labels,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None:
|
def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None:
|
||||||
if workspace_id is None:
|
if workspace_id is None:
|
||||||
return
|
return
|
||||||
|
|
@ -221,14 +255,19 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
|
||||||
)
|
)
|
||||||
workspace_id: str | None = None
|
workspace_id: str | None = None
|
||||||
try:
|
try:
|
||||||
workspace_id = _create_workspace(
|
created = _create_project_aware_workspace(
|
||||||
pyro,
|
pyro,
|
||||||
environment=environment,
|
environment=environment,
|
||||||
seed_path=seed_dir,
|
project_path=seed_dir,
|
||||||
name="repro-fix-loop",
|
name="repro-fix-loop",
|
||||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
|
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
|
||||||
)
|
)
|
||||||
|
workspace_id = str(created["workspace_id"])
|
||||||
_log(f"repro-fix-loop workspace_id={workspace_id}")
|
_log(f"repro-fix-loop workspace_id={workspace_id}")
|
||||||
|
workspace_seed = created["workspace_seed"]
|
||||||
|
assert isinstance(workspace_seed, dict), created
|
||||||
|
assert workspace_seed["origin_kind"] == "project_path", created
|
||||||
|
assert workspace_seed["origin_ref"] == str(seed_dir.resolve()), created
|
||||||
initial_read = pyro.read_workspace_file(workspace_id, "message.txt")
|
initial_read = pyro.read_workspace_file(workspace_id, "message.txt")
|
||||||
assert str(initial_read["content"]) == "broken\n", initial_read
|
assert str(initial_read["content"]) == "broken\n", initial_read
|
||||||
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
@ -15,6 +16,28 @@ from pyro_mcp.vm_manager import VmManager
|
||||||
from pyro_mcp.vm_network import TapNetworkManager
|
from pyro_mcp.vm_network import TapNetworkManager
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run( # noqa: S603
|
||||||
|
["git", *args],
|
||||||
|
cwd=repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
|
||||||
|
root.mkdir()
|
||||||
|
_git(root, "init")
|
||||||
|
_git(root, "config", "user.name", "Pyro Tests")
|
||||||
|
_git(root, "config", "user.email", "pyro-tests@example.com")
|
||||||
|
(root / "note.txt").write_text(content, encoding="utf-8")
|
||||||
|
_git(root, "add", "note.txt")
|
||||||
|
_git(root, "commit", "-m", "init")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
|
def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
|
||||||
pyro = Pyro(
|
pyro = Pyro(
|
||||||
manager=VmManager(
|
manager=VmManager(
|
||||||
|
|
@ -134,6 +157,58 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_
|
||||||
assert "workspace_disk_export" not in tool_map
|
assert "workspace_disk_export" not in tool_map
|
||||||
|
|
||||||
|
|
||||||
|
def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
|
||||||
|
pyro = Pyro(
|
||||||
|
manager=VmManager(
|
||||||
|
backend_name="mock",
|
||||||
|
base_dir=tmp_path / "vms",
|
||||||
|
network_manager=TapNetworkManager(enabled=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||||
|
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||||
|
raise TypeError("unexpected call_tool result shape")
|
||||||
|
_, structured = raw_result
|
||||||
|
if not isinstance(structured, dict):
|
||||||
|
raise TypeError("expected structured dictionary result")
|
||||||
|
return cast(dict[str, Any], structured)
|
||||||
|
|
||||||
|
async def _run() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||||
|
server = pyro.create_server(project_path=repo)
|
||||||
|
tools = await server.list_tools()
|
||||||
|
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||||
|
created = _extract_structured(
|
||||||
|
await server.call_tool(
|
||||||
|
"workspace_create",
|
||||||
|
{
|
||||||
|
"environment": "debian:12-base",
|
||||||
|
"allow_host_compat": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
executed = _extract_structured(
|
||||||
|
await server.call_tool(
|
||||||
|
"workspace_exec",
|
||||||
|
{
|
||||||
|
"workspace_id": created["workspace_id"],
|
||||||
|
"command": "cat note.txt",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tool_map["workspace_create"], created, executed
|
||||||
|
|
||||||
|
workspace_create_tool, created, executed = asyncio.run(_run())
|
||||||
|
assert "If `seed_path` is omitted" in str(workspace_create_tool["description"])
|
||||||
|
assert str(repo.resolve()) in str(workspace_create_tool["description"])
|
||||||
|
assert created["workspace_seed"]["origin_kind"] == "project_path"
|
||||||
|
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
|
||||||
|
assert executed["stdout"] == "project-aware\n"
|
||||||
|
|
||||||
|
|
||||||
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
|
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
|
||||||
pyro = Pyro(
|
pyro = Pyro(
|
||||||
manager=VmManager(
|
manager=VmManager(
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
assert "recommended first profile for most chat hosts" in mcp_help
|
assert "recommended first profile for most chat hosts" in mcp_help
|
||||||
assert "workspace-core: default for normal persistent chat editing" in mcp_help
|
assert "workspace-core: default for normal persistent chat editing" in mcp_help
|
||||||
assert "workspace-full: larger opt-in surface" in mcp_help
|
assert "workspace-full: larger opt-in surface" in mcp_help
|
||||||
|
assert "--project-path" in mcp_help
|
||||||
|
assert "--repo-url" in mcp_help
|
||||||
|
assert "--repo-ref" in mcp_help
|
||||||
|
assert "--no-project-source" in mcp_help
|
||||||
|
assert "pyro mcp serve --project-path ." in mcp_help
|
||||||
|
assert "pyro mcp serve --repo-url https://github.com/example/project.git" in mcp_help
|
||||||
|
|
||||||
workspace_help = _subparser_choice(parser, "workspace").format_help()
|
workspace_help = _subparser_choice(parser, "workspace").format_help()
|
||||||
assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help
|
assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help
|
||||||
|
|
@ -2825,25 +2831,30 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
||||||
assert claude_cmd in readme
|
assert claude_cmd in readme
|
||||||
assert codex_cmd in readme
|
assert codex_cmd in readme
|
||||||
assert "examples/opencode_mcp_config.json" in readme
|
assert "examples/opencode_mcp_config.json" in readme
|
||||||
assert "Bare `pyro mcp serve` starts `workspace-core`" in readme
|
assert "bare `pyro mcp serve` starts `workspace-core`" in readme
|
||||||
|
assert "auto-detects\nthe current Git checkout" in readme
|
||||||
|
assert "--project-path /abs/path/to/repo" in readme
|
||||||
|
assert "--repo-url https://github.com/example/project.git" in readme
|
||||||
|
|
||||||
assert "## 5. Connect a chat host" in install
|
assert "## 5. Connect a chat host" in install
|
||||||
assert "uvx --from pyro-mcp pyro mcp serve" in install
|
assert "uvx --from pyro-mcp pyro mcp serve" in install
|
||||||
assert claude_cmd in install
|
assert claude_cmd in install
|
||||||
assert codex_cmd in install
|
assert codex_cmd in install
|
||||||
assert "workspace-full" in install
|
assert "workspace-full" in install
|
||||||
|
assert "--project-path /abs/path/to/repo" in install
|
||||||
|
|
||||||
assert claude_cmd in first_run
|
assert claude_cmd in first_run
|
||||||
assert codex_cmd in first_run
|
assert codex_cmd in first_run
|
||||||
|
assert "--project-path /abs/path/to/repo" in first_run
|
||||||
|
|
||||||
assert (
|
assert "Bare `pyro mcp serve` starts `workspace-core`." in integrations
|
||||||
"Bare `pyro mcp serve` starts `workspace-core`. That is the product path."
|
assert "auto-detects the current Git checkout" in integrations
|
||||||
in integrations
|
|
||||||
)
|
|
||||||
assert "examples/claude_code_mcp.md" in integrations
|
assert "examples/claude_code_mcp.md" in integrations
|
||||||
assert "examples/codex_mcp.md" in integrations
|
assert "examples/codex_mcp.md" in integrations
|
||||||
assert "examples/opencode_mcp_config.json" in integrations
|
assert "examples/opencode_mcp_config.json" in integrations
|
||||||
assert "That is the product path." in integrations
|
assert "That is the product path." in integrations
|
||||||
|
assert "--project-path /abs/path/to/repo" in integrations
|
||||||
|
assert "--repo-url https://github.com/example/project.git" in integrations
|
||||||
|
|
||||||
assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config
|
assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config
|
||||||
assert "Use the host-specific examples first when they apply:" in mcp_config
|
assert "Use the host-specific examples first when they apply:" in mcp_config
|
||||||
|
|
@ -2854,10 +2865,12 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
||||||
assert claude_cmd in claude_code
|
assert claude_cmd in claude_code
|
||||||
assert "claude mcp list" in claude_code
|
assert "claude mcp list" in claude_code
|
||||||
assert "workspace-full" in claude_code
|
assert "workspace-full" in claude_code
|
||||||
|
assert "--project-path /abs/path/to/repo" in claude_code
|
||||||
|
|
||||||
assert codex_cmd in codex
|
assert codex_cmd in codex
|
||||||
assert "codex mcp list" in codex
|
assert "codex mcp list" in codex
|
||||||
assert "workspace-full" in codex
|
assert "workspace-full" in codex
|
||||||
|
assert "--project-path /abs/path/to/repo" in codex
|
||||||
|
|
||||||
assert opencode == {
|
assert opencode == {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
|
|
@ -4020,11 +4033,23 @@ def test_cli_run_json_error_exits_nonzero(
|
||||||
|
|
||||||
|
|
||||||
def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
observed: dict[str, str] = {}
|
observed: dict[str, Any] = {}
|
||||||
|
|
||||||
class StubPyro:
|
class StubPyro:
|
||||||
def create_server(self, *, profile: str) -> Any:
|
def create_server(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
profile: str,
|
||||||
|
project_path: str | None,
|
||||||
|
repo_url: str | None,
|
||||||
|
repo_ref: str | None,
|
||||||
|
no_project_source: bool,
|
||||||
|
) -> Any:
|
||||||
observed["profile"] = profile
|
observed["profile"] = profile
|
||||||
|
observed["project_path"] = project_path
|
||||||
|
observed["repo_url"] = repo_url
|
||||||
|
observed["repo_ref"] = repo_ref
|
||||||
|
observed["no_project_source"] = no_project_source
|
||||||
return type(
|
return type(
|
||||||
"StubServer",
|
"StubServer",
|
||||||
(),
|
(),
|
||||||
|
|
@ -4033,12 +4058,27 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
||||||
class StubParser:
|
class StubParser:
|
||||||
def parse_args(self) -> argparse.Namespace:
|
def parse_args(self) -> argparse.Namespace:
|
||||||
return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core")
|
return argparse.Namespace(
|
||||||
|
command="mcp",
|
||||||
|
mcp_command="serve",
|
||||||
|
profile="workspace-core",
|
||||||
|
project_path="/repo",
|
||||||
|
repo_url=None,
|
||||||
|
repo_ref=None,
|
||||||
|
no_project_source=False,
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||||
cli.main()
|
cli.main()
|
||||||
assert observed == {"profile": "workspace-core", "transport": "stdio"}
|
assert observed == {
|
||||||
|
"profile": "workspace-core",
|
||||||
|
"project_path": "/repo",
|
||||||
|
"repo_url": None,
|
||||||
|
"repo_ref": None,
|
||||||
|
"no_project_source": False,
|
||||||
|
"transport": "stdio",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_cli_demo_default_prints_json(
|
def test_cli_demo_default_prints_json(
|
||||||
|
|
|
||||||
115
tests/test_project_startup.py
Normal file
115
tests/test_project_startup.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyro_mcp.project_startup import (
|
||||||
|
ProjectStartupSource,
|
||||||
|
describe_project_startup_source,
|
||||||
|
materialize_project_startup_source,
|
||||||
|
resolve_project_startup_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run( # noqa: S603
|
||||||
|
["git", *args],
|
||||||
|
cwd=repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path:
|
||||||
|
root.mkdir()
|
||||||
|
_git(root, "init")
|
||||||
|
_git(root, "config", "user.name", "Pyro Tests")
|
||||||
|
_git(root, "config", "user.email", "pyro-tests@example.com")
|
||||||
|
(root / filename).write_text(content, encoding="utf-8")
|
||||||
|
_git(root, "add", filename)
|
||||||
|
_git(root, "commit", "-m", "init")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None:
|
||||||
|
repo = _make_repo(tmp_path / "repo")
|
||||||
|
nested = repo / "src" / "pkg"
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
|
||||||
|
resolved = resolve_project_startup_source(cwd=nested)
|
||||||
|
|
||||||
|
assert resolved == ProjectStartupSource(
|
||||||
|
kind="project_path",
|
||||||
|
origin_ref=str(repo.resolve()),
|
||||||
|
resolved_path=repo.resolve(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None:
|
||||||
|
repo = _make_repo(tmp_path / "repo")
|
||||||
|
nested = repo / "nested"
|
||||||
|
nested.mkdir()
|
||||||
|
|
||||||
|
resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path)
|
||||||
|
|
||||||
|
assert resolved == ProjectStartupSource(
|
||||||
|
kind="project_path",
|
||||||
|
origin_ref=str(repo.resolve()),
|
||||||
|
resolved_path=repo.resolve(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None:
|
||||||
|
repo = _make_repo(tmp_path / "repo")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||||
|
resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="requires --repo-url"):
|
||||||
|
resolve_project_startup_source(repo_ref="main")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="cannot be combined"):
|
||||||
|
resolve_project_startup_source(project_path=repo, no_project_source=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None:
|
||||||
|
repo = _make_repo(tmp_path / "repo", content="one\n")
|
||||||
|
first_commit = _git(repo, "rev-parse", "HEAD")
|
||||||
|
(repo / "note.txt").write_text("two\n", encoding="utf-8")
|
||||||
|
_git(repo, "add", "note.txt")
|
||||||
|
_git(repo, "commit", "-m", "update")
|
||||||
|
|
||||||
|
source = ProjectStartupSource(
|
||||||
|
kind="repo_url",
|
||||||
|
origin_ref=str(repo.resolve()),
|
||||||
|
repo_ref=first_commit,
|
||||||
|
)
|
||||||
|
|
||||||
|
with materialize_project_startup_source(source) as clone_dir:
|
||||||
|
assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None:
|
||||||
|
repo = _make_repo(tmp_path / "repo")
|
||||||
|
|
||||||
|
project_description = describe_project_startup_source(
|
||||||
|
ProjectStartupSource(
|
||||||
|
kind="project_path",
|
||||||
|
origin_ref=str(repo.resolve()),
|
||||||
|
resolved_path=repo.resolve(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
repo_description = describe_project_startup_source(
|
||||||
|
ProjectStartupSource(
|
||||||
|
kind="repo_url",
|
||||||
|
origin_ref="https://example.com/repo.git",
|
||||||
|
repo_ref="main",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert project_description == f"the current project at {repo.resolve()}"
|
||||||
|
assert repo_description == "the clean clone source https://example.com/repo.git at ref main"
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
|
|
@ -16,6 +17,28 @@ from pyro_mcp.vm_manager import VmManager
|
||||||
from pyro_mcp.vm_network import TapNetworkManager
|
from pyro_mcp.vm_network import TapNetworkManager
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run( # noqa: S603
|
||||||
|
["git", *args],
|
||||||
|
cwd=repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
|
||||||
|
root.mkdir()
|
||||||
|
_git(root, "init")
|
||||||
|
_git(root, "config", "user.name", "Pyro Tests")
|
||||||
|
_git(root, "config", "user.email", "pyro-tests@example.com")
|
||||||
|
(root / "note.txt").write_text(content, encoding="utf-8")
|
||||||
|
_git(root, "add", "note.txt")
|
||||||
|
_git(root, "commit", "-m", "init")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
||||||
manager = VmManager(
|
manager = VmManager(
|
||||||
backend_name="mock",
|
backend_name="mock",
|
||||||
|
|
@ -62,6 +85,121 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path:
|
||||||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
|
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None:
|
||||||
|
manager = VmManager(
|
||||||
|
backend_name="mock",
|
||||||
|
base_dir=tmp_path / "vms",
|
||||||
|
network_manager=TapNetworkManager(enabled=False),
|
||||||
|
)
|
||||||
|
repo = _make_repo(tmp_path / "repo")
|
||||||
|
|
||||||
|
async def _run() -> dict[str, Any]:
|
||||||
|
server = create_server(manager=manager, project_path=repo)
|
||||||
|
tools = await server.list_tools()
|
||||||
|
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||||
|
return tool_map["workspace_create"]
|
||||||
|
|
||||||
|
workspace_create = asyncio.run(_run())
|
||||||
|
description = cast(str, workspace_create["description"])
|
||||||
|
assert "If `seed_path` is omitted" in description
|
||||||
|
assert str(repo.resolve()) in description
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_server_project_path_seeds_workspace_when_seed_path_is_omitted(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
manager = VmManager(
|
||||||
|
backend_name="mock",
|
||||||
|
base_dir=tmp_path / "vms",
|
||||||
|
network_manager=TapNetworkManager(enabled=False),
|
||||||
|
)
|
||||||
|
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
|
||||||
|
|
||||||
|
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||||
|
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||||
|
raise TypeError("unexpected call_tool result shape")
|
||||||
|
_, structured = raw_result
|
||||||
|
if not isinstance(structured, dict):
|
||||||
|
raise TypeError("expected structured dictionary result")
|
||||||
|
return cast(dict[str, Any], structured)
|
||||||
|
|
||||||
|
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
server = create_server(manager=manager, project_path=repo)
|
||||||
|
created = _extract_structured(
|
||||||
|
await server.call_tool(
|
||||||
|
"workspace_create",
|
||||||
|
{
|
||||||
|
"environment": "debian:12-base",
|
||||||
|
"allow_host_compat": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
executed = _extract_structured(
|
||||||
|
await server.call_tool(
|
||||||
|
"workspace_exec",
|
||||||
|
{
|
||||||
|
"workspace_id": created["workspace_id"],
|
||||||
|
"command": "cat note.txt",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return created, executed
|
||||||
|
|
||||||
|
created, executed = asyncio.run(_run())
|
||||||
|
assert created["workspace_seed"]["mode"] == "directory"
|
||||||
|
assert created["workspace_seed"]["seed_path"] == str(repo.resolve())
|
||||||
|
assert created["workspace_seed"]["origin_kind"] == "project_path"
|
||||||
|
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
|
||||||
|
assert executed["stdout"] == "project-aware\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_server_repo_url_seeds_workspace_when_seed_path_is_omitted(tmp_path: Path) -> None:
|
||||||
|
manager = VmManager(
|
||||||
|
backend_name="mock",
|
||||||
|
base_dir=tmp_path / "vms",
|
||||||
|
network_manager=TapNetworkManager(enabled=False),
|
||||||
|
)
|
||||||
|
repo = _make_repo(tmp_path / "repo", content="committed\n")
|
||||||
|
(repo / "note.txt").write_text("dirty\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||||
|
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||||
|
raise TypeError("unexpected call_tool result shape")
|
||||||
|
_, structured = raw_result
|
||||||
|
if not isinstance(structured, dict):
|
||||||
|
raise TypeError("expected structured dictionary result")
|
||||||
|
return cast(dict[str, Any], structured)
|
||||||
|
|
||||||
|
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
server = create_server(manager=manager, repo_url=str(repo.resolve()))
|
||||||
|
created = _extract_structured(
|
||||||
|
await server.call_tool(
|
||||||
|
"workspace_create",
|
||||||
|
{
|
||||||
|
"environment": "debian:12-base",
|
||||||
|
"allow_host_compat": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
executed = _extract_structured(
|
||||||
|
await server.call_tool(
|
||||||
|
"workspace_exec",
|
||||||
|
{
|
||||||
|
"workspace_id": created["workspace_id"],
|
||||||
|
"command": "cat note.txt",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return created, executed
|
||||||
|
|
||||||
|
created, executed = asyncio.run(_run())
|
||||||
|
assert created["workspace_seed"]["mode"] == "directory"
|
||||||
|
assert created["workspace_seed"]["seed_path"] is None
|
||||||
|
assert created["workspace_seed"]["origin_kind"] == "repo_url"
|
||||||
|
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
|
||||||
|
assert executed["stdout"] == "committed\n"
|
||||||
|
|
||||||
|
|
||||||
def test_vm_run_round_trip(tmp_path: Path) -> None:
|
def test_vm_run_round_trip(tmp_path: Path) -> None:
|
||||||
manager = VmManager(
|
manager = VmManager(
|
||||||
backend_name="mock",
|
backend_name="mock",
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,36 @@ class _FakePyro:
|
||||||
workspace.shells.pop(shell_id, None)
|
workspace.shells.pop(shell_id, None)
|
||||||
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
|
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
|
||||||
|
|
||||||
|
def create_server(self, *, profile: str, project_path: Path) -> Any:
|
||||||
|
assert profile == "workspace-core"
|
||||||
|
seed_path = Path(project_path)
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class _FakeServer:
|
||||||
|
async def call_tool(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
) -> tuple[None, dict[str, Any]]:
|
||||||
|
if tool_name != "workspace_create":
|
||||||
|
raise AssertionError(f"unexpected tool call: {tool_name}")
|
||||||
|
result = outer.create_workspace(
|
||||||
|
environment=cast(str, arguments["environment"]),
|
||||||
|
seed_path=seed_path,
|
||||||
|
name=cast(str | None, arguments.get("name")),
|
||||||
|
labels=cast(dict[str, str] | None, arguments.get("labels")),
|
||||||
|
)
|
||||||
|
created = outer.status_workspace(cast(str, result["workspace_id"]))
|
||||||
|
created["workspace_seed"] = {
|
||||||
|
"mode": "directory",
|
||||||
|
"seed_path": str(seed_path.resolve()),
|
||||||
|
"origin_kind": "project_path",
|
||||||
|
"origin_ref": str(seed_path.resolve()),
|
||||||
|
}
|
||||||
|
return None, created
|
||||||
|
|
||||||
|
return _FakeServer()
|
||||||
|
|
||||||
|
|
||||||
def test_use_case_registry_has_expected_scenarios() -> None:
|
def test_use_case_registry_has_expected_scenarios() -> None:
|
||||||
expected = (
|
expected = (
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -715,7 +715,7 @@ crypto = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyro-mcp"
|
name = "pyro-mcp"
|
||||||
version = "4.0.0"
|
version = "4.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue