Add task sync push milestone
Tasks could start from host content in 2.2.0, but there was still no post-create path to update a live workspace from the host. This change adds the next host-to-task step so repeated fix or review loops do not require recreating the task for every local change. Add task sync push across the CLI, Python SDK, and MCP server, reusing the existing safe archive import path from seeded task creation instead of introducing a second transfer stack. The implementation keeps sync separate from workspace_seed metadata, validates destinations under /workspace, and documents the current non-atomic recovery path as delete-and-recreate. Validation: - uv lock - UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_cli.py tests/test_vm_manager.py tests/test_api.py tests/test_server.py tests/test_public_contract.py - UV_CACHE_DIR=.uv-cache make check - UV_CACHE_DIR=.uv-cache make dist-check - real guest-backed smoke: task create --source-path, task sync push, task exec to verify both files, task delete
This commit is contained in:
parent
aa886b346e
commit
9e11dcf9ab
19 changed files with 461 additions and 41 deletions
|
|
@ -2,6 +2,15 @@
|
||||||
|
|
||||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
All notable user-visible changes to `pyro-mcp` are documented here.
|
||||||
|
|
||||||
|
## 2.3.0
|
||||||
|
|
||||||
|
- Added `task sync push` across the CLI, Python SDK, and MCP server so started task workspaces can
|
||||||
|
import later host-side directory or archive content without being recreated.
|
||||||
|
- Reused the existing safe archive import path with an explicit destination under `/workspace`,
|
||||||
|
including host-side and guest-backed task support.
|
||||||
|
- Documented sync as a non-atomic update path in `2.3.0`, with delete-and-recreate as the recovery
|
||||||
|
path if a sync fails partway through.
|
||||||
|
|
||||||
## 2.2.0
|
## 2.2.0
|
||||||
|
|
||||||
- Added seeded task creation across the CLI, Python SDK, and MCP server with an optional
|
- Added seeded task creation across the CLI, Python SDK, and MCP server with an optional
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -18,7 +18,7 @@ It exposes the same runtime in three public forms:
|
||||||
- First run transcript: [docs/first-run.md](docs/first-run.md)
|
- First run transcript: [docs/first-run.md](docs/first-run.md)
|
||||||
- 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)
|
||||||
- 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/)
|
||||||
- What's new in 2.2.0: [CHANGELOG.md#220](CHANGELOG.md#220)
|
- What's new in 2.3.0: [CHANGELOG.md#230](CHANGELOG.md#230)
|
||||||
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
||||||
- Integration targets: [docs/integrations.md](docs/integrations.md)
|
- Integration targets: [docs/integrations.md](docs/integrations.md)
|
||||||
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
||||||
|
|
@ -55,7 +55,7 @@ What success looks like:
|
||||||
```bash
|
```bash
|
||||||
Platform: linux-x86_64
|
Platform: linux-x86_64
|
||||||
Runtime: PASS
|
Runtime: PASS
|
||||||
Catalog version: 2.2.0
|
Catalog version: 2.3.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
|
||||||
|
|
@ -75,6 +75,7 @@ After the quickstart works:
|
||||||
|
|
||||||
- prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo`
|
- prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo`
|
||||||
- create a persistent workspace with `uvx --from pyro-mcp pyro task create debian:12 --source-path ./repo`
|
- create a persistent workspace with `uvx --from pyro-mcp pyro task create debian:12 --source-path ./repo`
|
||||||
|
- update a live task from the host with `uvx --from pyro-mcp pyro task sync push TASK_ID ./changes`
|
||||||
- move to Python or MCP via [docs/integrations.md](docs/integrations.md)
|
- move to Python or MCP via [docs/integrations.md](docs/integrations.md)
|
||||||
|
|
||||||
## Supported Hosts
|
## Supported Hosts
|
||||||
|
|
@ -199,8 +200,8 @@ workspace without recreating the sandbox every time.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pyro task create debian:12 --source-path ./repo
|
pyro task create debian:12 --source-path ./repo
|
||||||
pyro task exec TASK_ID -- sh -lc 'printf "hello from task\n" > note.txt'
|
pyro task sync push TASK_ID ./changes --dest src
|
||||||
pyro task exec TASK_ID -- cat note.txt
|
pyro task exec TASK_ID -- cat src/note.txt
|
||||||
pyro task logs TASK_ID
|
pyro task logs TASK_ID
|
||||||
pyro task delete TASK_ID
|
pyro task delete TASK_ID
|
||||||
```
|
```
|
||||||
|
|
@ -208,7 +209,9 @@ pyro task delete TASK_ID
|
||||||
Task workspaces start in `/workspace` and keep command history until you delete them. For machine
|
Task workspaces start in `/workspace` and keep command history until you delete them. For machine
|
||||||
consumption, add `--json` and read the returned `task_id`. Use `--source-path` when you want the
|
consumption, add `--json` and read the returned `task_id`. Use `--source-path` when you want the
|
||||||
task to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an
|
task to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an
|
||||||
empty workspace.
|
empty workspace. Use `pyro task sync push` when you want to import later host-side changes into a
|
||||||
|
started task. Sync is non-atomic in `2.3.0`; if it fails partway through, delete and recreate the
|
||||||
|
task from its seed.
|
||||||
|
|
||||||
## Public Interfaces
|
## Public Interfaces
|
||||||
|
|
||||||
|
|
@ -353,8 +356,8 @@ pyro = Pyro()
|
||||||
task = pyro.create_task(environment="debian:12", source_path="./repo")
|
task = pyro.create_task(environment="debian:12", source_path="./repo")
|
||||||
task_id = task["task_id"]
|
task_id = task["task_id"]
|
||||||
try:
|
try:
|
||||||
pyro.exec_task(task_id, command="printf 'hello from task\\n' > note.txt")
|
pyro.push_task_sync(task_id, "./changes", dest="src")
|
||||||
result = pyro.exec_task(task_id, command="cat note.txt")
|
result = pyro.exec_task(task_id, command="cat src/note.txt")
|
||||||
print(result["stdout"], end="")
|
print(result["stdout"], end="")
|
||||||
finally:
|
finally:
|
||||||
pyro.delete_task(task_id)
|
pyro.delete_task(task_id)
|
||||||
|
|
@ -381,6 +384,7 @@ Advanced lifecycle tools:
|
||||||
Persistent workspace tools:
|
Persistent workspace tools:
|
||||||
|
|
||||||
- `task_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false, source_path=null)`
|
- `task_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false, source_path=null)`
|
||||||
|
- `task_sync_push(task_id, source_path, dest="/workspace")`
|
||||||
- `task_exec(task_id, command, timeout_seconds=30)`
|
- `task_exec(task_id, command, timeout_seconds=30)`
|
||||||
- `task_status(task_id)`
|
- `task_status(task_id)`
|
||||||
- `task_logs(task_id)`
|
- `task_logs(task_id)`
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,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: 2.2.0
|
Catalog version: 2.3.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,6 +71,7 @@ deterministic structured result.
|
||||||
```bash
|
```bash
|
||||||
$ uvx --from pyro-mcp pyro demo
|
$ uvx --from pyro-mcp pyro demo
|
||||||
$ uvx --from pyro-mcp pyro task create debian:12 --source-path ./repo
|
$ uvx --from pyro-mcp pyro task create debian:12 --source-path ./repo
|
||||||
|
$ uvx --from pyro-mcp pyro task sync push TASK_ID ./changes
|
||||||
$ uvx --from pyro-mcp pyro mcp serve
|
$ uvx --from pyro-mcp pyro mcp serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -89,16 +90,18 @@ Execution mode: guest_vsock
|
||||||
Resources: 1 vCPU / 1024 MiB
|
Resources: 1 vCPU / 1024 MiB
|
||||||
Command count: 0
|
Command count: 0
|
||||||
|
|
||||||
$ uvx --from pyro-mcp pyro task exec TASK_ID -- sh -lc 'printf "hello from task\n" > note.txt'
|
$ uvx --from pyro-mcp pyro task sync push TASK_ID ./changes --dest src
|
||||||
[task-exec] task_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=...
|
[task-sync] task_id=... mode=directory source=... destination=/workspace/src entry_count=... bytes_written=... execution_mode=guest_vsock
|
||||||
|
|
||||||
$ uvx --from pyro-mcp pyro task exec TASK_ID -- cat note.txt
|
$ uvx --from pyro-mcp pyro task exec TASK_ID -- cat src/note.txt
|
||||||
hello from task
|
hello from synced task
|
||||||
[task-exec] task_id=... sequence=2 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=...
|
[task-exec] task_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=...
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--source-path` when the task should start from a host directory or a local
|
Use `--source-path` when the task should start from a host directory or a local
|
||||||
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`.
|
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use
|
||||||
|
`pyro task sync push` when you need to import later host-side changes into a started task.
|
||||||
|
Sync is non-atomic in `2.3.0`; if it fails partway through, delete and recreate the task.
|
||||||
|
|
||||||
Example output:
|
Example output:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list
|
||||||
Expected output:
|
Expected output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
Catalog version: 2.2.0
|
Catalog version: 2.3.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.
|
||||||
|
|
@ -175,6 +175,7 @@ pyro run debian:12 -- git --version
|
||||||
After the CLI path works, you can move on to:
|
After the CLI path works, you can move on to:
|
||||||
|
|
||||||
- persistent workspaces: `pyro task create debian:12 --source-path ./repo`
|
- persistent workspaces: `pyro task create debian:12 --source-path ./repo`
|
||||||
|
- live task updates: `pyro task sync push TASK_ID ./changes`
|
||||||
- MCP: `pyro mcp serve`
|
- MCP: `pyro mcp serve`
|
||||||
- Python SDK: `from pyro_mcp import Pyro`
|
- Python SDK: `from pyro_mcp import Pyro`
|
||||||
- Demos: `pyro demo` or `pyro demo --network`
|
- Demos: `pyro demo` or `pyro demo --network`
|
||||||
|
|
@ -185,8 +186,8 @@ Use `pyro task ...` when you need repeated commands in one sandbox instead of on
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pyro task create debian:12 --source-path ./repo
|
pyro task create debian:12 --source-path ./repo
|
||||||
pyro task exec TASK_ID -- sh -lc 'printf "hello from task\n" > note.txt'
|
pyro task sync push TASK_ID ./changes --dest src
|
||||||
pyro task exec TASK_ID -- cat note.txt
|
pyro task exec TASK_ID -- cat src/note.txt
|
||||||
pyro task logs TASK_ID
|
pyro task logs TASK_ID
|
||||||
pyro task delete TASK_ID
|
pyro task delete TASK_ID
|
||||||
```
|
```
|
||||||
|
|
@ -194,6 +195,8 @@ pyro task delete TASK_ID
|
||||||
Task commands default to the persistent `/workspace` directory inside the guest. If you need the
|
Task commands default to the persistent `/workspace` directory inside the guest. If you need the
|
||||||
task identifier programmatically, use `--json` and read the `task_id` field. Use `--source-path`
|
task identifier programmatically, use `--json` and read the `task_id` field. Use `--source-path`
|
||||||
when the task should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive.
|
when the task should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive.
|
||||||
|
Use `pyro task sync push` for later host-side changes to a started task. Sync is non-atomic in
|
||||||
|
`2.3.0`; if it fails partway through, delete and recreate the task from its seed.
|
||||||
|
|
||||||
## Contributor Clone
|
## Contributor Clone
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ Best when:
|
||||||
Recommended surface:
|
Recommended surface:
|
||||||
|
|
||||||
- `vm_run`
|
- `vm_run`
|
||||||
- `task_create(source_path=...)` + `task_exec` when the agent needs persistent workspace state
|
- `task_create(source_path=...)` + `task_sync_push` + `task_exec` when the agent needs persistent workspace state
|
||||||
|
|
||||||
Canonical example:
|
Canonical example:
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ Best when:
|
||||||
Recommended default:
|
Recommended default:
|
||||||
|
|
||||||
- `Pyro.run_in_vm(...)`
|
- `Pyro.run_in_vm(...)`
|
||||||
- `Pyro.create_task(source_path=...)` + `Pyro.exec_task(...)` when repeated workspace commands are required
|
- `Pyro.create_task(source_path=...)` + `Pyro.push_task_sync(...)` + `Pyro.exec_task(...)` when repeated workspace commands are required
|
||||||
|
|
||||||
Lifecycle note:
|
Lifecycle note:
|
||||||
|
|
||||||
|
|
@ -74,6 +74,8 @@ Lifecycle note:
|
||||||
that final exec
|
that final exec
|
||||||
- use `create_task(source_path=...)` when the agent needs repeated commands in one persistent
|
- use `create_task(source_path=...)` when the agent needs repeated commands in one persistent
|
||||||
`/workspace` that starts from host content
|
`/workspace` that starts from host content
|
||||||
|
- use `push_task_sync(...)` when later host-side changes need to be imported into that running
|
||||||
|
workspace without recreating the task
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ Top-level commands:
|
||||||
- `pyro mcp serve`
|
- `pyro mcp serve`
|
||||||
- `pyro run`
|
- `pyro run`
|
||||||
- `pyro task create`
|
- `pyro task create`
|
||||||
|
- `pyro task sync push`
|
||||||
- `pyro task exec`
|
- `pyro task exec`
|
||||||
- `pyro task status`
|
- `pyro task status`
|
||||||
- `pyro task logs`
|
- `pyro task logs`
|
||||||
|
|
@ -48,6 +49,8 @@ Behavioral guarantees:
|
||||||
- `pyro task create` auto-starts a persistent workspace.
|
- `pyro task create` auto-starts a persistent workspace.
|
||||||
- `pyro task create --source-path PATH` seeds `/workspace` from a host directory or a local
|
- `pyro task create --source-path PATH` seeds `/workspace` from a host directory or a local
|
||||||
`.tar` / `.tar.gz` / `.tgz` archive before the task is returned.
|
`.tar` / `.tar.gz` / `.tgz` archive before the task is returned.
|
||||||
|
- `pyro task sync push TASK_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side
|
||||||
|
directory or archive content into a started task workspace.
|
||||||
- `pyro task exec` runs in the persistent `/workspace` for that task and does not auto-clean.
|
- `pyro task exec` runs in the persistent `/workspace` for that task and does not auto-clean.
|
||||||
- `pyro task logs` returns persisted command history for that task until `pyro task delete`.
|
- `pyro task logs` returns persisted command history for that task until `pyro task delete`.
|
||||||
- Task create/status results expose `workspace_seed` metadata describing how `/workspace` was
|
- Task create/status results expose `workspace_seed` metadata describing how `/workspace` was
|
||||||
|
|
@ -69,6 +72,7 @@ Supported public entrypoints:
|
||||||
- `Pyro.prune_environments()`
|
- `Pyro.prune_environments()`
|
||||||
- `Pyro.create_vm(...)`
|
- `Pyro.create_vm(...)`
|
||||||
- `Pyro.create_task(...)`
|
- `Pyro.create_task(...)`
|
||||||
|
- `Pyro.push_task_sync(task_id, source_path, *, dest="/workspace")`
|
||||||
- `Pyro.start_vm(vm_id)`
|
- `Pyro.start_vm(vm_id)`
|
||||||
- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)`
|
- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)`
|
||||||
- `Pyro.exec_task(task_id, *, command, timeout_seconds=30)`
|
- `Pyro.exec_task(task_id, *, command, timeout_seconds=30)`
|
||||||
|
|
@ -91,6 +95,7 @@ Stable public method names:
|
||||||
- `prune_environments()`
|
- `prune_environments()`
|
||||||
- `create_vm(...)`
|
- `create_vm(...)`
|
||||||
- `create_task(...)`
|
- `create_task(...)`
|
||||||
|
- `push_task_sync(task_id, source_path, *, dest="/workspace")`
|
||||||
- `start_vm(vm_id)`
|
- `start_vm(vm_id)`
|
||||||
- `exec_vm(vm_id, *, command, timeout_seconds=30)`
|
- `exec_vm(vm_id, *, command, timeout_seconds=30)`
|
||||||
- `exec_task(task_id, *, command, timeout_seconds=30)`
|
- `exec_task(task_id, *, command, timeout_seconds=30)`
|
||||||
|
|
@ -112,6 +117,8 @@ Behavioral defaults:
|
||||||
- `allow_host_compat` defaults to `False` on `create_task(...)`.
|
- `allow_host_compat` defaults to `False` on `create_task(...)`.
|
||||||
- `Pyro.create_task(..., source_path=...)` seeds `/workspace` from a host directory or a local
|
- `Pyro.create_task(..., source_path=...)` seeds `/workspace` from a host directory or a local
|
||||||
`.tar` / `.tar.gz` / `.tgz` archive before the task is returned.
|
`.tar` / `.tar.gz` / `.tgz` archive before the task is returned.
|
||||||
|
- `Pyro.push_task_sync(...)` imports later host-side directory or archive content into a started
|
||||||
|
task workspace.
|
||||||
- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes.
|
- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes.
|
||||||
- `Pyro.exec_task(...)` runs one command in the persistent task workspace and leaves the task alive.
|
- `Pyro.exec_task(...)` runs one command in the persistent task workspace and leaves the task alive.
|
||||||
|
|
||||||
|
|
@ -136,6 +143,7 @@ Advanced lifecycle tools:
|
||||||
Task workspace tools:
|
Task workspace tools:
|
||||||
|
|
||||||
- `task_create`
|
- `task_create`
|
||||||
|
- `task_sync_push`
|
||||||
- `task_exec`
|
- `task_exec`
|
||||||
- `task_status`
|
- `task_status`
|
||||||
- `task_logs`
|
- `task_logs`
|
||||||
|
|
@ -149,6 +157,8 @@ Behavioral defaults:
|
||||||
- `task_create` exposes `allow_host_compat`, which defaults to `false`.
|
- `task_create` exposes `allow_host_compat`, which defaults to `false`.
|
||||||
- `task_create` accepts optional `source_path` and seeds `/workspace` from a host directory or a
|
- `task_create` accepts optional `source_path` and seeds `/workspace` from a host directory or a
|
||||||
local `.tar` / `.tar.gz` / `.tgz` archive before the task is returned.
|
local `.tar` / `.tar.gz` / `.tgz` archive before the task is returned.
|
||||||
|
- `task_sync_push` imports later host-side directory or archive content into a started task
|
||||||
|
workspace, with an optional `dest` under `/workspace`.
|
||||||
- `vm_exec` runs one command and auto-cleans that VM after the exec completes.
|
- `vm_exec` runs one command and auto-cleans that VM after the exec completes.
|
||||||
- `task_exec` runs one command in a persistent `/workspace` and leaves the task alive.
|
- `task_exec` runs one command in a persistent `/workspace` and leaves the task alive.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,29 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pyro_mcp import Pyro
|
from pyro_mcp import Pyro
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
pyro = Pyro()
|
pyro = Pyro()
|
||||||
created = pyro.create_task(environment="debian:12")
|
with (
|
||||||
task_id = str(created["task_id"])
|
tempfile.TemporaryDirectory(prefix="pyro-task-seed-") as seed_dir,
|
||||||
try:
|
tempfile.TemporaryDirectory(prefix="pyro-task-sync-") as sync_dir,
|
||||||
pyro.exec_task(task_id, command="printf 'hello from task\\n' > note.txt")
|
):
|
||||||
result = pyro.exec_task(task_id, command="cat note.txt")
|
Path(seed_dir, "note.txt").write_text("hello from seed\n", encoding="utf-8")
|
||||||
print(result["stdout"], end="")
|
Path(sync_dir, "note.txt").write_text("hello from sync\n", encoding="utf-8")
|
||||||
logs = pyro.logs_task(task_id)
|
created = pyro.create_task(environment="debian:12", source_path=seed_dir)
|
||||||
print(f"task_id={task_id} command_count={logs['count']}")
|
task_id = str(created["task_id"])
|
||||||
finally:
|
try:
|
||||||
pyro.delete_task(task_id)
|
pyro.push_task_sync(task_id, sync_dir)
|
||||||
|
result = pyro.exec_task(task_id, command="cat note.txt")
|
||||||
|
print(result["stdout"], end="")
|
||||||
|
logs = pyro.logs_task(task_id)
|
||||||
|
print(f"task_id={task_id} command_count={logs['count']}")
|
||||||
|
finally:
|
||||||
|
pyro.delete_task(task_id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "pyro-mcp"
|
name = "pyro-mcp"
|
||||||
version = "2.2.0"
|
version = "2.3.0"
|
||||||
description = "Curated Linux environments for ephemeral Firecracker-backed VM execution."
|
description = "Curated Linux environments for ephemeral Firecracker-backed VM execution."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,15 @@ class Pyro:
|
||||||
def status_task(self, task_id: str) -> dict[str, Any]:
|
def status_task(self, task_id: str) -> dict[str, Any]:
|
||||||
return self._manager.status_task(task_id)
|
return self._manager.status_task(task_id)
|
||||||
|
|
||||||
|
def push_task_sync(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
source_path: str | Path,
|
||||||
|
*,
|
||||||
|
dest: str = "/workspace",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self._manager.push_task_sync(task_id, source_path=source_path, dest=dest)
|
||||||
|
|
||||||
def logs_task(self, task_id: str) -> dict[str, Any]:
|
def logs_task(self, task_id: str) -> dict[str, Any]:
|
||||||
return self._manager.logs_task(task_id)
|
return self._manager.logs_task(task_id)
|
||||||
|
|
||||||
|
|
@ -269,6 +278,15 @@ class Pyro:
|
||||||
"""Run one command inside an existing task workspace."""
|
"""Run one command inside an existing task workspace."""
|
||||||
return self.exec_task(task_id, command=command, timeout_seconds=timeout_seconds)
|
return self.exec_task(task_id, command=command, timeout_seconds=timeout_seconds)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
async def task_sync_push(
|
||||||
|
task_id: str,
|
||||||
|
source_path: str,
|
||||||
|
dest: str = "/workspace",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Push host content into the persistent workspace of a started task."""
|
||||||
|
return self.push_task_sync(task_id, source_path=source_path, dest=dest)
|
||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
async def task_status(task_id: str) -> dict[str, Any]:
|
async def task_status(task_id: str) -> dict[str, Any]:
|
||||||
"""Inspect task state and latest command metadata."""
|
"""Inspect task state and latest command metadata."""
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,23 @@ def _print_task_exec_human(payload: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_task_sync_human(payload: dict[str, Any]) -> None:
|
||||||
|
workspace_sync = payload.get("workspace_sync")
|
||||||
|
if not isinstance(workspace_sync, dict):
|
||||||
|
print(f"Synced task: {str(payload.get('task_id', 'unknown'))}")
|
||||||
|
return
|
||||||
|
print(
|
||||||
|
"[task-sync] "
|
||||||
|
f"task_id={str(payload.get('task_id', 'unknown'))} "
|
||||||
|
f"mode={str(workspace_sync.get('mode', 'unknown'))} "
|
||||||
|
f"source={str(workspace_sync.get('source_path', 'unknown'))} "
|
||||||
|
f"destination={str(workspace_sync.get('destination', TASK_WORKSPACE_GUEST_PATH))} "
|
||||||
|
f"entry_count={int(workspace_sync.get('entry_count', 0))} "
|
||||||
|
f"bytes_written={int(workspace_sync.get('bytes_written', 0))} "
|
||||||
|
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _print_task_logs_human(payload: dict[str, Any]) -> None:
|
def _print_task_logs_human(payload: dict[str, Any]) -> None:
|
||||||
entries = payload.get("entries")
|
entries = payload.get("entries")
|
||||||
if not isinstance(entries, list) or not entries:
|
if not isinstance(entries, list) or not entries:
|
||||||
|
|
@ -250,7 +267,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
pyro run debian:12 -- git --version
|
pyro run debian:12 -- git --version
|
||||||
|
|
||||||
Need repeated commands in one workspace after that?
|
Need repeated commands in one workspace after that?
|
||||||
pyro task create debian:12
|
pyro task create debian:12 --source-path ./repo
|
||||||
|
pyro task sync push TASK_ID ./changes
|
||||||
|
|
||||||
Use `pyro mcp serve` only after the CLI validation path works.
|
Use `pyro mcp serve` only after the CLI validation path works.
|
||||||
"""
|
"""
|
||||||
|
|
@ -456,6 +474,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"""
|
"""
|
||||||
Examples:
|
Examples:
|
||||||
pyro task create debian:12 --source-path ./repo
|
pyro task create debian:12 --source-path ./repo
|
||||||
|
pyro task sync push TASK_ID ./repo --dest src
|
||||||
pyro task exec TASK_ID -- sh -lc 'printf "hello\\n" > note.txt'
|
pyro task exec TASK_ID -- sh -lc 'printf "hello\\n" > note.txt'
|
||||||
pyro task logs TASK_ID
|
pyro task logs TASK_ID
|
||||||
"""
|
"""
|
||||||
|
|
@ -472,6 +491,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
Examples:
|
Examples:
|
||||||
pyro task create debian:12
|
pyro task create debian:12
|
||||||
pyro task create debian:12 --source-path ./repo
|
pyro task create debian:12 --source-path ./repo
|
||||||
|
pyro task sync push TASK_ID ./changes
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
formatter_class=_HelpFormatter,
|
formatter_class=_HelpFormatter,
|
||||||
|
|
@ -552,6 +572,56 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"for example `pyro task exec TASK_ID -- cat note.txt`."
|
"for example `pyro task exec TASK_ID -- cat note.txt`."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
task_sync_parser = task_subparsers.add_parser(
|
||||||
|
"sync",
|
||||||
|
help="Push host content into a started task workspace.",
|
||||||
|
description=(
|
||||||
|
"Push host directory or archive content into `/workspace` for an existing "
|
||||||
|
"started task."
|
||||||
|
),
|
||||||
|
epilog=dedent(
|
||||||
|
"""
|
||||||
|
Examples:
|
||||||
|
pyro task sync push TASK_ID ./repo
|
||||||
|
pyro task sync push TASK_ID ./patches --dest src
|
||||||
|
|
||||||
|
Sync is non-atomic. If a sync fails partway through, delete and recreate the task.
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
formatter_class=_HelpFormatter,
|
||||||
|
)
|
||||||
|
task_sync_subparsers = task_sync_parser.add_subparsers(
|
||||||
|
dest="task_sync_command",
|
||||||
|
required=True,
|
||||||
|
metavar="SYNC",
|
||||||
|
)
|
||||||
|
task_sync_push_parser = task_sync_subparsers.add_parser(
|
||||||
|
"push",
|
||||||
|
help="Push one host directory or archive into a started task.",
|
||||||
|
description="Import host content into `/workspace` or a subdirectory of it.",
|
||||||
|
epilog="Example:\n pyro task sync push TASK_ID ./repo --dest src",
|
||||||
|
formatter_class=_HelpFormatter,
|
||||||
|
)
|
||||||
|
task_sync_push_parser.add_argument(
|
||||||
|
"task_id",
|
||||||
|
metavar="TASK_ID",
|
||||||
|
help="Persistent task identifier.",
|
||||||
|
)
|
||||||
|
task_sync_push_parser.add_argument(
|
||||||
|
"source_path",
|
||||||
|
metavar="SOURCE_PATH",
|
||||||
|
help="Host directory or .tar/.tar.gz/.tgz archive to push into the task workspace.",
|
||||||
|
)
|
||||||
|
task_sync_push_parser.add_argument(
|
||||||
|
"--dest",
|
||||||
|
default=TASK_WORKSPACE_GUEST_PATH,
|
||||||
|
help="Workspace destination path. Relative values resolve inside `/workspace`.",
|
||||||
|
)
|
||||||
|
task_sync_push_parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Print structured JSON instead of human-readable output.",
|
||||||
|
)
|
||||||
task_status_parser = task_subparsers.add_parser(
|
task_status_parser = task_subparsers.add_parser(
|
||||||
"status",
|
"status",
|
||||||
help="Inspect one task workspace.",
|
help="Inspect one task workspace.",
|
||||||
|
|
@ -821,6 +891,30 @@ def main() -> None:
|
||||||
if exit_code != 0:
|
if exit_code != 0:
|
||||||
raise SystemExit(exit_code)
|
raise SystemExit(exit_code)
|
||||||
return
|
return
|
||||||
|
if args.task_command == "sync" and args.task_sync_command == "push":
|
||||||
|
if bool(args.json):
|
||||||
|
try:
|
||||||
|
payload = pyro.push_task_sync(
|
||||||
|
args.task_id,
|
||||||
|
args.source_path,
|
||||||
|
dest=args.dest,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_print_json({"ok": False, "error": str(exc)})
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
_print_json(payload)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
payload = pyro.push_task_sync(
|
||||||
|
args.task_id,
|
||||||
|
args.source_path,
|
||||||
|
dest=args.dest,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
_print_task_sync_human(payload)
|
||||||
|
return
|
||||||
if args.task_command == "status":
|
if args.task_command == "status":
|
||||||
payload = pyro.status_task(args.task_id)
|
payload = pyro.status_task(args.task_id)
|
||||||
if bool(args.json):
|
if bool(args.json):
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ from __future__ import annotations
|
||||||
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "task")
|
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "task")
|
||||||
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_TASK_SUBCOMMANDS = ("create", "delete", "exec", "logs", "status")
|
PUBLIC_CLI_TASK_SUBCOMMANDS = ("create", "delete", "exec", "logs", "status", "sync")
|
||||||
|
PUBLIC_CLI_TASK_SYNC_SUBCOMMANDS = ("push",)
|
||||||
PUBLIC_CLI_TASK_CREATE_FLAGS = (
|
PUBLIC_CLI_TASK_CREATE_FLAGS = (
|
||||||
"--vcpu-count",
|
"--vcpu-count",
|
||||||
"--mem-mib",
|
"--mem-mib",
|
||||||
|
|
@ -15,6 +16,7 @@ PUBLIC_CLI_TASK_CREATE_FLAGS = (
|
||||||
"--source-path",
|
"--source-path",
|
||||||
"--json",
|
"--json",
|
||||||
)
|
)
|
||||||
|
PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS = ("--dest", "--json")
|
||||||
PUBLIC_CLI_RUN_FLAGS = (
|
PUBLIC_CLI_RUN_FLAGS = (
|
||||||
"--vcpu-count",
|
"--vcpu-count",
|
||||||
"--mem-mib",
|
"--mem-mib",
|
||||||
|
|
@ -39,6 +41,7 @@ PUBLIC_SDK_METHODS = (
|
||||||
"network_info_vm",
|
"network_info_vm",
|
||||||
"prune_environments",
|
"prune_environments",
|
||||||
"pull_environment",
|
"pull_environment",
|
||||||
|
"push_task_sync",
|
||||||
"reap_expired",
|
"reap_expired",
|
||||||
"run_in_vm",
|
"run_in_vm",
|
||||||
"start_vm",
|
"start_vm",
|
||||||
|
|
@ -63,4 +66,5 @@ PUBLIC_MCP_TOOLS = (
|
||||||
"task_exec",
|
"task_exec",
|
||||||
"task_logs",
|
"task_logs",
|
||||||
"task_status",
|
"task_status",
|
||||||
|
"task_sync_push",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 = "2.2.0"
|
DEFAULT_CATALOG_VERSION = "2.3.0"
|
||||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||||
(
|
(
|
||||||
"application/vnd.oci.image.index.v1+json",
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
|
|
||||||
|
|
@ -194,11 +194,11 @@ class PreparedWorkspaceSeed:
|
||||||
bytes_written: int = 0
|
bytes_written: int = 0
|
||||||
cleanup_dir: Path | None = None
|
cleanup_dir: Path | None = None
|
||||||
|
|
||||||
def to_payload(self) -> dict[str, Any]:
|
def to_payload(self, *, destination: str = TASK_WORKSPACE_GUEST_PATH) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"mode": self.mode,
|
"mode": self.mode,
|
||||||
"source_path": self.source_path,
|
"source_path": self.source_path,
|
||||||
"destination": TASK_WORKSPACE_GUEST_PATH,
|
"destination": destination,
|
||||||
"entry_count": self.entry_count,
|
"entry_count": self.entry_count,
|
||||||
"bytes_written": self.bytes_written,
|
"bytes_written": self.bytes_written,
|
||||||
}
|
}
|
||||||
|
|
@ -372,6 +372,8 @@ def _normalize_workspace_destination(destination: str) -> tuple[str, PurePosixPa
|
||||||
if candidate == "":
|
if candidate == "":
|
||||||
raise ValueError("workspace destination must not be empty")
|
raise ValueError("workspace destination must not be empty")
|
||||||
destination_path = PurePosixPath(candidate)
|
destination_path = PurePosixPath(candidate)
|
||||||
|
if any(part == ".." for part in destination_path.parts):
|
||||||
|
raise ValueError("workspace destination must stay inside /workspace")
|
||||||
workspace_root = PurePosixPath(TASK_WORKSPACE_GUEST_PATH)
|
workspace_root = PurePosixPath(TASK_WORKSPACE_GUEST_PATH)
|
||||||
if not destination_path.is_absolute():
|
if not destination_path.is_absolute():
|
||||||
destination_path = workspace_root / destination_path
|
destination_path = workspace_root / destination_path
|
||||||
|
|
@ -1218,6 +1220,52 @@ class VmManager:
|
||||||
finally:
|
finally:
|
||||||
prepared_seed.cleanup()
|
prepared_seed.cleanup()
|
||||||
|
|
||||||
|
def push_task_sync(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
*,
|
||||||
|
source_path: str | Path,
|
||||||
|
dest: str = TASK_WORKSPACE_GUEST_PATH,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
prepared_seed = self._prepare_workspace_seed(source_path)
|
||||||
|
if prepared_seed.archive_path is None:
|
||||||
|
prepared_seed.cleanup()
|
||||||
|
raise ValueError("source_path is required")
|
||||||
|
normalized_destination, _ = _normalize_workspace_destination(dest)
|
||||||
|
with self._lock:
|
||||||
|
task = self._load_task_locked(task_id)
|
||||||
|
self._ensure_task_not_expired_locked(task, time.time())
|
||||||
|
self._refresh_task_liveness_locked(task)
|
||||||
|
if task.state != "started":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"task {task_id} must be in 'started' state before task_sync_push"
|
||||||
|
)
|
||||||
|
instance = task.to_instance(workdir=self._task_runtime_dir(task.task_id))
|
||||||
|
try:
|
||||||
|
import_summary = self._backend.import_archive(
|
||||||
|
instance,
|
||||||
|
archive_path=prepared_seed.archive_path,
|
||||||
|
destination=normalized_destination,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
prepared_seed.cleanup()
|
||||||
|
workspace_sync = prepared_seed.to_payload(destination=normalized_destination)
|
||||||
|
workspace_sync["entry_count"] = int(import_summary["entry_count"])
|
||||||
|
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
|
||||||
|
workspace_sync["destination"] = str(import_summary["destination"])
|
||||||
|
with self._lock:
|
||||||
|
task = self._load_task_locked(task_id)
|
||||||
|
task.state = instance.state
|
||||||
|
task.firecracker_pid = instance.firecracker_pid
|
||||||
|
task.last_error = instance.last_error
|
||||||
|
task.metadata = dict(instance.metadata)
|
||||||
|
self._save_task_locked(task)
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"execution_mode": instance.metadata.get("execution_mode", "pending"),
|
||||||
|
"workspace_sync": workspace_sync,
|
||||||
|
}
|
||||||
|
|
||||||
def exec_task(self, task_id: str, *, command: str, timeout_seconds: int = 30) -> dict[str, Any]:
|
def exec_task(self, task_id: str, *, command: str, timeout_seconds: int = 30) -> dict[str, Any]:
|
||||||
if timeout_seconds <= 0:
|
if timeout_seconds <= 0:
|
||||||
raise ValueError("timeout_seconds must be positive")
|
raise ValueError("timeout_seconds must be positive")
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
||||||
assert "vm_run" in tool_names
|
assert "vm_run" in tool_names
|
||||||
assert "vm_create" in tool_names
|
assert "vm_create" in tool_names
|
||||||
assert "task_create" in tool_names
|
assert "task_create" in tool_names
|
||||||
|
assert "task_sync_push" in tool_names
|
||||||
|
|
||||||
|
|
||||||
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
|
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
|
||||||
|
|
@ -124,6 +125,10 @@ def test_pyro_task_methods_delegate_to_manager(tmp_path: Path) -> None:
|
||||||
source_path=source_dir,
|
source_path=source_dir,
|
||||||
)
|
)
|
||||||
task_id = str(created["task_id"])
|
task_id = str(created["task_id"])
|
||||||
|
updated_dir = tmp_path / "updated"
|
||||||
|
updated_dir.mkdir()
|
||||||
|
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
||||||
|
synced = pyro.push_task_sync(task_id, updated_dir, dest="subdir")
|
||||||
executed = pyro.exec_task(task_id, command="cat note.txt")
|
executed = pyro.exec_task(task_id, command="cat note.txt")
|
||||||
status = pyro.status_task(task_id)
|
status = pyro.status_task(task_id)
|
||||||
logs = pyro.logs_task(task_id)
|
logs = pyro.logs_task(task_id)
|
||||||
|
|
@ -131,6 +136,7 @@ def test_pyro_task_methods_delegate_to_manager(tmp_path: Path) -> None:
|
||||||
|
|
||||||
assert executed["stdout"] == "ok\n"
|
assert executed["stdout"] == "ok\n"
|
||||||
assert created["workspace_seed"]["mode"] == "directory"
|
assert created["workspace_seed"]["mode"] == "directory"
|
||||||
|
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||||
assert status["command_count"] == 1
|
assert status["command_count"] == 1
|
||||||
assert logs["count"] == 1
|
assert logs["count"] == 1
|
||||||
assert deleted["deleted"] is True
|
assert deleted["deleted"] is True
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ def test_cli_help_guides_first_run() -> None:
|
||||||
assert "pyro env list" in help_text
|
assert "pyro env list" in help_text
|
||||||
assert "pyro env pull debian:12" in help_text
|
assert "pyro env pull debian:12" in help_text
|
||||||
assert "pyro run debian:12 -- git --version" in help_text
|
assert "pyro run debian:12 -- git --version" in help_text
|
||||||
|
assert "pyro task sync push TASK_ID ./changes" in help_text
|
||||||
assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text
|
assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -61,6 +62,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
|
|
||||||
task_help = _subparser_choice(parser, "task").format_help()
|
task_help = _subparser_choice(parser, "task").format_help()
|
||||||
assert "pyro task create debian:12 --source-path ./repo" in task_help
|
assert "pyro task create debian:12 --source-path ./repo" in task_help
|
||||||
|
assert "pyro task sync push TASK_ID ./repo --dest src" in task_help
|
||||||
assert "pyro task exec TASK_ID" in task_help
|
assert "pyro task exec TASK_ID" in task_help
|
||||||
|
|
||||||
task_create_help = _subparser_choice(_subparser_choice(parser, "task"), "create").format_help()
|
task_create_help = _subparser_choice(_subparser_choice(parser, "task"), "create").format_help()
|
||||||
|
|
@ -71,6 +73,16 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
assert "persistent `/workspace`" in task_exec_help
|
assert "persistent `/workspace`" in task_exec_help
|
||||||
assert "pyro task exec TASK_ID -- cat note.txt" in task_exec_help
|
assert "pyro task exec TASK_ID -- cat note.txt" in task_exec_help
|
||||||
|
|
||||||
|
task_sync_help = _subparser_choice(_subparser_choice(parser, "task"), "sync").format_help()
|
||||||
|
assert "Sync is non-atomic." in task_sync_help
|
||||||
|
assert "pyro task sync push TASK_ID ./repo" in task_sync_help
|
||||||
|
|
||||||
|
task_sync_push_help = _subparser_choice(
|
||||||
|
_subparser_choice(_subparser_choice(parser, "task"), "sync"), "push"
|
||||||
|
).format_help()
|
||||||
|
assert "--dest" in task_sync_push_help
|
||||||
|
assert "Import host content into `/workspace`" in task_sync_push_help
|
||||||
|
|
||||||
|
|
||||||
def test_cli_run_prints_json(
|
def test_cli_run_prints_json(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|
@ -456,6 +468,89 @@ def test_cli_task_exec_prints_human_output(
|
||||||
assert "[task-exec] task_id=task-123 sequence=2 cwd=/workspace" in captured.err
|
assert "[task-exec] task_id=task-123 sequence=2 cwd=/workspace" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_task_sync_push_prints_json(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
class StubPyro:
|
||||||
|
def push_task_sync(self, task_id: str, source_path: str, *, dest: str) -> dict[str, Any]:
|
||||||
|
assert task_id == "task-123"
|
||||||
|
assert source_path == "./repo"
|
||||||
|
assert dest == "src"
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"execution_mode": "guest_vsock",
|
||||||
|
"workspace_sync": {
|
||||||
|
"mode": "directory",
|
||||||
|
"source_path": "/tmp/repo",
|
||||||
|
"destination": "/workspace/src",
|
||||||
|
"entry_count": 2,
|
||||||
|
"bytes_written": 12,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
class StubParser:
|
||||||
|
def parse_args(self) -> argparse.Namespace:
|
||||||
|
return argparse.Namespace(
|
||||||
|
command="task",
|
||||||
|
task_command="sync",
|
||||||
|
task_sync_command="push",
|
||||||
|
task_id="task-123",
|
||||||
|
source_path="./repo",
|
||||||
|
dest="src",
|
||||||
|
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_sync"]["destination"] == "/workspace/src"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_task_sync_push_prints_human(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
class StubPyro:
|
||||||
|
def push_task_sync(self, task_id: str, source_path: str, *, dest: str) -> dict[str, Any]:
|
||||||
|
assert task_id == "task-123"
|
||||||
|
assert source_path == "./repo"
|
||||||
|
assert dest == "/workspace"
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"execution_mode": "guest_vsock",
|
||||||
|
"workspace_sync": {
|
||||||
|
"mode": "directory",
|
||||||
|
"source_path": "/tmp/repo",
|
||||||
|
"destination": "/workspace",
|
||||||
|
"entry_count": 2,
|
||||||
|
"bytes_written": 12,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
class StubParser:
|
||||||
|
def parse_args(self) -> argparse.Namespace:
|
||||||
|
return argparse.Namespace(
|
||||||
|
command="task",
|
||||||
|
task_command="sync",
|
||||||
|
task_sync_command="push",
|
||||||
|
task_id="task-123",
|
||||||
|
source_path="./repo",
|
||||||
|
dest="/workspace",
|
||||||
|
json=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||||
|
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||||
|
cli.main()
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "[task-sync] task_id=task-123 mode=directory source=/tmp/repo" in output
|
||||||
|
assert (
|
||||||
|
"destination=/workspace entry_count=2 bytes_written=12 "
|
||||||
|
"execution_mode=guest_vsock"
|
||||||
|
) in output
|
||||||
|
|
||||||
|
|
||||||
def test_cli_task_logs_and_delete_print_human(
|
def test_cli_task_logs_and_delete_print_human(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ from pyro_mcp.contract import (
|
||||||
PUBLIC_CLI_RUN_FLAGS,
|
PUBLIC_CLI_RUN_FLAGS,
|
||||||
PUBLIC_CLI_TASK_CREATE_FLAGS,
|
PUBLIC_CLI_TASK_CREATE_FLAGS,
|
||||||
PUBLIC_CLI_TASK_SUBCOMMANDS,
|
PUBLIC_CLI_TASK_SUBCOMMANDS,
|
||||||
|
PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS,
|
||||||
|
PUBLIC_CLI_TASK_SYNC_SUBCOMMANDS,
|
||||||
PUBLIC_MCP_TOOLS,
|
PUBLIC_MCP_TOOLS,
|
||||||
PUBLIC_SDK_METHODS,
|
PUBLIC_SDK_METHODS,
|
||||||
)
|
)
|
||||||
|
|
@ -73,6 +75,14 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
||||||
).format_help()
|
).format_help()
|
||||||
for flag in PUBLIC_CLI_TASK_CREATE_FLAGS:
|
for flag in PUBLIC_CLI_TASK_CREATE_FLAGS:
|
||||||
assert flag in task_create_help_text
|
assert flag in task_create_help_text
|
||||||
|
task_sync_help_text = _subparser_choice(_subparser_choice(parser, "task"), "sync").format_help()
|
||||||
|
for subcommand_name in PUBLIC_CLI_TASK_SYNC_SUBCOMMANDS:
|
||||||
|
assert subcommand_name in task_sync_help_text
|
||||||
|
task_sync_push_help_text = _subparser_choice(
|
||||||
|
_subparser_choice(_subparser_choice(parser, "task"), "sync"), "push"
|
||||||
|
).format_help()
|
||||||
|
for flag in PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS:
|
||||||
|
assert flag in task_sync_push_help_text
|
||||||
|
|
||||||
demo_help_text = _subparser_choice(parser, "demo").format_help()
|
demo_help_text = _subparser_choice(parser, "demo").format_help()
|
||||||
for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS:
|
for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS:
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
||||||
assert "vm_status" in tool_names
|
assert "vm_status" in tool_names
|
||||||
assert "task_create" in tool_names
|
assert "task_create" in tool_names
|
||||||
assert "task_logs" in tool_names
|
assert "task_logs" in tool_names
|
||||||
|
assert "task_sync_push" in tool_names
|
||||||
|
|
||||||
|
|
||||||
def test_vm_run_round_trip(tmp_path: Path) -> None:
|
def test_vm_run_round_trip(tmp_path: Path) -> None:
|
||||||
|
|
@ -183,7 +184,13 @@ def test_task_tools_round_trip(tmp_path: Path) -> None:
|
||||||
raise TypeError("expected structured dictionary result")
|
raise TypeError("expected structured dictionary result")
|
||||||
return cast(dict[str, Any], structured)
|
return cast(dict[str, Any], structured)
|
||||||
|
|
||||||
async def _run() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
|
async def _run() -> tuple[
|
||||||
|
dict[str, Any],
|
||||||
|
dict[str, Any],
|
||||||
|
dict[str, Any],
|
||||||
|
dict[str, Any],
|
||||||
|
dict[str, Any],
|
||||||
|
]:
|
||||||
server = create_server(manager=manager)
|
server = create_server(manager=manager)
|
||||||
created = _extract_structured(
|
created = _extract_structured(
|
||||||
await server.call_tool(
|
await server.call_tool(
|
||||||
|
|
@ -196,22 +203,36 @@ def test_task_tools_round_trip(tmp_path: Path) -> None:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
task_id = str(created["task_id"])
|
task_id = str(created["task_id"])
|
||||||
|
update_dir = tmp_path / "update"
|
||||||
|
update_dir.mkdir()
|
||||||
|
(update_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
||||||
|
synced = _extract_structured(
|
||||||
|
await server.call_tool(
|
||||||
|
"task_sync_push",
|
||||||
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"source_path": str(update_dir),
|
||||||
|
"dest": "subdir",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
executed = _extract_structured(
|
executed = _extract_structured(
|
||||||
await server.call_tool(
|
await server.call_tool(
|
||||||
"task_exec",
|
"task_exec",
|
||||||
{
|
{
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"command": "cat note.txt",
|
"command": "cat subdir/more.txt",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logs = _extract_structured(await server.call_tool("task_logs", {"task_id": task_id}))
|
logs = _extract_structured(await server.call_tool("task_logs", {"task_id": task_id}))
|
||||||
deleted = _extract_structured(await server.call_tool("task_delete", {"task_id": task_id}))
|
deleted = _extract_structured(await server.call_tool("task_delete", {"task_id": task_id}))
|
||||||
return created, executed, logs, deleted
|
return created, synced, executed, logs, deleted
|
||||||
|
|
||||||
created, executed, logs, deleted = asyncio.run(_run())
|
created, synced, executed, logs, deleted = asyncio.run(_run())
|
||||||
assert created["state"] == "started"
|
assert created["state"] == "started"
|
||||||
assert created["workspace_seed"]["mode"] == "directory"
|
assert created["workspace_seed"]["mode"] == "directory"
|
||||||
assert executed["stdout"] == "ok\n"
|
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||||
|
assert executed["stdout"] == "more\n"
|
||||||
assert logs["count"] == 1
|
assert logs["count"] == 1
|
||||||
assert deleted["deleted"] is True
|
assert deleted["deleted"] is True
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,90 @@ def test_task_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None:
|
||||||
assert executed["stdout"] == "archive\n"
|
assert executed["stdout"] == "archive\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_sync_push_updates_started_workspace(tmp_path: Path) -> None:
|
||||||
|
source_dir = tmp_path / "seed"
|
||||||
|
source_dir.mkdir()
|
||||||
|
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||||
|
update_dir = tmp_path / "update"
|
||||||
|
update_dir.mkdir()
|
||||||
|
(update_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
||||||
|
|
||||||
|
manager = VmManager(
|
||||||
|
backend_name="mock",
|
||||||
|
base_dir=tmp_path / "vms",
|
||||||
|
network_manager=TapNetworkManager(enabled=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
created = manager.create_task(
|
||||||
|
environment="debian:12-base",
|
||||||
|
allow_host_compat=True,
|
||||||
|
source_path=source_dir,
|
||||||
|
)
|
||||||
|
task_id = str(created["task_id"])
|
||||||
|
synced = manager.push_task_sync(task_id, source_path=update_dir, dest="subdir")
|
||||||
|
|
||||||
|
assert synced["workspace_sync"]["mode"] == "directory"
|
||||||
|
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||||
|
|
||||||
|
executed = manager.exec_task(task_id, command="cat subdir/more.txt", timeout_seconds=30)
|
||||||
|
assert executed["stdout"] == "more\n"
|
||||||
|
|
||||||
|
status = manager.status_task(task_id)
|
||||||
|
assert status["command_count"] == 1
|
||||||
|
assert status["workspace_seed"]["mode"] == "directory"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_sync_push_requires_started_task(tmp_path: Path) -> None:
|
||||||
|
source_dir = tmp_path / "seed"
|
||||||
|
source_dir.mkdir()
|
||||||
|
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||||
|
update_dir = tmp_path / "update"
|
||||||
|
update_dir.mkdir()
|
||||||
|
(update_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
||||||
|
|
||||||
|
manager = VmManager(
|
||||||
|
backend_name="mock",
|
||||||
|
base_dir=tmp_path / "vms",
|
||||||
|
network_manager=TapNetworkManager(enabled=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
created = manager.create_task(
|
||||||
|
environment="debian:12-base",
|
||||||
|
allow_host_compat=True,
|
||||||
|
source_path=source_dir,
|
||||||
|
)
|
||||||
|
task_id = str(created["task_id"])
|
||||||
|
task_path = tmp_path / "vms" / "tasks" / task_id / "task.json"
|
||||||
|
payload = json.loads(task_path.read_text(encoding="utf-8"))
|
||||||
|
payload["state"] = "stopped"
|
||||||
|
task_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="must be in 'started' state before task_sync_push"):
|
||||||
|
manager.push_task_sync(task_id, source_path=update_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_sync_push_rejects_destination_outside_workspace(tmp_path: Path) -> None:
|
||||||
|
source_dir = tmp_path / "seed"
|
||||||
|
source_dir.mkdir()
|
||||||
|
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||||
|
|
||||||
|
manager = VmManager(
|
||||||
|
backend_name="mock",
|
||||||
|
base_dir=tmp_path / "vms",
|
||||||
|
network_manager=TapNetworkManager(enabled=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id = str(
|
||||||
|
manager.create_task(
|
||||||
|
environment="debian:12-base",
|
||||||
|
allow_host_compat=True,
|
||||||
|
)["task_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="workspace destination must stay inside /workspace"):
|
||||||
|
manager.push_task_sync(task_id, source_path=source_dir, dest="../escape")
|
||||||
|
|
||||||
|
|
||||||
def test_task_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
|
def test_task_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
|
||||||
archive_path = tmp_path / "bad.tgz"
|
archive_path = tmp_path / "bad.tgz"
|
||||||
with tarfile.open(archive_path, "w:gz") as archive:
|
with tarfile.open(archive_path, "w:gz") as archive:
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -706,7 +706,7 @@ crypto = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyro-mcp"
|
name = "pyro-mcp"
|
||||||
version = "2.2.0"
|
version = "2.3.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue