claire/tests/test_sync_convergence.py
Natalie c1e6f7dbe5 feat: initial Clare scaffold — project manager for the Claude agent fleet
Push A (single-machine):
- HLC + event-sourced SQLite (events table is source of truth, projections rebuildable)
- Pydantic v2 domain models (Project, Task, Assignment, Session, Group, Update)
- rclaude subprocess wrapper (local_sessions via _claude-projects --sessions)
- Typer CLI: init, project, task, assign, pull, status, broadcast, serve, sync
- FastAPI + Jinja2 + HTMX dashboard
- 26 unit tests passing

Push B (HTTP API + sync substrate):
- /api/v1/* JSON routes (projects, tasks, assignments, sessions, status, broadcast, sync)
- CLI refactored as thin httpx client over the API — single business-logic codepath
- web/service.py: every business op defined once; HTML routes + API routes both call into it
- sync.py: peer-to-peer sync via /api/v1/sync/events with HLC + uuid-based dedup
- 32 tests passing including two-Clare convergence test

Push C (cross-host deployment):
- apricot install via uv (Python 3.12.12)
- systemd --user unit for clare-serve on apricot
- Cross-host sync demoed plum (10.9.0.3) ↔ apricot (10.9.0.2) over wg
- .local → .lan rename for forge URLs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 02:20:23 -07:00

118 lines
4 KiB
Python

"""Two-Clare convergence test.
Stands up two separate Clare apps (each with its own tmp DB + machine_id),
makes writes on each, then runs `sync_peer` and verifies both DBs end up
with identical projection state.
The two servers are TestClient instances; we monkey-patch sync.py's httpx
calls to use the TestClient transport rather than real network.
"""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
import httpx
import pytest
from fastapi.testclient import TestClient
def _build_app(tmp_dir: Path) -> TestClient:
"""Build an isolated Clare with its own config + DB."""
from clare.web.app import create_app
cfg_path = tmp_dir / "clare.toml"
db_path = tmp_dir / "clare.db"
return TestClient(create_app(config_path=cfg_path, db_path=db_path))
def _swap_httpx_to_clients(
clients_by_base: dict[str, TestClient]
):
"""Return a context manager that routes httpx.{get,post} calls to the
TestClient whose base_url matches."""
def fake_get(url: str, **kwargs):
for base, client in clients_by_base.items():
if url.startswith(base):
path = url[len(base):]
return client.get(path, params=kwargs.get("params"), timeout=kwargs.get("timeout"))
raise RuntimeError(f"no TestClient registered for {url}")
def fake_post(url: str, **kwargs):
for base, client in clients_by_base.items():
if url.startswith(base):
path = url[len(base):]
return client.post(path, json=kwargs.get("json"), timeout=kwargs.get("timeout"))
raise RuntimeError(f"no TestClient registered for {url}")
def fake_request(method: str, url: str, **kwargs):
if method.upper() == "GET":
return fake_get(url, **kwargs)
if method.upper() == "POST":
return fake_post(url, **kwargs)
raise RuntimeError(f"unsupported method {method}")
return patch.multiple(
httpx,
get=fake_get,
post=fake_post,
request=fake_request,
)
def test_two_clares_converge(tmp_path: Path) -> None:
# Build two independent Clare instances.
a_dir = tmp_path / "machine-a"
b_dir = tmp_path / "machine-b"
a_dir.mkdir()
b_dir.mkdir()
a = _build_app(a_dir)
b = _build_app(b_dir)
# Write on A.
a.post("/api/v1/projects", json={"name": "alpha", "goal": "from A"})
a_task_id = a.post(
"/api/v1/tasks", json={"project": "alpha", "title": "task on A"}
).json()["id"]
# Write on B.
b.post("/api/v1/projects", json={"name": "beta"})
b.post("/api/v1/tasks", json={"project": "beta", "title": "task on B"})
# Before sync, each knows only its own data.
assert {p["name"] for p in a.get("/api/v1/projects").json()["projects"]} == {"alpha"}
assert {p["name"] for p in b.get("/api/v1/projects").json()["projects"]} == {"beta"}
# Run sync from A's perspective, with B as the peer.
BASE_A = "http://machine-a"
BASE_B = "http://machine-b"
with _swap_httpx_to_clients({BASE_A: a, BASE_B: b}):
from clare.sync import sync_peer
result = sync_peer(BASE_B, local_base=BASE_A)
assert result.pulled > 0
assert result.pushed > 0
# After sync, both A and B should see both projects.
a_names = {p["name"] for p in a.get("/api/v1/projects").json()["projects"]}
b_names = {p["name"] for p in b.get("/api/v1/projects").json()["projects"]}
assert a_names == {"alpha", "beta"}
assert b_names == {"alpha", "beta"}
# And both tasks.
a_titles = {t["title"] for t in a.get("/api/v1/tasks").json()["tasks"]}
b_titles = {t["title"] for t in b.get("/api/v1/tasks").json()["tasks"]}
assert a_titles == {"task on A", "task on B"}
assert b_titles == {"task on A", "task on B"}
# Re-running sync is a no-op (dedup by event uuid).
with _swap_httpx_to_clients({BASE_A: a, BASE_B: b}):
from clare.sync import sync_peer
result2 = sync_peer(BASE_B, local_base=BASE_A)
assert result2.pulled == 0
assert result2.pushed == 0