Make project MCP workflows self-synchronizing
This commit is contained in:
parent
a30f021a52
commit
73165c9f51
17 changed files with 1124 additions and 56 deletions
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections.abc import Mapping
|
||||
from contextlib import closing
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -350,6 +352,38 @@ class AdapterContractTests(unittest.TestCase):
|
|||
index.get_node("guide.workflow")["node"]["source_path"],
|
||||
)
|
||||
|
||||
def test_fast_incremental_reads_reverify_a_changed_index_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
loader = IncrementalLoader(root)
|
||||
project = AdapterProject(loader, cache_root=root / ".cache" / "incremental")
|
||||
index = ProjectIndex(project)
|
||||
index.build()
|
||||
index.synchronize()
|
||||
self.assertTrue(index.attestation_path.is_file())
|
||||
fresh = ProjectIndex(project)
|
||||
self.assertEqual(
|
||||
"current",
|
||||
fresh.synchronize()["synchronization"]["action"],
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(index.path)) as connection:
|
||||
connection.execute(
|
||||
"UPDATE nodes SET content = ? WHERE node_id = ?",
|
||||
("tampered", "guide.foundation"),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
with self.assertRaisesRegex(DocForgeError, "rows do not match metadata"):
|
||||
index.get_node("guide.foundation")
|
||||
|
||||
repaired = index.synchronize()
|
||||
self.assertEqual("rebuilt", repaired["synchronization"]["action"])
|
||||
self.assertEqual(
|
||||
"Foundation content.",
|
||||
index.get_node("guide.foundation")["node"]["content"],
|
||||
)
|
||||
|
||||
def test_incremental_delete_failure_and_equivalence_are_safe(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
|
|
|
|||
|
|
@ -244,6 +244,8 @@ class DocForgeChangesetTests(unittest.TestCase):
|
|||
node_ids = {node.node_id for node in snapshot.nodes}
|
||||
workflow = next(node for node in snapshot.nodes if node.node_id == "guide.workflow")
|
||||
self.assertTrue(result["applied"])
|
||||
self.assertEqual("applied", result["lifecycle"]["status"])
|
||||
self.assertEqual("ok", result["derived_refresh"]["status"])
|
||||
self.assertEqual(
|
||||
[
|
||||
"docs/content/applied.md",
|
||||
|
|
@ -265,8 +267,95 @@ class DocForgeChangesetTests(unittest.TestCase):
|
|||
self.assertTrue((root / ".docforge/cache/index.sqlite3").is_file())
|
||||
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
|
||||
|
||||
with self.assertRaisesRegex(DocForgeError, "Canonical project changed"):
|
||||
with self.assertRaisesRegex(DocForgeError, "cannot be modified") as closed:
|
||||
service.apply("apply-all", str(final["changeset_hash"]))
|
||||
self.assertEqual("changeset_closed", closed.exception.code)
|
||||
|
||||
def test_abandoned_proposal_releases_overlap_and_stale_work_remains_active(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
project = Project.open(root)
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
first = store.register(
|
||||
"first",
|
||||
[
|
||||
{
|
||||
"operation": "update",
|
||||
"node_id": "guide.foundation",
|
||||
"metadata": {"summary": "Abandoned proposal."},
|
||||
"rationale": "Reserve then release this node.",
|
||||
}
|
||||
],
|
||||
)
|
||||
store.abandon(
|
||||
"first",
|
||||
str(first["changeset_hash"]),
|
||||
"The proposal is no longer wanted.",
|
||||
)
|
||||
second = store.register(
|
||||
"second",
|
||||
[
|
||||
{
|
||||
"operation": "update",
|
||||
"node_id": "guide.foundation",
|
||||
"metadata": {"summary": "Replacement proposal."},
|
||||
"rationale": "Verify terminal proposals release conflicts.",
|
||||
}
|
||||
],
|
||||
)
|
||||
workflow = root / "docs/content/workflow.md"
|
||||
workflow.write_text(
|
||||
workflow.read_text(encoding="utf-8") + "\nUnrelated current fact.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
active = store.list_changesets(include_history=False)
|
||||
stale = store.list_changesets(include_history=False, status="stale")
|
||||
|
||||
self.assertEqual(0, active["count"])
|
||||
self.assertEqual(["second"], [item["changeset_id"] for item in stale["changesets"]])
|
||||
self.assertEqual("stale", stale["changesets"][0]["lifecycle"]["status"])
|
||||
self.assertEqual("ready", second["lifecycle"])
|
||||
|
||||
def test_applied_receipt_survives_a_derived_refresh_failure(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
project = Project.open(root)
|
||||
registered = ChangesetStore(project, "alpha-editor").register(
|
||||
"degraded-refresh",
|
||||
[
|
||||
{
|
||||
"operation": "update",
|
||||
"node_id": "guide.workflow",
|
||||
"metadata": {"summary": "Canonical even if refresh fails."},
|
||||
"rationale": "Separate canonical success from disposable refresh.",
|
||||
}
|
||||
],
|
||||
)
|
||||
service = CanonicalApplicationService(
|
||||
project,
|
||||
applier_id="alpha-editor",
|
||||
applier=GenericCanonicalApplier(project),
|
||||
)
|
||||
with mock.patch.object(
|
||||
service.index,
|
||||
"build",
|
||||
side_effect=DocForgeError("index_failure", "Synthetic derived failure"),
|
||||
):
|
||||
result = service.apply(
|
||||
"degraded-refresh",
|
||||
str(registered["changeset_hash"]),
|
||||
)
|
||||
|
||||
self.assertTrue(result["applied"])
|
||||
self.assertEqual("applied", result["lifecycle"]["status"])
|
||||
self.assertEqual("degraded", result["derived_refresh"]["status"])
|
||||
self.assertEqual("index", result["derived_refresh"]["errors"][0]["component"])
|
||||
with self.assertRaisesRegex(DocForgeError, "cannot be modified"):
|
||||
service.apply(
|
||||
"degraded-refresh",
|
||||
str(registered["changeset_hash"]),
|
||||
)
|
||||
|
||||
def test_relationship_only_update_is_hash_bound_and_does_not_rewrite_node(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ class DocForgeCliTests(unittest.TestCase):
|
|||
parser = _parser()
|
||||
reindexed = _run(parser.parse_args(["--project-root", str(root), "reindex"]))
|
||||
self.assertTrue(reindexed["reindexed"])
|
||||
synchronized = _run(parser.parse_args(["--project-root", str(root), "sync"]))
|
||||
self.assertEqual("current", synchronized["synchronization"]["action"])
|
||||
|
||||
project = Project.open(root)
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
names = tuple(tool.name for tool in response.tools)
|
||||
self.assertEqual(ALL_TOOLS, names)
|
||||
self.assertEqual(11, len(PROPOSAL_TOOLS))
|
||||
self.assertEqual(14, len(PROPOSAL_TOOLS))
|
||||
self.assertFalse(
|
||||
any(
|
||||
token in name
|
||||
|
|
@ -98,6 +98,8 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
("docforge_visualize", {"node_id": "guide.workflow", "depth": 1}),
|
||||
("docforge_stop_visualization", {}),
|
||||
("docforge_visualization_status", {}),
|
||||
("docforge_bootstrap", {}),
|
||||
("docforge_sync", {}),
|
||||
)
|
||||
with self.running_manager(Path(directory) / "viewer-manager.json"):
|
||||
service = DocForgeService(Project.open(root))
|
||||
|
|
@ -143,7 +145,113 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertLessEqual(context["estimated_tokens"], 180)
|
||||
self.assertTrue(context["omissions"])
|
||||
|
||||
async def test_missing_node_and_stale_index_are_structured_failures(self) -> None:
|
||||
async def test_sync_register_rebase_apply_and_lifecycle_are_one_bound_workflow(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(
|
||||
root,
|
||||
"alpha-editor",
|
||||
canonical_applier_id="alpha-editor",
|
||||
),
|
||||
raise_exceptions=True,
|
||||
) as session:
|
||||
bootstrap = await session.call_tool("docforge_bootstrap", {})
|
||||
self.assertEqual("current", bootstrap.structuredContent["staleness"])
|
||||
self.assertEqual(
|
||||
str(root),
|
||||
bootstrap.structuredContent["binding"]["project_root"],
|
||||
)
|
||||
|
||||
proof = root / "docs/content/proof.toml"
|
||||
proof.write_text(
|
||||
proof.read_text(encoding="utf-8") + "\n# Current validation evidence.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
synchronized = await session.call_tool("docforge_sync", {})
|
||||
self.assertEqual(
|
||||
"rebuilt",
|
||||
synchronized.structuredContent["synchronization"]["action"],
|
||||
)
|
||||
|
||||
registered = await session.call_tool(
|
||||
"docforge_register_changes",
|
||||
{
|
||||
"changeset_id": "bound-workflow",
|
||||
"operations": [
|
||||
{
|
||||
"operation": "update",
|
||||
"node_id": "guide.workflow",
|
||||
"metadata": {
|
||||
"summary": "Registered and applied in one bound workflow."
|
||||
},
|
||||
"rationale": "Verify atomic registration without caller hashes.",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertTrue(registered.structuredContent["ready_for_review"])
|
||||
self.assertEqual("ready", registered.structuredContent["lifecycle"])
|
||||
|
||||
foundation = root / "docs/content/foundation.md"
|
||||
foundation.write_text(
|
||||
foundation.read_text(encoding="utf-8") + "\nUnrelated current fact.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
rebased = await session.call_tool(
|
||||
"docforge_rebase_changeset",
|
||||
{
|
||||
"changeset_id": "bound-workflow",
|
||||
"expected_changeset_hash": registered.structuredContent["changeset_hash"],
|
||||
},
|
||||
)
|
||||
self.assertTrue(rebased.structuredContent["rebased"])
|
||||
|
||||
difference = await session.call_tool(
|
||||
"docforge_get_changeset_diff",
|
||||
{"changeset_id": "bound-workflow"},
|
||||
)
|
||||
self.assertEqual("ok", difference.structuredContent["status"])
|
||||
applied = await session.call_tool(
|
||||
"docforge_apply_changeset",
|
||||
{
|
||||
"changeset_id": "bound-workflow",
|
||||
"expected_changeset_hash": rebased.structuredContent["changeset_hash"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
"applied",
|
||||
applied.structuredContent["lifecycle"]["status"],
|
||||
)
|
||||
closed = await session.call_tool(
|
||||
"docforge_rebase_changeset",
|
||||
{
|
||||
"changeset_id": "bound-workflow",
|
||||
"expected_changeset_hash": rebased.structuredContent["changeset_hash"],
|
||||
},
|
||||
)
|
||||
active = await session.call_tool("docforge_list_changesets", {})
|
||||
history = await session.call_tool(
|
||||
"docforge_list_changesets",
|
||||
{"include_history": True, "status": "applied"},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
"changeset_closed",
|
||||
closed.structuredContent["error"]["code"],
|
||||
)
|
||||
self.assertEqual(0, active.structuredContent["count"])
|
||||
self.assertEqual(1, history.structuredContent["count"])
|
||||
self.assertEqual(
|
||||
"applied",
|
||||
history.structuredContent["changesets"][0]["lifecycle"]["status"],
|
||||
)
|
||||
|
||||
async def test_missing_node_fails_and_stale_index_self_heals(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
ProjectIndex(Project.open(root)).build()
|
||||
|
|
@ -159,16 +267,22 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
workflow.read_text(encoding="utf-8") + "\nChanged after startup.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
stale = await session.call_tool("docforge_get_node", {"node_id": "guide.workflow"})
|
||||
repaired = await session.call_tool(
|
||||
"docforge_get_node", {"node_id": "guide.workflow"}
|
||||
)
|
||||
|
||||
self.assertEqual("missing_node", missing.structuredContent["error"]["code"])
|
||||
self.assertEqual("stale_index", stale.structuredContent["error"]["code"])
|
||||
self.assertEqual("ok", repaired.structuredContent["status"])
|
||||
self.assertEqual("current", missing.structuredContent["staleness"])
|
||||
self.assertEqual("stale", stale.structuredContent["staleness"])
|
||||
self.assertEqual("current", repaired.structuredContent["staleness"])
|
||||
self.assertTrue(missing.structuredContent["source_hash"])
|
||||
self.assertTrue(stale.structuredContent["source_hash"])
|
||||
self.assertTrue(repaired.structuredContent["source_hash"])
|
||||
self.assertEqual(
|
||||
"rebuilt",
|
||||
repaired.structuredContent["synchronization"]["action"],
|
||||
)
|
||||
self.assertFalse(missing.isError)
|
||||
self.assertFalse(stale.isError)
|
||||
self.assertFalse(repaired.isError)
|
||||
|
||||
async def test_output_limit_fails_without_returning_partial_content(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue