Files
state-hub/tests/test_routers_core.py

1442 lines
55 KiB
Python

"""
Core router tests: topics, domains, workstreams, tasks, decisions, state summary.
Happy path + key error cases. All tests use a real PostgreSQL test database
(no mocking). The `client` fixture provides an httpx.AsyncClient backed by
the FastAPI ASGI app.
"""
from __future__ import annotations
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _create_domain(client, slug="testdomain", name="Test Domain"):
r = await client.post("/domains/", json={"slug": slug, "name": name})
assert r.status_code == 201, r.text
return r.json()
async def _create_topic(client, domain_slug="testdomain", slug="testtopic", title="Test Topic"):
r = await client.post("/topics/", json={
"slug": slug, "title": title, "domain": domain_slug,
})
assert r.status_code == 201, r.text
return r.json()
async def _create_workstream(client, topic_id, slug="test-ws", title="Test WS", status="active", **extra):
payload = {
"topic_id": topic_id, "slug": slug, "title": title, "status": status,
}
payload.update(extra)
r = await client.post("/workstreams/", json=payload)
assert r.status_code == 201, r.text
return r.json()
async def _create_task(client, workstream_id, title="Test task"):
r = await client.post("/tasks/", json={
"workstream_id": workstream_id, "title": title,
})
assert r.status_code == 201, r.text
return r.json()
async def _create_repo(client, domain_slug="testdomain", slug="test-repo", local_path=None):
r = await client.post("/repos/", json={
"domain_slug": domain_slug,
"slug": slug,
"name": "Test Repo",
"local_path": str(local_path) if local_path else None,
})
assert r.status_code == 201, r.text
return r.json()
# ---------------------------------------------------------------------------
# Domain tests
# ---------------------------------------------------------------------------
class TestDomains:
async def test_create_and_list(self, client):
await _create_domain(client)
r = await client.get("/domains/")
assert r.status_code == 200
slugs = [d["slug"] for d in r.json()]
assert "testdomain" in slugs
async def test_duplicate_slug_returns_409(self, client):
await _create_domain(client)
r = await client.post("/domains/", json={"slug": "testdomain", "name": "Dupe"})
assert r.status_code == 409
# ---------------------------------------------------------------------------
# Topic tests
# ---------------------------------------------------------------------------
class TestTopics:
async def test_create_and_get(self, client):
await _create_domain(client)
topic = await _create_topic(client)
r = await client.get(f"/topics/{topic['id']}")
assert r.status_code == 200
assert r.json()["slug"] == "testtopic"
async def test_duplicate_slug_returns_409(self, client):
await _create_domain(client)
await _create_topic(client)
r = await client.post("/topics/", json={
"slug": "testtopic", "title": "Dupe", "domain": "testdomain",
})
assert r.status_code == 409
async def test_unknown_domain_returns_404(self, client):
r = await client.post("/topics/", json={
"slug": "x", "title": "X", "domain": "doesnotexist",
})
assert r.status_code == 404
# ---------------------------------------------------------------------------
# Workstream tests
# ---------------------------------------------------------------------------
class TestWorkstreams:
async def test_create_and_list_by_topic(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.get(f"/workstreams/?topic_id={topic['id']}")
assert r.status_code == 200
ids = [w["id"] for w in r.json()]
assert ws["id"] in ids
async def test_status_transition(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "finished"})
assert r.status_code == 200
assert r.json()["status"] == "finished"
async def test_legacy_completed_status_is_normalized(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "completed"})
assert r.status_code == 200
assert r.json()["status"] == "finished"
async def test_filter_by_owner(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
await client.patch(f"/workstreams/{ws['id']}", json={"owner": "alice"})
r = await client.get("/workstreams/?owner=alice")
assert r.status_code == 200
assert len(r.json()) == 1
assert r.json()[0]["id"] == ws["id"]
async def test_filter_by_slug(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], slug="my-special-ws")
r = await client.get("/workstreams/?slug=my-special-ws")
assert r.status_code == 200
assert len(r.json()) == 1
async def test_workplan_index_route(self, client):
r = await client.get("/workstreams/workplan-index")
assert r.status_code == 200
assert "workstreams" in r.json()
# ---------------------------------------------------------------------------
# Task tests
# ---------------------------------------------------------------------------
class TestTasks:
async def test_create_and_list_by_workstream(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.get(f"/tasks/?workstream_id={ws['id']}")
assert r.status_code == 200
ids = [t["id"] for t in r.json()]
assert task["id"] in ids
async def test_needs_human_flag(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={
"needs_human": True,
"intervention_note": "needs review",
})
assert r.status_code == 200
assert r.json()["needs_human"] is True
async def test_needs_human_without_note_returns_422(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"needs_human": True})
assert r.status_code == 422
async def test_cancel_via_delete(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.delete(f"/tasks/{task['id']}")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
async def test_filter_by_priority(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
await client.post("/tasks/", json={
"workstream_id": ws["id"], "title": "High prio", "priority": "high",
})
await client.post("/tasks/", json={
"workstream_id": ws["id"], "title": "Low prio", "priority": "low",
})
r = await client.get(f"/tasks/?workstream_id={ws['id']}&priority=high")
assert r.status_code == 200
titles = [t["title"] for t in r.json()]
assert "High prio" in titles
assert "Low prio" not in titles
@pytest.mark.parametrize("initial_status", ["proposed", "ready", "backlog"])
async def test_task_start_activates_planning_workstream(self, client, initial_status):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(
client,
topic["id"],
slug=f"{initial_status}-ws",
status=initial_status,
)
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"})
assert r.status_code == 200
r = await client.get(f"/workstreams/{ws['id']}")
assert r.status_code == 200
assert r.json()["status"] == "active"
async def test_task_start_does_not_unblock_blocked_workstream(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], slug="blocked-ws", status="blocked")
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"})
assert r.status_code == 200
r = await client.get(f"/workstreams/{ws['id']}")
assert r.status_code == 200
assert r.json()["status"] == "blocked"
# ---------------------------------------------------------------------------
# Decision tests
# ---------------------------------------------------------------------------
class TestDecisions:
async def test_create_and_resolve(self, client):
await _create_domain(client)
topic = await _create_topic(client)
r = await client.post("/decisions/", json={
"title": "Should we use X?",
"topic_id": topic["id"],
})
assert r.status_code == 201
d_id = r.json()["id"]
r2 = await client.post(f"/decisions/{d_id}/resolve", json={
"rationale": "Yes, use X.",
"decided_by": "bernd",
})
assert r2.status_code == 200
assert r2.json()["status"] == "resolved"
async def test_resolve_already_resolved_returns_409(self, client):
await _create_domain(client)
topic = await _create_topic(client)
r = await client.post("/decisions/", json={
"title": "Already done", "topic_id": topic["id"],
})
d_id = r.json()["id"]
await client.post(f"/decisions/{d_id}/resolve", json={"rationale": "Done", "decided_by": "bernd"})
r2 = await client.post(f"/decisions/{d_id}/resolve", json={"rationale": "Again", "decided_by": "bernd"})
assert r2.status_code == 409
async def test_financial_keyword_auto_escalates(self, client):
await _create_domain(client)
topic = await _create_topic(client)
r = await client.post("/decisions/", json={
"title": "Purchase cloud credits",
"topic_id": topic["id"],
"decision_type": "pending",
})
assert r.status_code == 201
body = r.json()
assert body["status"] == "escalated"
assert body["escalation_note"]
# ---------------------------------------------------------------------------
# State summary
# ---------------------------------------------------------------------------
class TestStateSummary:
async def test_summary_returns_expected_shape(self, client):
r = await client.get("/state/summary")
assert r.status_code == 200
body = r.json()
assert "open_workstreams" in body
assert "blocking_decisions" in body
assert "blocked_tasks" in body
assert "domains" in body
async def test_summary_counts_reflect_created_data(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
# Mark as blocked so it shows in blocked_tasks
await client.patch(f"/tasks/{task['id']}",
json={"status": "blocked", "blocking_reason": "waiting on dep"})
r = await client.get("/state/summary")
body = r.json()
assert len(body["blocked_tasks"]) >= 1
async def test_summary_derives_blocked_workstream_from_flow_engine(self, client):
await _create_domain(client)
topic = await _create_topic(client)
blocked_ws = await _create_workstream(client, topic["id"], slug="blocked-ws")
dependency_ws = await _create_workstream(client, topic["id"], slug="dependency-ws")
r = await client.post(
f"/workstreams/{blocked_ws['id']}/dependencies/",
json={
"to_workstream_id": dependency_ws["id"],
"description": "Blocked until dependency completes",
},
)
assert r.status_code == 201
r = await client.get("/state/summary")
assert r.status_code == 200
body = r.json()
summaries = {item["id"]: item for item in body["open_workstreams"]}
assert summaries[blocked_ws["id"]]["status"] == "blocked"
assert summaries[blocked_ws["id"]]["blocked_reasons"][0]["id"] == "dependencies.all_complete"
assert body["totals"]["workstreams"]["blocked"] == 1
class TestFlowEndpoints:
async def test_list_flow_definitions(self, client):
r = await client.get("/flows/definitions")
assert r.status_code == 200
entity_types = {item["entity_type"] for item in r.json()}
assert {"workstream", "task", "contribution", "capability_request"} <= entity_types
async def test_get_flow_state_and_advance_workstream(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
await client.patch(f"/tasks/{task['id']}", json={"status": "done"})
r = await client.get(f"/flows/workstream/{ws['id']}")
assert r.status_code == 200
assert "finished" in r.json()["reachable"]
r = await client.post(f"/flows/workstream/{ws['id']}/advance/finished")
assert r.status_code == 200
assert r.json()["current_workstation"] == "finished"
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "finished"
async def test_advance_workstream_respects_current_exit_assertions(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], slug="exit-blocked-ws")
dependency_ws = await _create_workstream(client, topic["id"], slug="unfinished-dep")
task = await _create_task(client, ws["id"])
await client.patch(f"/tasks/{task['id']}", json={"status": "done"})
await client.post(
f"/workstreams/{ws['id']}/dependencies/",
json={
"to_workstream_id": dependency_ws["id"],
"description": "Dependency must finish first",
},
)
r = await client.post(f"/flows/workstream/{ws['id']}/advance/finished")
assert r.status_code == 409
assert r.json()["detail"]["blocking_assertions"][0]["id"] == "dependencies.all_complete"
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active"
class TestReconciliationEndpoints:
async def test_classify_workstream_open_transition_write_through(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"actor": "dashboard",
"intent": "push back to backlog",
"file_backed": True,
})
assert r.status_code == 200
body = r.json()
assert body["current_status"] == "active"
assert body["target_status"] == "backlog"
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "not_attempted"
assert body["intent"] == "push back to backlog"
async def test_classify_workstream_finish_with_open_task_needs_confirmation(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
await _create_task(client, ws["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "finished",
"file_backed": True,
})
assert r.status_code == 200
body = r.json()
assert body["tasks_terminal"] is False
assert body["reconciliation_class"] == "human_confirmation"
assert "open work" in body["reason"]
async def test_classify_task_blocked_without_reason_needs_confirmation(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "blocked",
"file_backed": True,
"task_linked": True,
})
assert r.status_code == 200
body = r.json()
assert body["current_status"] == "todo"
assert body["reconciliation_class"] == "human_confirmation"
assert "blocking reason" in body["reason"]
async def test_classify_unknown_workstream_returns_404(self, client):
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": "00000000-0000-0000-0000-000000000001",
"target_status": "active",
})
assert r.status_code == 404
async def test_apply_workstream_write_through_patches_file_then_db(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: active\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"actor": "dashboard",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "applied"
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
assert "status: backlog" in wp.read_text(encoding="utf-8")
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "backlog"
async def test_apply_workstream_without_file_does_not_mutate_db(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"file_backed": True,
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["file_backed"] is False
assert body["reconciliation_class"] == "deferred"
assert body["write_through_result"] == "not_applicable"
assert body["reconciliation_record_id"]
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active"
r = await client.get("/messages/?to_agent=state-hub&unread_only=true")
assert r.status_code == 200
messages = r.json()
assert len(messages) == 1
assert messages[0]["id"] == body["reconciliation_record_id"]
assert "Reconcile workstream state change" in messages[0]["subject"]
assert ws["id"] in messages[0]["body"]
async def test_apply_task_write_through_patches_task_block_then_db(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
task = await _create_task(client, ws["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: active\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n\n"
"## Demo Task\n\n"
"```task\n"
"id: STATE-WP-9999-T01\n"
"status: todo\n"
"priority: high\n"
f"state_hub_task_id: \"{task['id']}\"\n"
"```\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"actor": "dashboard",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "applied"
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
assert "status: in_progress" in wp.read_text(encoding="utf-8")
r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "in_progress"
async def test_apply_task_start_write_through_activates_parent_file_and_db(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"], status="ready")
task = await _create_task(client, ws["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: ready\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n\n"
"## Demo Task\n\n"
"```task\n"
"id: STATE-WP-9999-T01\n"
"status: todo\n"
"priority: high\n"
f"state_hub_task_id: \"{task['id']}\"\n"
"```\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"actor": "dashboard",
"expected_current_status": "todo",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["write_through_result"] == "applied"
text = wp.read_text(encoding="utf-8")
assert "status: active" in text
assert "status: in_progress" in text
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active"
r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "in_progress"
async def test_apply_task_confirmation_case_creates_reconciliation_message(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
task = await _create_task(client, ws["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: active\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n\n"
"## Demo Task\n\n"
"```task\n"
"id: STATE-WP-9999-T01\n"
"status: todo\n"
"priority: high\n"
f"state_hub_task_id: \"{task['id']}\"\n"
"```\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "blocked",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["reconciliation_class"] == "human_confirmation"
assert body["write_through_result"] == "not_applicable"
assert body["reconciliation_record_id"]
r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "todo"
r = await client.get("/messages/?to_agent=state-hub&unread_only=true")
messages = r.json()
assert len(messages) == 1
assert "blocking reason" in messages[0]["body"]
async def test_apply_workstream_stale_expected_status_creates_conflict_message(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: active\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n",
encoding="utf-8",
)
await client.patch(f"/workstreams/{ws['id']}", json={"status": "ready"})
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"expected_current_status": "active",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["conflict"] is True
assert body["write_through_result"] == "not_applicable"
assert body["current_status"] == "ready"
assert "expected" in body["reason"]
assert "status: active" in wp.read_text(encoding="utf-8")
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "ready"
async def test_apply_workstream_file_status_drift_creates_conflict_message(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: ready\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"expected_current_status": "active",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["conflict"] is True
assert body["write_through_result"] == "not_applicable"
assert "differs from cached DB status" in body["reason"]
assert "status: ready" in wp.read_text(encoding="utf-8")
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active"
async def test_apply_task_unavailable_host_path_creates_conflict_message(self, client, tmp_path):
await _create_domain(client)
repo = await _create_repo(client, local_path=tmp_path / "missing-repo")
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
task = await _create_task(client, ws["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"expected_current_status": "todo",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["conflict"] is True
assert body["write_through_result"] == "not_applicable"
assert "host path" in body["reason"]
assert body["reconciliation_record_id"]
r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "todo"
class TestExecutionQueueEndpoints:
async def test_execution_semantics_separates_state_hub_and_activity_core(self, client):
r = await client.get("/execution/semantics")
assert r.status_code == 200
body = r.json()
assert "queued" in body["execution_states"]
assert "immediate" in body["launch_modes"]
assert "parallel" in body["concurrency_modes"]
assert any("launch requests" in item for item in body["state_hub_responsibility"])
assert any("dispatch" in item for item in body["activity_core_responsibility"])
async def test_execution_intent_update_does_not_change_lifecycle_status(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], status="ready")
r = await client.patch(f"/execution/workstreams/{ws['id']}/intent", json={
"execution_state": "queued",
"launch_mode": "queued",
"concurrency_mode": "parallel",
"queue_rank": 7,
"execution_group": "ui-state",
})
assert r.status_code == 200, r.text
body = r.json()
assert body["execution_state"] == "queued"
assert body["launch_mode"] == "queued"
assert body["concurrency_mode"] == "parallel"
assert body["queue_rank"] == 7
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "ready"
assert r.json()["execution_state"] == "queued"
async def test_workplan_stack_orders_eligible_queued_work_before_blocked(self, client):
await _create_domain(client)
topic = await _create_topic(client)
queued = await _create_workstream(
client,
topic["id"],
slug="queued-wp",
status="ready",
planning_priority="high",
planning_order=2,
)
blocked = await _create_workstream(
client,
topic["id"],
slug="blocked-wp",
status="ready",
planning_priority="high",
planning_order=1,
)
lifecycle_blocked = await _create_workstream(
client,
topic["id"],
slug="lifecycle-blocked-wp",
status="blocked",
planning_priority="high",
planning_order=0,
)
dependency = await _create_workstream(
client,
topic["id"],
slug="dependency-wp",
status="active",
planning_priority="low",
planning_order=3,
)
await client.patch(f"/execution/workstreams/{queued['id']}/intent", json={
"execution_state": "queued",
"launch_mode": "queued",
"queue_rank": 2,
})
await client.patch(f"/execution/workstreams/{blocked['id']}/intent", json={
"execution_state": "queued",
"launch_mode": "queued",
"queue_rank": 1,
})
await client.patch(f"/execution/workstreams/{lifecycle_blocked['id']}/intent", json={
"execution_state": "queued",
"launch_mode": "queued",
"queue_rank": 0,
})
await client.post(
f"/workstreams/{blocked['id']}/dependencies/",
json={"to_workstream_id": dependency["id"], "description": "wait"},
)
r = await client.get("/execution/workplan-stack?include_manual=false")
assert r.status_code == 200, r.text
rows = r.json()
assert [row["slug"] for row in rows] == ["queued-wp", "lifecycle-blocked-wp", "blocked-wp"]
assert rows[0]["eligible"] is True
assert rows[1]["eligible"] is False
assert rows[1]["status"] == "blocked"
assert rows[2]["eligible"] is False
assert rows[2]["blocked_by_workstream_ids"] == [dependency["id"]]
async def test_launch_request_records_handoff_and_updates_execution_intent(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], status="ready")
r = await client.post("/execution/launch-requests", json={
"workstream_id": ws["id"],
"requested_by": "dashboard",
"requested_actor": "activity-core",
"launch_mode": "immediate",
"concurrency_mode": "sequential",
"priority": "high",
"branch_preference": "codex/state-wp-0049",
"immediate_pickup": True,
"notes": "start now",
})
assert r.status_code == 201, r.text
body = r.json()
assert body["workstream_id"] == ws["id"]
assert body["launch_mode"] == "immediate"
assert body["immediate_pickup"] is True
assert body["status"] == "requested"
r = await client.get(f"/workstreams/{ws['id']}")
updated = r.json()
assert updated["status"] == "ready"
assert updated["execution_state"] == "launching"
assert updated["launch_mode"] == "immediate"
r = await client.get(f"/execution/launch-requests?workstream_id={ws['id']}")
assert len(r.json()) == 1
def _fabric_graph_export(generated_at="2026-05-23T12:00:00Z", extra_node=False):
nodes = [
{
"id": "the-custodian.state-hub",
"kind": "ServiceDeclaration",
"name": "State Hub",
"repo": "state-hub",
"domain": "custodian",
"lifecycle": "active",
"canon_category": "service",
"canon_anchor": "state-hub",
"mapping_fit": "direct",
"evidence_state": "declared",
"attributes": {"state_hub_repo_id": "state-hub"},
},
{
"id": "the-custodian.state-hub.http-api",
"kind": "InterfaceDeclaration",
"name": "State Hub HTTP API",
"repo": "state-hub",
"domain": "custodian",
"lifecycle": "active",
"canon_category": "interface",
"mapping_fit": "direct",
"evidence_state": "observed",
"attributes": {},
},
{
"id": "the-custodian.state-hub.coordination",
"kind": "CapabilityDeclaration",
"name": "Coordination",
"repo": "state-hub",
"domain": "custodian",
"lifecycle": "active",
"canon_category": "capability",
"mapping_fit": "direct",
"evidence_state": "declared",
"attributes": {},
},
]
edges = [
{
"from": "the-custodian.state-hub",
"to": "the-custodian.state-hub.http-api",
"type": "exposes",
"canonical_type": "exposes",
"canon_anchor": "state-hub-http",
"mapping_fit": "direct",
"display_only": False,
"evidence_state": "observed",
"attributes": {},
},
{
"from": "the-custodian.state-hub",
"to": "the-custodian.state-hub.coordination",
"type": "provides",
"canonical_type": "implements",
"mapping_fit": "direct",
"display_only": False,
"evidence_state": "declared",
"attributes": {},
},
{
"from": "the-custodian.state-hub.coordination",
"to": "the-custodian.state-hub.http-api",
"type": "depends_on",
"canonical_type": "depends_on",
"mapping_fit": "direct",
"display_only": False,
"evidence_state": "declared",
"attributes": {},
},
]
if extra_node:
nodes.append(
{
"id": "railiance.fabric.registry",
"kind": "ServiceDeclaration",
"name": "Railiance Fabric Registry",
"repo": "railiance-fabric",
"domain": "custodian",
"lifecycle": "active",
"canon_category": "service",
"mapping_fit": "direct",
"evidence_state": "observed",
"attributes": {},
}
)
edges.append(
{
"from": "railiance.fabric.registry",
"to": "the-custodian.state-hub",
"type": "exposes",
"canonical_type": "exposes",
"mapping_fit": "direct",
"display_only": False,
"evidence_state": "observed",
"attributes": {},
}
)
return {
"apiVersion": "railiance.fabric/v1alpha1",
"kind": "FabricGraphExport",
"generated_at": generated_at,
"source": {
"repo": "registry",
"commit": "abc123",
"path": ".railiance-fabric/registry.sqlite3",
},
"nodes": nodes,
"edges": edges,
}
def _financial_fabric_graph_export(generated_at="2026-05-24T00:00:00Z"):
return {
"apiVersion": "railiance.fabric/v1alpha2",
"kind": "FabricGraphExport",
"schema_version": "financial-fabric-v1",
"generated_at": generated_at,
"source": {
"producer": "railiance-fabric",
"registry": "registry",
"commit": "financial-example",
"generation_reason": "operator_refresh",
},
"compatibility": {
"legacy_v1alpha1_supported": True,
"breaking_reset": False,
},
"netkingdom": {
"id": "railiance.netkingdom",
"name": "Railiance Netkingdom",
"king_actor_id": "actor.railiance.king",
},
"actors": [
{
"id": "actor.railiance.king",
"kind": "FabricActor",
"role": "king",
"name": "Railiance King",
},
{
"id": "actor.railiance.primary-lord",
"kind": "FabricActor",
"role": "lord",
"name": "Railiance Primary Lord",
},
{
"id": "actor.coulomb.tenant",
"kind": "FabricActor",
"role": "tenant",
"name": "Coulomb Tenant",
},
],
"fabrics": [
{
"id": "fabric.railiance.primary",
"kind": "Fabric",
"name": "Railiance Primary Fabric",
"netkingdom_id": "railiance.netkingdom",
"lord_actor_id": "actor.railiance.primary-lord",
"parent_fabric_id": None,
"status": "active",
"boundary": {"boundary_type": "fabric"},
"evidence_refs": [],
},
{
"id": "subfabric.railiance.tenant.coulomb",
"kind": "Subfabric",
"name": "Coulomb Tenant Subfabric",
"netkingdom_id": "railiance.netkingdom",
"parent_fabric_id": "fabric.railiance.primary",
"tenant_actor_id": "actor.coulomb.tenant",
"status": "planned",
"boundary": {"boundary_type": "subfabric"},
"evidence_refs": [],
},
],
"nodes": [
{
"id": "state-hub.http",
"kind": "UtilityInterface",
"name": "State Hub HTTP API",
"repo": "state-hub",
"domain": "custodian",
"lifecycle": "active",
"containment": {
"netkingdom_id": "railiance.netkingdom",
"fabric_id": "fabric.railiance.primary",
"subfabric_id": None,
"environment": "local",
"deployment_scenario_id": None,
},
"ownership": {
"owner_actor_id": "actor.railiance.primary-lord",
"owner_role": "lord",
"resolution": "inherited",
"inherited_from": "fabric.railiance.primary",
"supporting_actor_ids": [],
},
"accounting": {
"cost_center_id": "cc.platform.shared",
"allocation_model": "direct",
},
"evidence": {
"state": "declared",
"review_state": "accepted",
"confidence": 0.9,
"refs": [],
},
"canon_category": "endpoint",
"canon_anchor": "model/network",
"mapping_fit": "partial",
"evidence_state": "declared",
"attributes": {},
},
{
"id": "coulomb.automation-client",
"kind": "Service",
"name": "Coulomb Automation Client",
"repo": "coulomb-automation",
"domain": "railiance",
"lifecycle": "planned",
"containment": {
"netkingdom_id": "railiance.netkingdom",
"fabric_id": "fabric.railiance.primary",
"subfabric_id": "subfabric.railiance.tenant.coulomb",
"environment": "local",
"deployment_scenario_id": None,
},
"ownership": {
"owner_actor_id": "actor.coulomb.tenant",
"owner_role": "tenant",
"resolution": "explicit",
"supporting_actor_ids": [],
},
"accounting": {
"cost_center_id": "cc.coulomb.automation",
"allocation_model": "direct",
},
"evidence": {
"state": "declared",
"review_state": "accepted",
"confidence": 0.8,
"refs": [],
},
"attributes": {},
},
],
"edges": [
{
"id": "utility:state-hub-http:coulomb-client",
"from": "state-hub.http",
"to": "coulomb.automation-client",
"type": "provides_utility_to",
"relationship_category": "utility",
"canonical_type": "depends_on",
"canon_anchor": "model/landscape",
"mapping_fit": "partial",
"display_only": False,
"evidence_state": "declared",
"provider": {
"owner_actor_id": "actor.railiance.primary-lord",
"fabric_id": "fabric.railiance.primary",
"subfabric_id": None,
},
"consumer": {
"owner_actor_id": "actor.coulomb.tenant",
"fabric_id": "fabric.railiance.primary",
"subfabric_id": "subfabric.railiance.tenant.coulomb",
},
"boundary": {
"crosses_fabric_boundary": False,
"crosses_subfabric_boundary": True,
},
"utility": {
"utility_type": "coordination_api",
"contract_id": "state-hub.http",
"payment_schema_id": "payment.internal-tenant-access",
"metering_basis": "unknown",
"business_model": "tenant_utility",
},
"accounting": {
"provider_profit_center_id": "pc.tenant-utilities",
"consumer_cost_center_id": "cc.coulomb.automation",
"allocation_model": "usage_weighted",
},
"evidence": {
"state": "declared",
"review_state": "accepted",
"confidence": 0.8,
"refs": [],
},
"attributes": {},
}
],
"unresolved": [],
}
class TestFabricGraphReadModel:
async def test_validation_failure_records_failed_import_without_read_model_rows(self, client):
payload = _fabric_graph_export()
payload.pop("kind")
r = await client.post("/fabric/graph-exports", json=payload)
assert r.status_code == 422
body = r.json()["detail"]
assert body["validation_status"] == "invalid"
assert body["import_id"]
r = await client.get("/fabric/graph-exports?validation_status=invalid")
assert r.status_code == 200
imports = r.json()
assert len(imports) == 1
assert imports[0]["node_count"] == 0
assert imports[0]["edge_count"] == 0
assert imports[0]["error_details"]["error"].startswith("invalid FabricGraphExport")
r = await client.get("/fabric/graph/nodes")
assert r.status_code == 404
r = await client.get("/progress/?event_type=fabric_graph_import")
assert r.status_code == 200
assert "rejected" in r.json()[0]["summary"]
async def test_idempotent_reingest_uses_canonical_graph_content_hash(self, client):
first = _fabric_graph_export(generated_at="2026-05-23T12:00:00Z")
second = _fabric_graph_export(generated_at="2026-05-23T12:05:00Z")
r = await client.post("/fabric/graph-exports", json=first)
assert r.status_code == 200, r.text
first_body = r.json()
assert first_body["created"] is True
assert first_body["idempotent"] is False
r = await client.post("/fabric/graph-exports", json=second)
assert r.status_code == 200, r.text
second_body = r.json()
assert second_body["created"] is False
assert second_body["idempotent"] is True
assert second_body["import_run"]["id"] == first_body["import_run"]["id"]
r = await client.get("/fabric/graph-exports")
assert len(r.json()) == 1
r = await client.get("/fabric/graph/nodes")
assert len(r.json()) == 3
async def test_latest_import_selection_tracks_new_graph_content(self, client):
r = await client.post("/fabric/graph-exports", json=_fabric_graph_export())
assert r.status_code == 200, r.text
first_id = r.json()["import_run"]["id"]
r = await client.post("/fabric/graph-exports", json=_fabric_graph_export(extra_node=True))
assert r.status_code == 200, r.text
second_id = r.json()["import_run"]["id"]
assert second_id != first_id
r = await client.get("/fabric/graph-exports/latest")
latest = r.json()
assert latest["id"] == second_id
assert latest["node_count"] == 4
assert latest["edge_count"] == 4
r = await client.get("/fabric/graph-exports")
latest_flags = {row["id"]: row["is_latest"] for row in r.json()}
assert latest_flags[first_id] is False
assert latest_flags[second_id] is True
async def test_read_only_queries_filter_graph_without_mutating_state_hub_entities(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], status="ready")
task = await _create_task(client, ws["id"])
r = await client.post("/fabric/graph-exports", json=_fabric_graph_export(extra_node=True))
assert r.status_code == 200, r.text
before_progress = await client.get("/progress/")
before_progress_count = len(before_progress.json())
r = await client.get("/fabric/graph/summary")
assert r.status_code == 200
summary = r.json()
assert summary["node_count"] == 4
assert summary["edge_count"] == 4
assert summary["nodes_by_repo"]["state-hub"] == 3
assert summary["edges_by_canonical_type"]["exposes"] == 2
r = await client.get("/fabric/graph/nodes?repo=state-hub&canonical_category=service")
assert r.status_code == 200
assert [node["graph_id"] for node in r.json()] == ["the-custodian.state-hub"]
for relationship in ("exposes", "depends_on", "implements"):
r = await client.get(f"/fabric/graph/edges?canonical_relationship={relationship}")
assert r.status_code == 200
assert len(r.json()) >= 1
after_progress = await client.get("/progress/")
assert len(after_progress.json()) == before_progress_count
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "ready"
r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "todo"
async def test_financial_vnext_ingest_materializes_ownership_utility_and_summary(self, client):
r = await client.post("/fabric/graph-exports", json=_financial_fabric_graph_export())
assert r.status_code == 200, r.text
body = r.json()
assert body["import_run"]["api_version"] == "railiance.fabric/v1alpha2"
assert body["import_run"]["schema_version"] == "financial-fabric-v1"
assert body["import_run"]["netkingdom_id"] == "railiance.netkingdom"
assert body["import_run"]["actor_count"] == 3
assert body["import_run"]["fabric_count"] == 2
r = await client.post(
"/fabric/graph-exports",
json=_financial_fabric_graph_export(generated_at="2026-05-24T00:05:00Z"),
)
assert r.status_code == 200, r.text
second_body = r.json()
assert second_body["created"] is False
assert second_body["idempotent"] is True
assert second_body["import_run"]["id"] == body["import_run"]["id"]
r = await client.get("/fabric/graph/nodes?owner_role=tenant")
assert r.status_code == 200
tenant_nodes = r.json()
assert [node["graph_id"] for node in tenant_nodes] == ["coulomb.automation-client"]
assert tenant_nodes[0]["subfabric_id"] == "subfabric.railiance.tenant.coulomb"
assert tenant_nodes[0]["owner_actor_id"] == "actor.coulomb.tenant"
assert tenant_nodes[0]["ownership_resolution"] == "explicit"
assert tenant_nodes[0]["cost_center_id"] == "cc.coulomb.automation"
r = await client.get(
"/fabric/graph/edges"
"?relationship_category=utility"
"&consumer_owner_actor_id=actor.coulomb.tenant"
"&crosses_subfabric_boundary=true"
)
assert r.status_code == 200
utility_edges = r.json()
assert len(utility_edges) == 1
assert utility_edges[0]["utility_type"] == "coordination_api"
assert utility_edges[0]["utility_payment_schema_id"] == "payment.internal-tenant-access"
assert utility_edges[0]["provider_profit_center_id"] == "pc.tenant-utilities"
r = await client.get("/fabric/graph/summary")
assert r.status_code == 200
summary = r.json()
assert summary["schema_version"] == "financial-fabric-v1"
assert summary["nodes_by_fabric"]["fabric.railiance.primary"] == 2
assert summary["nodes_by_subfabric"]["subfabric.railiance.tenant.coulomb"] == 1
assert summary["nodes_by_owner_role"]["lord"] == 1
assert summary["nodes_by_owner_role"]["tenant"] == 1
assert summary["edges_by_relationship_category"]["utility"] == 1
assert summary["utility_edges_by_provider_owner"]["actor.railiance.primary-lord"] == 1
assert summary["utility_edges_by_consumer_owner"]["actor.coulomb.tenant"] == 1
assert summary["utility_edges_by_business_model"]["tenant_utility"] == 1
assert summary["tenant_utilities_without_payment_schema"] == 0
assert summary["unresolved_ownership_count"] == 0
async def test_financial_vnext_validation_rejects_unresolved_accepted_ownership(self, client):
payload = _financial_fabric_graph_export()
payload["nodes"][0]["ownership"]["resolution"] = "unresolved"
r = await client.post("/fabric/graph-exports", json=payload)
assert r.status_code == 422
body = r.json()["detail"]
assert body["validation_status"] == "invalid"
assert "accepted nodes" in body["message"]
r = await client.get("/fabric/graph/nodes")
assert r.status_code == 404
async def test_legacy_fabric_exports_remain_compatible_with_null_financial_fields(self, client):
r = await client.post("/fabric/graph-exports", json=_fabric_graph_export())
assert r.status_code == 200, r.text
assert r.json()["import_run"]["schema_version"] is None
r = await client.get("/fabric/graph/nodes?repo=state-hub&canonical_category=service")
assert r.status_code == 200
node = r.json()[0]
assert node["graph_id"] == "the-custodian.state-hub"
assert node["fabric_id"] is None
assert node["owner_actor_id"] is None
r = await client.get("/fabric/graph/summary")
assert r.status_code == 200
summary = r.json()
assert summary["schema_version"] is None
assert summary["nodes_by_fabric"] == {}