Receive source-owned depends_on edges through Forge projection (STATE-WP-0094-T05)
Derive frontmatter and task-block depends_on edges from the pinned commit and reconcile workplan_dependencies under an explicit ownership contract: only forge:<repo> rows are created or removed, external relations are preserved, missing/ambiguous/self targets are recorded and hold owned edges, failures roll back, and repeat receipts are noop. Additive migration b3c4d5e6f7a8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 671493@bnt-lap001 Assistant-Session: 60d7b11f-78f9-4731-9367-918511b2762d
This commit is contained in:
parent
b2af3c1c1d
commit
fceb5f27f0
6 changed files with 637 additions and 0 deletions
204
tests/test_forge_dependency_edges.py
Normal file
204
tests/test_forge_dependency_edges.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""Source-owned dependency edges through normal Forge receiving (STATE-WP-0094-T05).
|
||||
|
||||
Runs the real reconcile endpoint against the PostgreSQL test database, as the
|
||||
qualifier receiving tests do. Contract: docs/forge-dependency-edge-receiving.md.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services import forge_projection as fp
|
||||
from tests.conftest import create_test_domain, create_test_repo, create_test_workplan
|
||||
|
||||
TARGET = """---
|
||||
id: TGT-WP-0001
|
||||
type: workplan
|
||||
title: Target
|
||||
status: active
|
||||
---
|
||||
|
||||
## Target task
|
||||
|
||||
```task
|
||||
id: TGT-WP-0001-T01
|
||||
status: todo
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def _source(frontmatter_deps: str, wp2_deps: str = "", t02_target: str = "MISSING-WP-0009") -> str:
|
||||
return {
|
||||
"workplans/SRC-WP-0001.md": (
|
||||
"---\nid: SRC-WP-0001\ntype: workplan\ntitle: Source\nstatus: active\n"
|
||||
+ frontmatter_deps + "---\n\n## Wait\n\n```task\nid: SRC-WP-0001-T01\nstatus: wait\n"
|
||||
"depends_on: [SRC-WP-0002]\n```\n\n## Missing\n\n```task\nid: T02\nstatus: wait\n"
|
||||
f"depends_on: {t02_target}\n```\n\n## Closed\n\n```task\nid: SRC-WP-0001-T03\n"
|
||||
"status: done\ndepends_on: TGT-WP-0001\n```\n"
|
||||
),
|
||||
"workplans/SRC-WP-0002.md": (
|
||||
"---\nid: SRC-WP-0002\ntype: workplan\ntitle: Sibling\nstatus: active\n"
|
||||
+ wp2_deps + "---\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _write(root: Path, files: dict[str, str]) -> None:
|
||||
for rel, text in files.items():
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receiving_owns_only_source_edges(client, monkeypatch, tmp_path):
|
||||
await create_test_domain(client)
|
||||
await create_test_repo(client, slug="edge-target")
|
||||
await create_test_repo(client, slug="edge-source")
|
||||
from api.config import settings
|
||||
from api.routers import work_record_projection as route
|
||||
|
||||
monkeypatch.setattr(settings, "state_hub_instance_role", "primary")
|
||||
monkeypatch.setattr(settings, "state_hub_instance_label", "railiance01")
|
||||
|
||||
roots = {"edge-target": tmp_path / "target", "edge-source": tmp_path / "source"}
|
||||
revision = {"edge-target": "a" * 40, "edge-source": "b" * 40}
|
||||
_write(roots["edge-target"], {"workplans/TGT-WP-0001.md": TARGET})
|
||||
_write(roots["edge-source"], _source("depends_on: [TGT-WP-0001, TGT-WP-0001-T01]\n"))
|
||||
monkeypatch.setattr(
|
||||
route, "derive_from_forge",
|
||||
lambda slug: fp.derive_from_checkout(roots[slug], slug, revision[slug]),
|
||||
)
|
||||
|
||||
async def reconcile(slug, key):
|
||||
response = await client.post(
|
||||
f"/repos/{slug}/work-record-projection/reconcile",
|
||||
json={"expected_commit": revision[slug]}, headers={"Idempotency-Key": key},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["outcome"]
|
||||
|
||||
async def edges(record_id):
|
||||
wp_id = fp.derived_record_uuid(record_id)
|
||||
response = await client.get(f"/workplans/{wp_id}/dependencies/")
|
||||
assert response.status_code == 200, response.text
|
||||
return [row for row in response.json() if row["from_workplan_id"] == wp_id]
|
||||
|
||||
def counts(outcome):
|
||||
c = outcome["counts"]
|
||||
return (c["created_edges"], c["removed_edges"], c["unresolved_edges"], c["external_edges"])
|
||||
|
||||
await reconcile("edge-target", "edges:target")
|
||||
first = await reconcile("edge-source", "edges:first")
|
||||
assert first["status"] == "applied"
|
||||
# wp->wp, wp->task, task->sibling wp; T02 target missing; closed T03 not desired.
|
||||
assert counts(first) == (3, 0, 1, 0)
|
||||
assert first["unresolved_edges"][0] == {
|
||||
"workplan": "SRC-WP-0001", "from": "SRC-WP-0001-T02",
|
||||
"target": "MISSING-WP-0009", "reason": "missing",
|
||||
}
|
||||
rows = await edges("SRC-WP-0001")
|
||||
assert len(rows) == 3
|
||||
assert {r["source_owner"] for r in rows} == {"forge:edge-source"}
|
||||
assert {r["source_commit"] for r in rows} == {"b" * 40}
|
||||
assert {r["relationship_type"] for r in rows} == {"blocks", "starts_after"}
|
||||
assert sum(1 for r in rows if r["from_task_id"]) == 1
|
||||
|
||||
# Repeat of the same immutable commit is a noop.
|
||||
repeat = await reconcile("edge-source", "edges:repeat")
|
||||
assert repeat["status"] == "noop"
|
||||
assert counts(repeat) == (0, 0, 1, 0)
|
||||
|
||||
# Externally authored relations: one undeclared, one the source will declare.
|
||||
sibling = fp.derived_record_uuid("SRC-WP-0002")
|
||||
target_wp = fp.derived_record_uuid("TGT-WP-0001")
|
||||
other_repo = await create_test_repo(client, slug="edge-other")
|
||||
other = await create_test_workplan(client, other_repo["id"], slug="other-wp", title="Other")
|
||||
for to_id in (target_wp, other["id"]):
|
||||
r = await client.post(f"/workplans/{sibling}/dependencies/",
|
||||
json={"to_workplan_id": to_id, "relationship_type": "blocks"})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
# Source drops the task-target edge and declares the externally present one.
|
||||
_write(roots["edge-source"], _source(
|
||||
"depends_on: [TGT-WP-0001]\n", wp2_deps="depends_on: TGT-WP-0001\n"))
|
||||
revision["edge-source"] = "c" * 40
|
||||
second = await reconcile("edge-source", "edges:second")
|
||||
assert second["status"] == "applied"
|
||||
assert counts(second) == (0, 1, 1, 1)
|
||||
assert second["removed_edges"][0]["to_task_id"] is not None
|
||||
assert len(await edges("SRC-WP-0001")) == 2
|
||||
sibling_edges = await edges("SRC-WP-0002")
|
||||
assert len(sibling_edges) == 2 and all(r["source_owner"] is None for r in sibling_edges)
|
||||
assert (await reconcile("edge-source", "edges:second-repeat"))["status"] == "noop"
|
||||
|
||||
# Dropping the declaration of the external edge does not remove it.
|
||||
_write(roots["edge-source"], _source("depends_on: [TGT-WP-0001]\n"))
|
||||
revision["edge-source"] = "d" * 40
|
||||
third = await reconcile("edge-source", "edges:third")
|
||||
assert counts(third) == (0, 0, 1, 0)
|
||||
assert len(await edges("SRC-WP-0002")) == 2
|
||||
|
||||
# Ambiguous target: refuse to choose, and retain the from-side's owned edges.
|
||||
r = await client.post("/tasks/", json={"workplan_id": other["id"], "title": "Clash",
|
||||
"record_id": "TGT-WP-0001-T01"})
|
||||
assert r.status_code == 201, r.text
|
||||
clash_task = r.json()["id"]
|
||||
target_task = fp.derived_record_uuid("TGT-WP-0001-T01")
|
||||
_write(roots["edge-source"], _source("depends_on: [TGT-WP-0001-T01]\n", t02_target="SRC-WP-0002"))
|
||||
revision["edge-source"] = "e" * 40
|
||||
fourth = await reconcile("edge-source", "edges:fourth")
|
||||
assert counts(fourth) == (1, 0, 1, 0)
|
||||
ambiguous = fourth["unresolved_edges"][0]
|
||||
assert ambiguous["reason"] == "ambiguous" and ambiguous["target"] == "TGT-WP-0001-T01"
|
||||
assert set(ambiguous["candidates"]) == {target_task, clash_task}
|
||||
# The undeclared wp->TGT edge is held, not removed, while the side is unresolved.
|
||||
assert len(await edges("SRC-WP-0001")) == 3
|
||||
|
||||
# Transactional failure: an error mid-reconcile leaves nothing applied.
|
||||
files = _source("", t02_target="SRC-WP-0002")
|
||||
files["workplans/SRC-WP-0001.md"] = files["workplans/SRC-WP-0001.md"].replace(
|
||||
"title: Source\n", "title: Source renamed\n")
|
||||
_write(roots["edge-source"], files)
|
||||
revision["edge-source"] = "f" * 40
|
||||
calls = {"n": 0}
|
||||
real_flush_target = fp._resolve_edge_target
|
||||
|
||||
async def failing(session, record_id, cache):
|
||||
calls["n"] += 1
|
||||
raise RuntimeError("injected edge resolution failure")
|
||||
|
||||
monkeypatch.setattr(fp, "_resolve_edge_target", failing)
|
||||
with pytest.raises(RuntimeError):
|
||||
await client.post("/repos/edge-source/work-record-projection/reconcile",
|
||||
json={"expected_commit": revision["edge-source"]},
|
||||
headers={"Idempotency-Key": "edges:fail"})
|
||||
assert calls["n"] >= 1
|
||||
monkeypatch.setattr(fp, "_resolve_edge_target", real_flush_target)
|
||||
assert len(await edges("SRC-WP-0001")) == 3
|
||||
wp = (await client.get(f"/workplans/{fp.derived_record_uuid('SRC-WP-0001')}")).json()
|
||||
assert wp["title"] == "Source"
|
||||
|
||||
# The same commit then applies cleanly: frontmatter edge removed only.
|
||||
final = await reconcile("edge-source", "edges:final")
|
||||
assert counts(final) == (0, 1, 0, 0)
|
||||
assert "SRC-WP-0001" in final["updated"]
|
||||
assert len(await edges("SRC-WP-0001")) == 2
|
||||
assert (await reconcile("edge-source", "edges:final-repeat"))["status"] == "noop"
|
||||
|
||||
|
||||
def test_malformed_dependency_declaration_refuses_derivation(tmp_path):
|
||||
_write(tmp_path, {"workplans/SRC-WP-0001.md": (
|
||||
"---\nid: SRC-WP-0001\ntype: workplan\nstatus: active\ndepends_on: {a: b}\n---\n")})
|
||||
with pytest.raises(fp.ForgeDeriveError):
|
||||
fp.derive_from_checkout(tmp_path, "edge-source", "a" * 40)
|
||||
|
||||
|
||||
def test_dependency_targets_split_and_qualify(tmp_path):
|
||||
_write(tmp_path, _source("depends_on: [TGT-WP-0001, TGT-WP-0001-T01, T04]\n"
|
||||
"depends_on_workplans: TGT-WP-0001\n"))
|
||||
derived = fp.derive_from_checkout(tmp_path, "edge-source", "a" * 40)
|
||||
wp = {w.record_id: w for w in derived.workplans}["SRC-WP-0001"]
|
||||
assert wp.depends_on_workplans == ["TGT-WP-0001"]
|
||||
assert wp.depends_on_tasks == ["TGT-WP-0001-T01", "SRC-WP-0001-T04"]
|
||||
assert [t.depends_on for t in wp.tasks] == [["SRC-WP-0002"], ["MISSING-WP-0009"], ["TGT-WP-0001"]]
|
||||
assert derived.to_dict()["workplans"][0]["depends_on_tasks"] == ["TGT-WP-0001-T01", "SRC-WP-0001-T04"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue