generated from coulomb/repo-seed
feat(statehub): add offline write buffer relay
This commit is contained in:
@@ -98,10 +98,17 @@ async def client(test_engine):
|
||||
async with factory() as session:
|
||||
yield session
|
||||
|
||||
from api.services import write_idempotency as _write_idempotency
|
||||
|
||||
old_session_factory = _write_idempotency.async_session_factory
|
||||
_write_idempotency.async_session_factory = factory
|
||||
app.dependency_overrides[get_session] = _override
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
try:
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_write_idempotency.async_session_factory = old_session_factory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1015,6 +1015,154 @@ class TestLifecycleRenormalization:
|
||||
assert any("C-23 fixed" in fix for fix in report.fixes_applied)
|
||||
|
||||
|
||||
|
||||
class TestC20DependencyDetection:
|
||||
def test_canonical_dependency_fields_satisfy_workplan_dependency(self, tmp_path, monkeypatch):
|
||||
repo = tmp_path / "repo"
|
||||
workplans = repo / "workplans"
|
||||
workplans.mkdir(parents=True)
|
||||
(workplans / "STATE-WP-0001-base.md").write_text(
|
||||
"---\n"
|
||||
"id: STATE-WP-0001\n"
|
||||
"title: Base\n"
|
||||
"domain: financials\n"
|
||||
"repo: demo-repo\n"
|
||||
"status: active\n"
|
||||
"state_hub_workstream_id: \"base-ws\"\n"
|
||||
"---\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(workplans / "STATE-WP-0002-dependent.md").write_text(
|
||||
"---\n"
|
||||
"id: STATE-WP-0002\n"
|
||||
"title: Dependent\n"
|
||||
"domain: financials\n"
|
||||
"repo: demo-repo\n"
|
||||
"status: active\n"
|
||||
"state_hub_workstream_id: \"dependent-ws\"\n"
|
||||
"depends_on_workplans:\n"
|
||||
" - STATE-WP-0001\n"
|
||||
"---\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_get(_api_base, path, params=None, **_kwargs):
|
||||
if path == "/repos/demo-repo":
|
||||
import socket
|
||||
|
||||
return {
|
||||
"id": "repo-1",
|
||||
"slug": "demo-repo",
|
||||
"local_path": str(repo),
|
||||
"host_paths": {socket.gethostname(): str(repo)},
|
||||
"domain_slug": "financials",
|
||||
}
|
||||
if path == "/workstreams/base-ws":
|
||||
return {"id": "base-ws", "repo_id": "repo-1", "slug": "state-wp-0001", "title": "Base", "status": "active"}
|
||||
if path == "/workstreams/dependent-ws":
|
||||
return {"id": "dependent-ws", "repo_id": "repo-1", "slug": "state-wp-0002", "title": "Dependent", "status": "active"}
|
||||
if path == "/tasks" and params and params.get("workstream_id") in {"base-ws", "dependent-ws"}:
|
||||
return []
|
||||
if path == "/workstreams/base-ws/dependencies":
|
||||
return []
|
||||
if path == "/workstreams/dependent-ws/dependencies":
|
||||
return [
|
||||
{
|
||||
"id": "dep-1",
|
||||
"from_workplan_id": "dependent-ws",
|
||||
"to_workplan_id": "base-ws",
|
||||
"to_task_id": None,
|
||||
"relationship_type": "blocks",
|
||||
}
|
||||
]
|
||||
if path == "/workstreams" and params == {"repo_id": "repo-1"}:
|
||||
return []
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("consistency_check._api_get", fake_get)
|
||||
|
||||
report = check_repo("http://unused", "demo-repo")
|
||||
|
||||
assert "C-20" not in [issue.check_id for issue in report.issues]
|
||||
|
||||
|
||||
class TestC06WorkstreamCreation:
|
||||
def test_fix_repo_uses_repo_qualified_slug_when_base_slug_is_taken(self, tmp_path, monkeypatch):
|
||||
repo = tmp_path / "repo"
|
||||
workplans = repo / "workplans"
|
||||
workplans.mkdir(parents=True)
|
||||
wp = workplans / "STATE-WP-0001-demo.md"
|
||||
wp.write_text(
|
||||
"---\n"
|
||||
"id: STATE-WP-0001\n"
|
||||
"type: workplan\n"
|
||||
"title: Demo Workplan\n"
|
||||
"domain: financials\n"
|
||||
"repo: demo-repo\n"
|
||||
"status: ready\n"
|
||||
"owner: codex\n"
|
||||
"---\n\n"
|
||||
"## Implement Demo\n\n"
|
||||
"```task\n"
|
||||
"id: STATE-WP-0001-T01\n"
|
||||
"status: todo\n"
|
||||
"priority: high\n"
|
||||
"```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
created_workstreams = []
|
||||
created_tasks = []
|
||||
|
||||
def fake_get(_api_base, path, params=None, **_kwargs):
|
||||
if path == "/repos/demo-repo":
|
||||
import socket
|
||||
|
||||
return {
|
||||
"id": "repo-1",
|
||||
"slug": "demo-repo",
|
||||
"local_path": str(repo),
|
||||
"host_paths": {socket.gethostname(): str(repo)},
|
||||
"domain_slug": "financials",
|
||||
}
|
||||
if path == "/topics":
|
||||
return [{"id": "topic-1", "domain_slug": "financials"}]
|
||||
if path == "/workstreams" and params == {"slug": "state-wp-0001"}:
|
||||
return [{"id": "old-ws", "repo_id": "other-repo", "title": "Old Workplan"}]
|
||||
if path == "/workstreams" and params == {"slug": "demo-repo-state-wp-0001"}:
|
||||
return []
|
||||
if path == "/workstreams" and params == {"repo_id": "repo-1"}:
|
||||
return []
|
||||
if path == "/workstreams" and params and params.get("topic_id") == "topic-1":
|
||||
return []
|
||||
return []
|
||||
|
||||
def fake_post(_api_base, path, body):
|
||||
if path == "/workstreams":
|
||||
created_workstreams.append(body)
|
||||
return {"id": "new-ws", **body}
|
||||
if path == "/tasks":
|
||||
created_tasks.append(body)
|
||||
return {"id": "new-task", **body}
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr("consistency_check._api_get", fake_get)
|
||||
monkeypatch.setattr("consistency_check._api_post", fake_post)
|
||||
monkeypatch.setattr("consistency_check._api_patch", lambda *args, **kwargs: {"ok": True})
|
||||
monkeypatch.setattr("consistency_check._detect_behind_remote", lambda _repo_path: False)
|
||||
monkeypatch.setattr("consistency_check._detect_ahead_of_remote", lambda _repo_path: 0)
|
||||
monkeypatch.setattr("consistency_check._write_custodian_brief", lambda *args, **kwargs: False)
|
||||
monkeypatch.setattr("consistency_check._git_push", lambda _repo_path: (True, "pushed"))
|
||||
|
||||
report = fix_repo("http://unused", "demo-repo")
|
||||
|
||||
assert created_workstreams[0]["slug"] == "demo-repo-state-wp-0001"
|
||||
assert created_tasks[0]["workstream_id"] == "new-ws"
|
||||
patched = wp.read_text(encoding="utf-8")
|
||||
assert 'state_hub_workstream_id: "new-ws"' in patched
|
||||
assert 'state_hub_task_id: "new-task"' in patched
|
||||
assert any("C-06 fixed" in fix for fix in report.fixes_applied)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _git_pull (T02 remote fix helper)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
51
tests/test_edge_outbox.py
Normal file
51
tests/test_edge_outbox.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from api.edge.outbox import OutboxStore, PayloadRejected
|
||||
from api.services.write_idempotency import route_class_for
|
||||
|
||||
|
||||
def test_route_classifier_matches_safe_writes():
|
||||
assert route_class_for("POST", "/progress/") == "append"
|
||||
assert route_class_for("PATCH", "/tasks/abc") == "replace"
|
||||
assert route_class_for("DELETE", "/tasks/abc") is None
|
||||
|
||||
|
||||
def test_outbox_scrubs_secret_fields_and_tracks_status(tmp_path):
|
||||
store = OutboxStore(tmp_path / "outbox.sqlite3")
|
||||
envelope = store.enqueue(
|
||||
method="POST",
|
||||
path="/progress/",
|
||||
body={"summary": "offline", "password": "secret", "tokens_in": 12},
|
||||
source_agent="pytest",
|
||||
source_host="host-a",
|
||||
)
|
||||
|
||||
assert envelope.status == "queued"
|
||||
assert envelope.route_class == "append"
|
||||
assert envelope.body["password"] == "[redacted]"
|
||||
assert envelope.body["tokens_in"] == 12
|
||||
assert store.summary()["pending_count"] == 1
|
||||
|
||||
store.mark_acked(envelope.id, response_status=201, response_body={"id": "central"})
|
||||
acked = store.get(envelope.id)
|
||||
assert acked.status == "acked"
|
||||
assert acked.response_body == {"id": "central"}
|
||||
assert store.summary()["pending_count"] == 0
|
||||
|
||||
|
||||
def test_outbox_rejects_non_queueable_routes(tmp_path):
|
||||
store = OutboxStore(tmp_path / "outbox.sqlite3")
|
||||
try:
|
||||
store.enqueue(method="DELETE", path="/tasks/abc", body={})
|
||||
except PayloadRejected as exc:
|
||||
assert "not queueable" in str(exc)
|
||||
else:
|
||||
raise AssertionError("DELETE should not be queueable")
|
||||
|
||||
|
||||
def test_replace_writes_coalesce_superseded_queued_envelopes(tmp_path):
|
||||
store = OutboxStore(tmp_path / "outbox.sqlite3")
|
||||
first = store.enqueue(method="PATCH", path="/tasks/task-1", body={"status": "progress"})
|
||||
second = store.enqueue(method="PATCH", path="/tasks/task-1", body={"status": "done"})
|
||||
|
||||
assert store.get(first.id).status == "cancelled"
|
||||
assert store.get(second.id).status == "queued"
|
||||
assert len(store.due()) == 1
|
||||
117
tests/test_edge_relay.py
Normal file
117
tests/test_edge_relay.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from api.edge.outbox import OutboxStore
|
||||
from api.edge.relay import create_app, replay_pending
|
||||
|
||||
|
||||
class FailingAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def request(self, *args, **kwargs):
|
||||
raise httpx.ConnectError("upstream down")
|
||||
|
||||
async def get(self, *args, **kwargs):
|
||||
raise httpx.ConnectError("upstream down")
|
||||
|
||||
|
||||
class ConflictAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def request(self, method, path, **kwargs):
|
||||
request = httpx.Request(method, f"http://upstream{path}")
|
||||
return httpx.Response(409, json={"error": "conflict"}, request=request)
|
||||
|
||||
|
||||
class SuccessAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def request(self, method, path, **kwargs):
|
||||
request = httpx.Request(method, f"http://upstream{path}")
|
||||
return httpx.Response(201, json={"id": "central-id", "path": path}, request=request)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relay_queues_allowlisted_write_when_upstream_unreachable(tmp_path, monkeypatch):
|
||||
from api.edge import relay
|
||||
|
||||
monkeypatch.setattr(relay.httpx, "AsyncClient", FailingAsyncClient)
|
||||
outbox_path = tmp_path / "edge.sqlite3"
|
||||
app = create_app(upstream_url="http://upstream", outbox_path=str(outbox_path))
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://edge") as client:
|
||||
response = await client.post("/progress/", json={"event_type": "note", "summary": "queued"})
|
||||
|
||||
assert response.status_code == 202
|
||||
body = response.json()
|
||||
assert body["queued"] is True
|
||||
assert body["route_class"] == "append"
|
||||
|
||||
store = OutboxStore(outbox_path)
|
||||
queued = store.list(status="queued")
|
||||
assert len(queued) == 1
|
||||
assert queued[0].path == "/progress/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relay_replay_acks_successful_envelope(tmp_path, monkeypatch):
|
||||
from api.edge import relay
|
||||
|
||||
monkeypatch.setattr(relay.httpx, "AsyncClient", SuccessAsyncClient)
|
||||
store = OutboxStore(tmp_path / "edge.sqlite3")
|
||||
envelope = store.enqueue(method="POST", path="/progress/", body={"event_type": "note", "summary": "queued"})
|
||||
|
||||
result = await replay_pending(store, upstream_url="http://upstream")
|
||||
|
||||
assert result["acked"] == 1
|
||||
assert store.get(envelope.id).status == "acked"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relay_rejects_online_only_write_when_upstream_unreachable(tmp_path, monkeypatch):
|
||||
from api.edge import relay
|
||||
|
||||
monkeypatch.setattr(relay.httpx, "AsyncClient", FailingAsyncClient)
|
||||
app = create_app(upstream_url="http://upstream", outbox_path=str(tmp_path / "edge.sqlite3"))
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://edge") as client:
|
||||
response = await client.delete("/tasks/abc")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "not queueable" in response.json()["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relay_replay_marks_conflict(tmp_path, monkeypatch):
|
||||
from api.edge import relay
|
||||
|
||||
monkeypatch.setattr(relay.httpx, "AsyncClient", ConflictAsyncClient)
|
||||
store = OutboxStore(tmp_path / "edge.sqlite3")
|
||||
envelope = store.enqueue(method="PATCH", path="/tasks/task-1", body={"status": "done"})
|
||||
|
||||
result = await replay_pending(store, upstream_url="http://upstream")
|
||||
|
||||
assert result["conflict"] == 1
|
||||
assert store.get(envelope.id).status == "conflict"
|
||||
22
tests/test_mcp_queued_receipts.py
Normal file
22
tests/test_mcp_queued_receipts.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
|
||||
from mcp_server import server
|
||||
|
||||
|
||||
def test_mcp_write_returns_queued_receipt_without_requiring_rest_shape(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_post",
|
||||
lambda path, body: {
|
||||
"queued": True,
|
||||
"outbox_id": "env-1",
|
||||
"idempotency_key": "statehub-edge:env-1",
|
||||
"upstream": "unreachable",
|
||||
},
|
||||
)
|
||||
|
||||
result = json.loads(server.add_progress_event("queued progress"))
|
||||
|
||||
assert result["queued"] is True
|
||||
assert result["tool"] == "add_progress_event"
|
||||
assert result["receipt"]["outbox_id"] == "env-1"
|
||||
47
tests/test_write_idempotency.py
Normal file
47
tests/test_write_idempotency.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotent_progress_post_replays_original_response(client):
|
||||
payload = {"event_type": "note", "summary": "first idempotent write", "author": "codex"}
|
||||
headers = {"Idempotency-Key": "test-progress-key", "X-StateHub-Source-Agent": "pytest"}
|
||||
|
||||
first = await client.post("/progress/", json=payload, headers=headers)
|
||||
assert first.status_code in {200, 201}
|
||||
first_body = first.json()
|
||||
|
||||
second = await client.post("/progress/", json=dict(reversed(list(payload.items()))), headers=headers)
|
||||
assert second.status_code == first.status_code
|
||||
assert second.headers["x-statehub-idempotency-replay"] == "true"
|
||||
assert second.json() == first_body
|
||||
|
||||
listed = await client.get("/progress/")
|
||||
assert len([row for row in listed.json() if row["summary"] == payload["summary"]]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotency_key_reuse_with_different_request_conflicts(client):
|
||||
headers = {"Idempotency-Key": "same-key-different-body"}
|
||||
first = await client.post(
|
||||
"/progress/",
|
||||
json={"event_type": "note", "summary": "original"},
|
||||
headers=headers,
|
||||
)
|
||||
assert first.status_code in {200, 201}
|
||||
|
||||
second = await client.post(
|
||||
"/progress/",
|
||||
json={"event_type": "note", "summary": "changed"},
|
||||
headers=headers,
|
||||
)
|
||||
assert second.status_code == 409
|
||||
assert "different request" in second.json()["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotency_header_on_unsupported_route_is_ignored(client):
|
||||
first = await client.get("/state/health", headers={"Idempotency-Key": "ignored-on-read"})
|
||||
second = await client.get("/state/health", headers={"Idempotency-Key": "ignored-on-read"})
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert "x-statehub-idempotency-replay" not in second.headers
|
||||
Reference in New Issue
Block a user