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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue