---
phase: 05-artifact-pipeline
plan: 02
type: execute
wave: 2
depends_on: ["05-01"]
files_modified:
  - vmcluster_mcp/artifact/syncer.py
  - vmcluster_mcp/artifact/__init__.py
  - tests/test_artifact_sync.py
autonomous: true
requirements: [ART-02]

must_haves:
  truths:
    - "Agent can sync a registered artifact to cluster nodes via rsync"
    - "Nodes where the artifact hash already matches are skipped (dedup)"
    - "Per-node failures do not abort other nodes (best-effort)"
    - "SyncStatus contains per-node bytes_transferred, duration, rsync_exit_code, skipped flag"
  artifacts:
    - path: "vmcluster_mcp/artifact/syncer.py"
      provides: "ArtifactSyncer with rsync subprocess + dedup check"
      min_lines: 60
    - path: "vmcluster_mcp/artifact/__init__.py"
      provides: "artifact_sync tool registration"
      contains: "async def artifact_sync"
    - path: "tests/test_artifact_sync.py"
      provides: "Unit tests for sync with dedup, parallel fan-out, per-node failure"
      min_lines: 40
  key_links:
    - from: "vmcluster_mcp/artifact/syncer.py"
      to: "vmcluster_mcp/exec/ssh.py"
      via: "SSHConnectionManager.execute for dedup check"
      pattern: "ssh_manager\\.execute"
    - from: "vmcluster_mcp/artifact/syncer.py"
      to: "asyncio.create_subprocess_exec"
      via: "rsync subprocess"
      pattern: "create_subprocess_exec"
    - from: "vmcluster_mcp/artifact/__init__.py"
      to: "vmcluster_mcp/artifact/syncer.py"
      via: "ArtifactSyncer instance"
      pattern: "ArtifactSyncer\\("
---

<objective>
Implement `artifact_sync` — rsync-based artifact transfer to cluster nodes with content-addressed deduplication and concurrent fan-out.

Purpose: Enables agents to push build artifacts to cluster nodes, skipping nodes that already have the same artifact version. This is the prerequisite for `artifact_install`.
Output: `artifact/syncer.py` with `ArtifactSyncer` class, `artifact_sync` MCP tool, and unit tests.
</objective>

<execution_context>
@~/.copilot/get-shit-done/workflows/execute-plan.md
@~/.copilot/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-artifact-pipeline/05-CONTEXT.md
@.planning/phases/05-artifact-pipeline/05-RESEARCH.md
@.planning/phases/05-artifact-pipeline/05-01-SUMMARY.md

<interfaces>
<!-- Key types from models.py -->
From vmcluster_mcp/models.py:
```python
class SyncNodeResult(BaseModel):
    node_name: str
    bytes_transferred: int
    duration_s: float
    rsync_exit_code: int
    skipped: bool

class SyncStatus(BaseModel):
    per_node: dict[str, SyncNodeResult]

class BuildArtifact(BaseModel):
    artifact_id: str
    source_path: str
    # ... (full model in models.py)
```

From vmcluster_mcp/exec/ssh.py:
```python
class SSHConnectionManager:
    async def execute(self, ip, port, user, key_path, command, timeout_s=30) -> ExecResult:
```

From vmcluster_mcp/topology/schema.py:
```python
class NodeSpec(BaseModel):
    name: str
    role: str
    ip: str
    # ...

class SshSpec(BaseModel):
    key_path: str
    user: str = "root"
    connect_timeout_s: int = 30

class TopologySpec(BaseModel):
    cluster_name: str
    nodes: list[NodeSpec]
    ssh: SshSpec
    # ...
```

From vmcluster_mcp/artifact/registry.py (created in Plan 01):
```python
class ArtifactRegistry:
    def get(self, artifact_id: str) -> BuildArtifact | None: ...
```

From vmcluster_mcp/artifact/__init__.py (created in Plan 01):
```python
_registry = ArtifactRegistry(config.artifact_registry)
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: ArtifactSyncer with rsync subprocess and dedup check</name>
  <files>vmcluster_mcp/artifact/syncer.py, tests/test_artifact_sync.py</files>
  <read_first>
    - vmcluster_mcp/exec/ssh.py (SSHConnectionManager.execute — for dedup check pattern)
    - vmcluster_mcp/exec/__init__.py (SSHConnectionManager instantiation pattern, asyncio.gather fan-out)
    - vmcluster_mcp/models.py (SyncNodeResult, SyncStatus)
    - vmcluster_mcp/artifact/registry.py (ArtifactRegistry.get)
    - .planning/phases/05-artifact-pipeline/05-CONTEXT.md (D-04, D-05, D-06, D-08)
    - .planning/phases/05-artifact-pipeline/05-RESEARCH.md (rsync subprocess pattern)
  </read_first>
  <behavior>
    - Test: sync_node skips node when remote dir exists and force=False (SyncNodeResult.skipped=True, rsync not called)
    - Test: sync_node runs rsync when remote dir does not exist (exit_code 0 from subprocess)
    - Test: sync_node runs rsync when force=True even if remote dir exists
    - Test: sync_node reports rsync failure with non-zero exit code (SyncNodeResult.rsync_exit_code != 0)
    - Test: sync_all fans out via asyncio.gather across all nodes
    - Test: per-node failure does not abort other nodes (best-effort D-08)
    - Test: rsync command includes correct flags: -avz, --delete, -e "ssh -i {key} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
  </behavior>
  <action>
1. **Create `vmcluster_mcp/artifact/syncer.py`:**
   ```python
   from __future__ import annotations
   import asyncio, logging, time
   from vmcluster_mcp.exec.ssh import SSHConnectionManager
   from vmcluster_mcp.models import SyncNodeResult, SyncStatus

   logger = logging.getLogger("vmcluster_mcp.artifact")
   ```

2. **`class ArtifactSyncer`:**
   - `__init__(self, ssh_manager: SSHConnectionManager)`: Store ssh_manager reference.
   - `async def sync_node(self, ip, port, user, key_path, source_path, dest_path, artifact_id, force=False) -> SyncNodeResult`:
     - **Dedup check (D-06):** Run `ssh_manager.execute(ip, port, user, key_path, f"test -d {dest_path}/{artifact_id}", timeout_s=5)`. If exit_code==0 and not force: return `SyncNodeResult(node_name=..., bytes_transferred=0, duration_s=0, rsync_exit_code=0, skipped=True)`.
     - **rsync (D-04):** Use `asyncio.create_subprocess_exec("rsync", "-avz", "--delete", "-e", f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", f"{source_path}/", f"{user}@{ip}:{dest_path}/{artifact_id}/", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)`.
     - `await proc.communicate()` to get stdout/stderr.
     - Parse bytes_transferred from rsync output if available (or default to 0).
     - Return `SyncNodeResult(node_name=..., bytes_transferred=..., duration_s=..., rsync_exit_code=proc.returncode, skipped=False)`.
   - `async def sync_all(self, nodes, ssh_spec, source_path, dest_base, artifact_id, force=False) -> SyncStatus`:
     - Build list of `sync_node(...)` coroutines for each node (D-05 asyncio.gather fan-out).
     - `results = await asyncio.gather(*tasks, return_exceptions=True)` (D-08 best-effort).
     - For exceptions: create `SyncNodeResult(rsync_exit_code=-1, skipped=False)`.
     - Return `SyncStatus(per_node={node.name: result for ...})`.

3. **Create `tests/test_artifact_sync.py`** — mock `SSHConnectionManager.execute` and `asyncio.create_subprocess_exec`.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_artifact_sync.py -x -v && ruff check vmcluster_mcp/artifact/syncer.py</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/artifact/syncer.py contains `class ArtifactSyncer:`
    - vmcluster_mcp/artifact/syncer.py contains `create_subprocess_exec`
    - vmcluster_mcp/artifact/syncer.py contains `asyncio.gather`
    - vmcluster_mcp/artifact/syncer.py contains `test -d`
    - vmcluster_mcp/artifact/syncer.py contains `StrictHostKeyChecking=no`
    - vmcluster_mcp/artifact/syncer.py contains `from __future__ import annotations`
    - tests/test_artifact_sync.py exits 0 with all tests passing
    - ruff check vmcluster_mcp/artifact/syncer.py exits 0
  </acceptance_criteria>
  <done>ArtifactSyncer class with rsync subprocess, dedup check via SSH, asyncio.gather fan-out, best-effort per-node reporting; all tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Wire artifact_sync tool to MCP server</name>
  <files>vmcluster_mcp/artifact/__init__.py</files>
  <read_first>
    - vmcluster_mcp/artifact/__init__.py (current state from Plan 01 — registry tools already wired)
    - vmcluster_mcp/artifact/syncer.py (Task 1 output — ArtifactSyncer API)
    - vmcluster_mcp/exec/__init__.py (reference for SSHConnectionManager instantiation + topology loading)
    - vmcluster_mcp/topology/parser.py (load_topology function)
    - .planning/phases/05-artifact-pipeline/05-CONTEXT.md (D-04, D-06, D-08)
  </read_first>
  <action>
1. **Add to `vmcluster_mcp/artifact/__init__.py`** — import syncer and create shared instances:
   ```python
   from vmcluster_mcp.artifact.syncer import ArtifactSyncer
   from vmcluster_mcp.exec.ssh import SSHConnectionManager
   from vmcluster_mcp.topology.parser import load_topology

   _ssh_manager = SSHConnectionManager()
   _syncer = ArtifactSyncer(_ssh_manager)
   ```

2. **Add `artifact_sync` tool:**
   ```python
   @mcp.tool()
   @structured_tool_handler
   async def artifact_sync(
       cluster_name: str,
       artifact_id: str,
       nodes: list[str] | None = None,
       force: bool = False,
       dest_base: str = "/opt/vmcluster/artifacts",
   ) -> ToolResult[SyncStatus]:
   ```
   - Validate artifact exists in `_registry.get(artifact_id)` — if None, return ARTIFACT_NOT_FOUND error
   - Validate source_path exists (`os.path.isdir(artifact.source_path)`) — if not, return SOURCE_NOT_FOUND error
   - Load topology: `topo = load_topology(cluster_name, config.topology_dir)`
   - Resolve target nodes (all or subset) — same pattern as `node_exec_all`
   - Call `_syncer.sync_all(target_nodes, topo.ssh, artifact.source_path, dest_base, artifact_id, force)`
   - Return `ToolResult(success=True, result=sync_status)`

Note: The `_ssh_manager` here is a separate instance from `exec/__init__.py`'s `_ssh_manager`. This is correct — artifact module manages its own SSH connections for dedup checks. The rsync subprocess uses its own SSH transport.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/ -x -v && ruff check vmcluster_mcp/artifact/__init__.py</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/artifact/__init__.py contains `async def artifact_sync(`
    - vmcluster_mcp/artifact/__init__.py contains `ArtifactSyncer(`
    - vmcluster_mcp/artifact/__init__.py contains `load_topology(`
    - vmcluster_mcp/artifact/__init__.py contains `_registry.get(artifact_id)`
    - All existing + new tests pass
    - ruff check exits 0
  </acceptance_criteria>
  <done>artifact_sync MCP tool wired to ArtifactSyncer with topology resolution, artifact validation, and SSH dedup; all tests pass.</done>
</task>

</tasks>

<verification>
- `python -m pytest tests/ -x` — all tests pass
- `ruff check vmcluster_mcp/artifact/` — no lint errors
- `grep "artifact_sync" vmcluster_mcp/artifact/__init__.py` — tool present
- `grep "create_subprocess_exec" vmcluster_mcp/artifact/syncer.py` — rsync subprocess confirmed
- `grep "asyncio.gather" vmcluster_mcp/artifact/syncer.py` — fan-out confirmed
- `grep "test -d" vmcluster_mcp/artifact/syncer.py` — dedup check confirmed
</verification>

<success_criteria>
- ArtifactSyncer class with rsync subprocess invocation and SSH dedup check
- artifact_sync tool validates artifact exists, resolves target nodes, syncs via rsync
- Nodes with matching artifact are skipped unless force=True
- Per-node failures don't abort other nodes (best-effort)
- All tests pass, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/05-artifact-pipeline/05-02-SUMMARY.md`
</output>
