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>
118 lines
4 KiB
Python
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
|