Add gated changeset application and graph controls
This commit is contained in:
parent
3c15e26283
commit
78335c8973
20 changed files with 1813 additions and 453 deletions
|
|
@ -10,6 +10,7 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
from docforge.application import CanonicalApplicationService, GenericCanonicalApplier
|
||||
from docforge.changesets import ChangesetStore
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.project import Project
|
||||
|
|
@ -182,6 +183,91 @@ class DocForgeChangesetTests(unittest.TestCase):
|
|||
self.assertEqual("delete", delete_store.diff("delete-node")["changes"][0]["operation"])
|
||||
self.assertEqual(delete_before, self.canonical_digest(delete_root))
|
||||
|
||||
def test_hash_bound_application_writes_projection_and_refreshes_derived_state(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
project = Project.open(root)
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
hashes = {node.node_id: node.content_hash for node in project.load().nodes}
|
||||
created = store.create("apply-all")
|
||||
updated = store.propose_update(
|
||||
changeset_id="apply-all",
|
||||
expected_changeset_hash=created["changeset_hash"],
|
||||
node_id="guide.workflow",
|
||||
expected_content_hash=hashes["guide.workflow"],
|
||||
metadata={"summary": "Applied through the canonical application service."},
|
||||
content="The applied workflow is now canonical.",
|
||||
relationship_changes=[],
|
||||
rationale="Exercise a canonical update.",
|
||||
)
|
||||
added = store.propose_create(
|
||||
changeset_id="apply-all",
|
||||
expected_changeset_hash=updated["changeset_hash"],
|
||||
node_id="guide.applied",
|
||||
target_source="docs/content/applied.md",
|
||||
metadata=self.new_metadata(),
|
||||
content="This node was created by an approved changeset.",
|
||||
relationship_changes=[],
|
||||
rationale="Exercise a canonical creation.",
|
||||
)
|
||||
moved = store.propose_move(
|
||||
changeset_id="apply-all",
|
||||
expected_changeset_hash=added["changeset_hash"],
|
||||
node_id="guide.foundation",
|
||||
expected_content_hash=hashes["guide.foundation"],
|
||||
target_source="docs/content/foundation-moved.md",
|
||||
rationale="Exercise a canonical move.",
|
||||
)
|
||||
final = store.propose_delete(
|
||||
changeset_id="apply-all",
|
||||
expected_changeset_hash=moved["changeset_hash"],
|
||||
node_id="proof.validation",
|
||||
expected_content_hash=hashes["proof.validation"],
|
||||
relationship_changes=[
|
||||
{
|
||||
"action": "remove",
|
||||
"source_id": "proof.validation",
|
||||
"relation": "proves",
|
||||
"target_id": "guide.workflow",
|
||||
}
|
||||
],
|
||||
rationale="Exercise a canonical deletion.",
|
||||
)
|
||||
service = CanonicalApplicationService(
|
||||
project,
|
||||
applier_id="alpha-editor",
|
||||
applier=GenericCanonicalApplier(project),
|
||||
)
|
||||
result = service.apply("apply-all", str(final["changeset_hash"]))
|
||||
|
||||
snapshot = project.load()
|
||||
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(
|
||||
[
|
||||
"docs/content/applied.md",
|
||||
"docs/content/foundation-moved.md",
|
||||
"docs/content/foundation.md",
|
||||
"docs/content/proof.toml",
|
||||
"docs/content/workflow.md",
|
||||
],
|
||||
result["applied_sources"],
|
||||
)
|
||||
self.assertEqual({"guide.applied", "guide.foundation", "guide.workflow"}, node_ids)
|
||||
self.assertEqual(
|
||||
"Applied through the canonical application service.",
|
||||
workflow.summary,
|
||||
)
|
||||
self.assertFalse((root / "docs/content/foundation.md").exists())
|
||||
self.assertFalse((root / "docs/content/proof.toml").exists())
|
||||
self.assertTrue((root / "docs/content/foundation-moved.md").is_file())
|
||||
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"):
|
||||
service.apply("apply-all", str(final["changeset_hash"]))
|
||||
|
||||
def test_optimistic_and_cross_changeset_conflicts_preserve_both_proposals(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
|
|
|
|||
107
tests/test_cli.py
Normal file
107
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from docforge.changesets import ChangesetStore
|
||||
from docforge.cli import _parser, _run
|
||||
from docforge.project import Project
|
||||
from docforge.viewer_manager import ViewerManager
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
|
||||
class DocForgeCliTests(unittest.TestCase):
|
||||
def copy_fixture(self, destination: Path) -> Path:
|
||||
root = destination / "alpha"
|
||||
shutil.copytree(FIXTURES / "alpha", root)
|
||||
return root
|
||||
|
||||
def test_reindex_apply_and_visualization_commands_are_self_service(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory)
|
||||
root = self.copy_fixture(parent)
|
||||
parser = _parser()
|
||||
reindexed = _run(parser.parse_args(["--project-root", str(root), "reindex"]))
|
||||
self.assertTrue(reindexed["reindexed"])
|
||||
|
||||
project = Project.open(root)
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
node = next(node for node in project.load().nodes if node.node_id == "guide.workflow")
|
||||
created = store.create("cli-apply")
|
||||
proposed = store.propose_update(
|
||||
changeset_id="cli-apply",
|
||||
expected_changeset_hash=str(created["changeset_hash"]),
|
||||
node_id=node.node_id,
|
||||
expected_content_hash=node.content_hash,
|
||||
metadata={"summary": "Applied through the CLI."},
|
||||
content=None,
|
||||
relationship_changes=[],
|
||||
rationale="Verify the direct application command.",
|
||||
)
|
||||
applied = _run(
|
||||
parser.parse_args(
|
||||
[
|
||||
"--project-root",
|
||||
str(root),
|
||||
"apply",
|
||||
"cli-apply",
|
||||
"--changeset-hash",
|
||||
str(proposed["changeset_hash"]),
|
||||
"--applier",
|
||||
"alpha-editor",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.assertTrue(applied["applied"])
|
||||
|
||||
state_path = parent / "viewer-manager.json"
|
||||
manager = ViewerManager(state_path, check_interval_seconds=0.02)
|
||||
thread = threading.Thread(target=manager.serve_forever, daemon=True)
|
||||
previous = os.environ.get("DOCFORGE_VIEWER_MANAGER_STATE")
|
||||
os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = str(state_path)
|
||||
thread.start()
|
||||
deadline = time.monotonic() + 2
|
||||
while not state_path.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
try:
|
||||
with mock.patch("docforge.cli.webbrowser.open", return_value=True) as opened:
|
||||
visualized = _run(
|
||||
parser.parse_args(
|
||||
[
|
||||
"--project-root",
|
||||
str(root),
|
||||
"visualize",
|
||||
"--node",
|
||||
"guide.workflow",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.assertTrue(visualized["opened_browser"])
|
||||
opened.assert_called_once()
|
||||
status = _run(
|
||||
parser.parse_args(["--project-root", str(root), "visualization-status"])
|
||||
)
|
||||
self.assertEqual("running", status["state"])
|
||||
stopped = _run(
|
||||
parser.parse_args(["--project-root", str(root), "visualization-stop"])
|
||||
)
|
||||
self.assertEqual("stopped", stopped["state"])
|
||||
finally:
|
||||
manager.shutdown()
|
||||
thread.join(timeout=2)
|
||||
if previous is None:
|
||||
os.environ.pop("DOCFORGE_VIEWER_MANAGER_STATE", None)
|
||||
else:
|
||||
os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = previous
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -17,6 +17,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
|
|||
from docforge.index import ProjectIndex
|
||||
from docforge.mcp_server import (
|
||||
ALL_TOOLS,
|
||||
APPLICATION_TOOLS,
|
||||
CONTENT_WARNING,
|
||||
PROPOSAL_TOOLS,
|
||||
DocForgeService,
|
||||
|
|
@ -130,7 +131,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
visualization = results[11].structuredContent["visualization"]
|
||||
self.assertTrue(visualization["read_only"])
|
||||
self.assertTrue(visualization["project_bound"])
|
||||
self.assertEqual("graph-browser@11", visualization["template"])
|
||||
self.assertEqual("graph-browser@12", visualization["template"])
|
||||
self.assertEqual("managed_idle", visualization["lifetime"]["policy"])
|
||||
self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"])
|
||||
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
|
||||
|
|
@ -343,6 +344,73 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual("proposal_access_disabled", result.structuredContent["error"]["code"])
|
||||
self.assertFalse((root / ".docforge/changesets/disabled.json").exists())
|
||||
|
||||
async def test_canonical_apply_tool_is_opt_in_hash_bound_and_refreshes_outputs(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
node_hash = next(
|
||||
node.content_hash
|
||||
for node in project.load().nodes
|
||||
if node.node_id == "guide.workflow"
|
||||
)
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(
|
||||
root,
|
||||
"alpha-editor",
|
||||
canonical_applier_id="alpha-editor",
|
||||
),
|
||||
raise_exceptions=True,
|
||||
) as session:
|
||||
names = tuple(tool.name for tool in (await session.list_tools()).tools)
|
||||
contract = await session.call_tool("docforge_get_contract", {})
|
||||
created = await session.call_tool(
|
||||
"docforge_create_changeset",
|
||||
{"changeset_id": "mcp-apply"},
|
||||
)
|
||||
updated = await session.call_tool(
|
||||
"docforge_propose_node_update",
|
||||
{
|
||||
"changeset_id": "mcp-apply",
|
||||
"expected_changeset_hash": created.structuredContent["changeset_hash"],
|
||||
"node_id": "guide.workflow",
|
||||
"expected_content_hash": node_hash,
|
||||
"metadata": {"summary": "Applied through the gated MCP tool."},
|
||||
"content": None,
|
||||
"relationship_changes": [],
|
||||
"rationale": "Verify canonical MCP application.",
|
||||
},
|
||||
)
|
||||
wrong = await session.call_tool(
|
||||
"docforge_apply_changeset",
|
||||
{
|
||||
"changeset_id": "mcp-apply",
|
||||
"expected_changeset_hash": "0" * 64,
|
||||
},
|
||||
)
|
||||
applied = await session.call_tool(
|
||||
"docforge_apply_changeset",
|
||||
{
|
||||
"changeset_id": "mcp-apply",
|
||||
"expected_changeset_hash": updated.structuredContent["changeset_hash"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual((*ALL_TOOLS, *APPLICATION_TOOLS), names)
|
||||
self.assertTrue(contract.structuredContent["canonical_writes_allowed"])
|
||||
self.assertTrue(contract.structuredContent["canonical_application_access"]["enabled"])
|
||||
self.assertNotIn(
|
||||
"canonical_changeset_application",
|
||||
contract.structuredContent["excluded_operations"],
|
||||
)
|
||||
self.assertEqual("changeset_conflict", wrong.structuredContent["error"]["code"])
|
||||
self.assertTrue(applied.structuredContent["applied"])
|
||||
workflow = next(
|
||||
node for node in project.load().nodes if node.node_id == "guide.workflow"
|
||||
)
|
||||
self.assertEqual("Applied through the gated MCP tool.", workflow.summary)
|
||||
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
|
||||
|
||||
async def test_stdio_transport_serves_the_same_project_bound_contract(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("beta", Path(directory))
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ class VisualizationTests(unittest.TestCase):
|
|||
first = snapshot.node("guide.workflow", depth=2, limit=2)
|
||||
second = snapshot.node("guide.workflow", depth=2, limit=2)
|
||||
filtered = snapshot.filter_nodes(category="tag", value="canonical", limit=2)
|
||||
source = snapshot.source("guide.workflow")
|
||||
|
||||
self.assertEqual(3, overview["node_count"])
|
||||
self.assertEqual(2, overview["edge_count"])
|
||||
|
|
@ -87,6 +88,8 @@ class VisualizationTests(unittest.TestCase):
|
|||
self.assertFalse(filtered["truncated"])
|
||||
self.assertEqual("guide.foundation", filtered["results"][0]["node_id"])
|
||||
self.assertLessEqual(len(first["edges"]), 2)
|
||||
self.assertEqual("docs/content/workflow.md", source["source_path"])
|
||||
self.assertIn("Editors change canonical nodes", source["content"])
|
||||
self.assertIn(
|
||||
"guide.workflow",
|
||||
{node["node_id"] for node in first["nodes"]},
|
||||
|
|
@ -112,6 +115,14 @@ class VisualizationTests(unittest.TestCase):
|
|||
self.assertEqual("", result.stderr)
|
||||
self.assertEqual(0, result.returncode)
|
||||
|
||||
def test_browser_contains_hiding_source_navigation_and_scrollable_inspector(self) -> None:
|
||||
self.assertIn('id="restore-hidden"', _GRAPH_BROWSER_HTML)
|
||||
self.assertIn('id="open-node-source"', _GRAPH_BROWSER_HTML)
|
||||
self.assertIn('id="hide-node"', _GRAPH_BROWSER_HTML)
|
||||
self.assertIn('id="source-dialog"', _GRAPH_BROWSER_HTML)
|
||||
self.assertIn("state.hiddenNodes.add(nodeId)", _GRAPH_BROWSER_HTML)
|
||||
self.assertIn("grid-template-rows: auto minmax(0, 1fr) auto", _GRAPH_BROWSER_HTML)
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node.js is required for topology validation")
|
||||
def test_embedded_topology_roles_hops_and_shading_are_deterministic(self) -> None:
|
||||
script = _GRAPH_BROWSER_HTML.split("<script>", 1)[1].split("</script>", 1)[0]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue