diff --git a/CHANGELOG.md b/CHANGELOG.md index a20099d..d04ff5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 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 - Added seeded task creation across the CLI, Python SDK, and MCP server with an optional diff --git a/README.md b/README.md index a085622..2057f42 100644 --- a/README.md +++ b/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) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) -- What's new in 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) - Integration targets: [docs/integrations.md](docs/integrations.md) - Public contract: [docs/public-contract.md](docs/public-contract.md) @@ -55,7 +55,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 2.2.0 +Catalog version: 2.3.0 ... [pull] phase=install 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` - 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) ## Supported Hosts @@ -199,8 +200,8 @@ workspace without recreating the sandbox every time. ```bash pyro task create debian:12 --source-path ./repo -pyro task exec TASK_ID -- sh -lc 'printf "hello from task\n" > note.txt' -pyro task exec TASK_ID -- cat note.txt +pyro task sync push TASK_ID ./changes --dest src +pyro task exec TASK_ID -- cat src/note.txt pyro task logs 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 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 -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 @@ -353,8 +356,8 @@ pyro = Pyro() task = pyro.create_task(environment="debian:12", source_path="./repo") task_id = task["task_id"] try: - pyro.exec_task(task_id, command="printf 'hello from task\\n' > note.txt") - result = pyro.exec_task(task_id, command="cat note.txt") + pyro.push_task_sync(task_id, "./changes", dest="src") + result = pyro.exec_task(task_id, command="cat src/note.txt") print(result["stdout"], end="") finally: pyro.delete_task(task_id) @@ -381,6 +384,7 @@ Advanced lifecycle 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_sync_push(task_id, source_path, dest="/workspace")` - `task_exec(task_id, command, timeout_seconds=30)` - `task_status(task_id)` - `task_logs(task_id)` diff --git a/docs/first-run.md b/docs/first-run.md index 8a09b35..3a382b3 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 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-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. @@ -71,6 +71,7 @@ deterministic structured result. ```bash $ uvx --from pyro-mcp pyro demo $ 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 ``` @@ -89,16 +90,18 @@ Execution mode: guest_vsock Resources: 1 vCPU / 1024 MiB Command count: 0 -$ uvx --from pyro-mcp pyro task exec TASK_ID -- sh -lc 'printf "hello from task\n" > note.txt' -[task-exec] task_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... +$ uvx --from pyro-mcp pyro task sync push TASK_ID ./changes --dest src +[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 -hello from task -[task-exec] task_id=... sequence=2 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... +$ uvx --from pyro-mcp pyro task exec TASK_ID -- cat src/note.txt +hello from synced task +[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 -`.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: diff --git a/docs/install.md b/docs/install.md index 9e234be..62ae490 100644 --- a/docs/install.md +++ b/docs/install.md @@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```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-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. @@ -175,6 +175,7 @@ pyro run debian:12 -- git --version After the CLI path works, you can move on to: - persistent workspaces: `pyro task create debian:12 --source-path ./repo` +- live task updates: `pyro task sync push TASK_ID ./changes` - MCP: `pyro mcp serve` - Python SDK: `from pyro_mcp import Pyro` - 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 pyro task create debian:12 --source-path ./repo -pyro task exec TASK_ID -- sh -lc 'printf "hello from task\n" > note.txt' -pyro task exec TASK_ID -- cat note.txt +pyro task sync push TASK_ID ./changes --dest src +pyro task exec TASK_ID -- cat src/note.txt pyro task logs 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 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. +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 diff --git a/docs/integrations.md b/docs/integrations.md index 9401071..9c23ecf 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -30,7 +30,7 @@ Best when: Recommended surface: - `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: @@ -65,7 +65,7 @@ Best when: Recommended default: - `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: @@ -74,6 +74,8 @@ Lifecycle note: that final exec - use `create_task(source_path=...)` when the agent needs repeated commands in one persistent `/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: diff --git a/docs/public-contract.md b/docs/public-contract.md index 2f505f6..872abea 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -20,6 +20,7 @@ Top-level commands: - `pyro mcp serve` - `pyro run` - `pyro task create` +- `pyro task sync push` - `pyro task exec` - `pyro task status` - `pyro task logs` @@ -48,6 +49,8 @@ Behavioral guarantees: - `pyro task create` auto-starts a persistent workspace. - `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. +- `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 logs` returns persisted command history for that task until `pyro task delete`. - Task create/status results expose `workspace_seed` metadata describing how `/workspace` was @@ -69,6 +72,7 @@ Supported public entrypoints: - `Pyro.prune_environments()` - `Pyro.create_vm(...)` - `Pyro.create_task(...)` +- `Pyro.push_task_sync(task_id, source_path, *, dest="/workspace")` - `Pyro.start_vm(vm_id)` - `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)` - `Pyro.exec_task(task_id, *, command, timeout_seconds=30)` @@ -91,6 +95,7 @@ Stable public method names: - `prune_environments()` - `create_vm(...)` - `create_task(...)` +- `push_task_sync(task_id, source_path, *, dest="/workspace")` - `start_vm(vm_id)` - `exec_vm(vm_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(...)`. - `Pyro.create_task(..., source_path=...)` seeds `/workspace` from a host directory or a local `.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_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_create` +- `task_sync_push` - `task_exec` - `task_status` - `task_logs` @@ -149,6 +157,8 @@ Behavioral defaults: - `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 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. - `task_exec` runs one command in a persistent `/workspace` and leaves the task alive. diff --git a/examples/python_task.py b/examples/python_task.py index c1c7b98..421d16c 100644 --- a/examples/python_task.py +++ b/examples/python_task.py @@ -1,20 +1,29 @@ from __future__ import annotations +import tempfile +from pathlib import Path + from pyro_mcp import Pyro def main() -> None: pyro = Pyro() - created = pyro.create_task(environment="debian:12") - task_id = str(created["task_id"]) - try: - pyro.exec_task(task_id, command="printf 'hello from task\\n' > note.txt") - 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) + with ( + tempfile.TemporaryDirectory(prefix="pyro-task-seed-") as seed_dir, + tempfile.TemporaryDirectory(prefix="pyro-task-sync-") as sync_dir, + ): + Path(seed_dir, "note.txt").write_text("hello from seed\n", encoding="utf-8") + Path(sync_dir, "note.txt").write_text("hello from sync\n", encoding="utf-8") + created = pyro.create_task(environment="debian:12", source_path=seed_dir) + task_id = str(created["task_id"]) + try: + 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__": diff --git a/pyproject.toml b/pyproject.toml index f3118c1..bf779b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "2.2.0" +version = "2.3.0" description = "Curated Linux environments for ephemeral Firecracker-backed VM execution." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 196e814..18d2ff3 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -110,6 +110,15 @@ class Pyro: def status_task(self, task_id: str) -> dict[str, Any]: 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]: return self._manager.logs_task(task_id) @@ -269,6 +278,15 @@ class Pyro: """Run one command inside an existing task workspace.""" 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() async def task_status(task_id: str) -> dict[str, Any]: """Inspect task state and latest command metadata.""" diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 90aad34..a58af1c 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -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: entries = payload.get("entries") if not isinstance(entries, list) or not entries: @@ -250,7 +267,8 @@ def _build_parser() -> argparse.ArgumentParser: pyro run debian:12 -- git --version 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. """ @@ -456,6 +474,7 @@ def _build_parser() -> argparse.ArgumentParser: """ Examples: 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 logs TASK_ID """ @@ -472,6 +491,7 @@ def _build_parser() -> argparse.ArgumentParser: Examples: pyro task create debian:12 pyro task create debian:12 --source-path ./repo + pyro task sync push TASK_ID ./changes """ ), formatter_class=_HelpFormatter, @@ -552,6 +572,56 @@ def _build_parser() -> argparse.ArgumentParser: "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( "status", help="Inspect one task workspace.", @@ -821,6 +891,30 @@ def main() -> None: if exit_code != 0: raise SystemExit(exit_code) 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": payload = pyro.status_task(args.task_id) if bool(args.json): diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index f582609..971c7d7 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -5,7 +5,8 @@ from __future__ import annotations PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "task") PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",) 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 = ( "--vcpu-count", "--mem-mib", @@ -15,6 +16,7 @@ PUBLIC_CLI_TASK_CREATE_FLAGS = ( "--source-path", "--json", ) +PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS = ("--dest", "--json") PUBLIC_CLI_RUN_FLAGS = ( "--vcpu-count", "--mem-mib", @@ -39,6 +41,7 @@ PUBLIC_SDK_METHODS = ( "network_info_vm", "prune_environments", "pull_environment", + "push_task_sync", "reap_expired", "run_in_vm", "start_vm", @@ -63,4 +66,5 @@ PUBLIC_MCP_TOOLS = ( "task_exec", "task_logs", "task_status", + "task_sync_push", ) diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 8f7cc21..12619c1 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "2.2.0" +DEFAULT_CATALOG_VERSION = "2.3.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index 398951a..9d5ca3a 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -194,11 +194,11 @@ class PreparedWorkspaceSeed: bytes_written: int = 0 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 { "mode": self.mode, "source_path": self.source_path, - "destination": TASK_WORKSPACE_GUEST_PATH, + "destination": destination, "entry_count": self.entry_count, "bytes_written": self.bytes_written, } @@ -372,6 +372,8 @@ def _normalize_workspace_destination(destination: str) -> tuple[str, PurePosixPa if candidate == "": raise ValueError("workspace destination must not be empty") 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) if not destination_path.is_absolute(): destination_path = workspace_root / destination_path @@ -1218,6 +1220,52 @@ class VmManager: finally: 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]: if timeout_seconds <= 0: raise ValueError("timeout_seconds must be positive") diff --git a/tests/test_api.py b/tests/test_api.py index ca0b2b1..e5f86bf 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -49,6 +49,7 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None: assert "vm_run" in tool_names assert "vm_create" in tool_names assert "task_create" in tool_names + assert "task_sync_push" in tool_names 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, ) 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") status = pyro.status_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 created["workspace_seed"]["mode"] == "directory" + assert synced["workspace_sync"]["destination"] == "/workspace/subdir" assert status["command_count"] == 1 assert logs["count"] == 1 assert deleted["deleted"] is True diff --git a/tests/test_cli.py b/tests/test_cli.py index 180fbb4..549d113 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,6 +30,7 @@ def test_cli_help_guides_first_run() -> None: assert "pyro env list" in help_text assert "pyro env pull debian:12" 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 @@ -61,6 +62,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: task_help = _subparser_choice(parser, "task").format_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 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 "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( 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 +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( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 5f052f0..0a71662 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -19,6 +19,8 @@ from pyro_mcp.contract import ( PUBLIC_CLI_RUN_FLAGS, PUBLIC_CLI_TASK_CREATE_FLAGS, PUBLIC_CLI_TASK_SUBCOMMANDS, + PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS, + PUBLIC_CLI_TASK_SYNC_SUBCOMMANDS, PUBLIC_MCP_TOOLS, PUBLIC_SDK_METHODS, ) @@ -73,6 +75,14 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: ).format_help() for flag in PUBLIC_CLI_TASK_CREATE_FLAGS: 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() for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS: diff --git a/tests/test_server.py b/tests/test_server.py index 7ddfb60..0f6d19c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -33,6 +33,7 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None: assert "vm_status" in tool_names assert "task_create" 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: @@ -183,7 +184,13 @@ def test_task_tools_round_trip(tmp_path: Path) -> None: 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], 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) created = _extract_structured( await server.call_tool( @@ -196,22 +203,36 @@ def test_task_tools_round_trip(tmp_path: Path) -> None: ) ) 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( await server.call_tool( "task_exec", { "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})) 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["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 deleted["deleted"] is True diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index 9810631..1b5b562 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -363,6 +363,90 @@ def test_task_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None: 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: archive_path = tmp_path / "bad.tgz" with tarfile.open(archive_path, "w:gz") as archive: diff --git a/uv.lock b/uv.lock index cda5b9c..74fea07 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "2.2.0" +version = "2.3.0" source = { editable = "." } dependencies = [ { name = "mcp" },