Make project MCP workflows self-synchronizing
This commit is contained in:
parent
a30f021a52
commit
73165c9f51
17 changed files with 1124 additions and 56 deletions
|
|
@ -79,6 +79,73 @@ class ChangesetStore:
|
|||
self._write(path, document)
|
||||
return self._result(snapshot, document, valid=True)
|
||||
|
||||
def register(
|
||||
self,
|
||||
changeset_id: str,
|
||||
operations: list[dict[str, Any]],
|
||||
) -> dict[str, object]:
|
||||
"""Create and validate one complete proposal in a single atomic write."""
|
||||
|
||||
writer = self._require_writer()
|
||||
validate_id(changeset_id, "changeset_id")
|
||||
if not operations:
|
||||
raise DocForgeError(
|
||||
"empty_changeset",
|
||||
"Registered changes require at least one operation",
|
||||
)
|
||||
with self._lock():
|
||||
path = self._path(changeset_id)
|
||||
if path.exists():
|
||||
raise DocForgeError(
|
||||
"changeset_exists",
|
||||
"Changeset ID already exists",
|
||||
changeset_id=changeset_id,
|
||||
)
|
||||
existing = tuple(self._root().glob("*.json"))
|
||||
if len(existing) >= self.project.descriptor.limits.max_changesets:
|
||||
raise DocForgeError("changeset_limit", "Project changeset limit has been reached")
|
||||
if len(operations) > self.project.descriptor.limits.max_changeset_operations:
|
||||
raise DocForgeError(
|
||||
"changeset_operation_limit",
|
||||
"Changeset operation limit has been reached",
|
||||
)
|
||||
snapshot = self.project.load()
|
||||
nodes = {node.node_id: node for node in snapshot.nodes}
|
||||
normalized = [
|
||||
normalize_operation(
|
||||
self._complete_operation(operation, nodes),
|
||||
sequence=sequence,
|
||||
)
|
||||
for sequence, operation in enumerate(operations, start=1)
|
||||
]
|
||||
if len({item["node_id"] for item in normalized}) != len(normalized):
|
||||
raise DocForgeError(
|
||||
"duplicate_operation",
|
||||
"A changeset may touch a node only once",
|
||||
)
|
||||
document: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"changeset_id": changeset_id,
|
||||
"project_id": snapshot.descriptor.project_id,
|
||||
"root_fingerprint": project_root_fingerprint(snapshot.descriptor.root),
|
||||
"base_revision": snapshot.revision,
|
||||
"base_source_hash": snapshot.source_hash,
|
||||
"creator": writer.writer_id,
|
||||
"operations": normalized,
|
||||
}
|
||||
projected_nodes, projected_edges = self.projector.project(snapshot, document)
|
||||
self._check_proposal_conflicts(document, snapshot)
|
||||
self._write(path, document)
|
||||
return self._result(
|
||||
snapshot,
|
||||
document,
|
||||
valid=True,
|
||||
lifecycle="ready",
|
||||
ready_for_review=True,
|
||||
projected_node_count=len(projected_nodes),
|
||||
projected_edge_count=len(projected_edges),
|
||||
)
|
||||
|
||||
def propose_create(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -222,12 +289,27 @@ class ChangesetStore:
|
|||
projected_edge_count=len(edges),
|
||||
)
|
||||
|
||||
def list_changesets(self) -> dict[str, object]:
|
||||
def list_changesets(
|
||||
self,
|
||||
*,
|
||||
include_history: bool = True,
|
||||
status: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
with self._lock():
|
||||
snapshot = self.project.load()
|
||||
records: list[dict[str, object]] = []
|
||||
for path in sorted(self._root().glob("*.json"), key=lambda item: item.name):
|
||||
document = self._read(path)
|
||||
base_state = self._base_state(document, snapshot)
|
||||
lifecycle = self._lifecycle(document, base_state)
|
||||
if status is not None and lifecycle["status"] != status:
|
||||
continue
|
||||
if (
|
||||
status is None
|
||||
and not include_history
|
||||
and lifecycle["status"] in {"abandoned", "applied", "stale"}
|
||||
):
|
||||
continue
|
||||
records.append(
|
||||
{
|
||||
"changeset_id": document["changeset_id"],
|
||||
|
|
@ -235,7 +317,8 @@ class ChangesetStore:
|
|||
"creator": document["creator"],
|
||||
"base_revision": document["base_revision"],
|
||||
"base_source_hash": document["base_source_hash"],
|
||||
"base_state": self._base_state(document, snapshot),
|
||||
"base_state": base_state,
|
||||
"lifecycle": lifecycle,
|
||||
"operation_count": len(document["operations"]),
|
||||
}
|
||||
)
|
||||
|
|
@ -250,6 +333,101 @@ class ChangesetStore:
|
|||
snapshot,
|
||||
document,
|
||||
base_state=self._base_state(document, snapshot),
|
||||
lifecycle=self._lifecycle(
|
||||
document,
|
||||
self._base_state(document, snapshot),
|
||||
),
|
||||
)
|
||||
|
||||
def rebase(
|
||||
self,
|
||||
changeset_id: str,
|
||||
expected_changeset_hash: str,
|
||||
) -> dict[str, object]:
|
||||
"""Move a proposal to the current base when every touched fact is unchanged."""
|
||||
|
||||
validate_id(changeset_id, "changeset_id")
|
||||
validate_hash(expected_changeset_hash, "expected_changeset_hash")
|
||||
with self._lock():
|
||||
path = self._path(changeset_id)
|
||||
document = self._read(path)
|
||||
actual_hash = document_hash(document)
|
||||
if actual_hash != expected_changeset_hash:
|
||||
raise DocForgeError(
|
||||
"changeset_conflict",
|
||||
"Changeset changed after the caller read it",
|
||||
changeset_id=changeset_id,
|
||||
expected=expected_changeset_hash,
|
||||
actual=actual_hash,
|
||||
)
|
||||
snapshot = self.project.load()
|
||||
self._require_mutable(document, snapshot)
|
||||
if self._base_state(document, snapshot) == "current":
|
||||
return self._result(
|
||||
snapshot,
|
||||
document,
|
||||
valid=True,
|
||||
rebased=False,
|
||||
lifecycle=self._lifecycle(document, "current"),
|
||||
)
|
||||
candidate = {
|
||||
**document,
|
||||
"base_revision": snapshot.revision,
|
||||
"base_source_hash": snapshot.source_hash,
|
||||
}
|
||||
nodes, edges = self.projector.project(snapshot, candidate)
|
||||
self._check_proposal_conflicts(candidate, snapshot)
|
||||
self._write(path, candidate)
|
||||
return self._result(
|
||||
snapshot,
|
||||
candidate,
|
||||
valid=True,
|
||||
rebased=True,
|
||||
lifecycle="ready",
|
||||
projected_node_count=len(nodes),
|
||||
projected_edge_count=len(edges),
|
||||
)
|
||||
|
||||
def abandon(
|
||||
self,
|
||||
changeset_id: str,
|
||||
expected_changeset_hash: str,
|
||||
reason: str,
|
||||
) -> dict[str, object]:
|
||||
"""Mark one proposal as abandoned without deleting its audit record."""
|
||||
|
||||
validate_id(changeset_id, "changeset_id")
|
||||
validate_hash(expected_changeset_hash, "expected_changeset_hash")
|
||||
if not reason.strip():
|
||||
raise DocForgeError("invalid_operation", "Abandon reason must be non-empty")
|
||||
with self._lock():
|
||||
document = self._read(self._path(changeset_id))
|
||||
actual_hash = document_hash(document)
|
||||
if actual_hash != expected_changeset_hash:
|
||||
raise DocForgeError(
|
||||
"changeset_conflict",
|
||||
"Changeset changed after the caller read it",
|
||||
changeset_id=changeset_id,
|
||||
expected=expected_changeset_hash,
|
||||
actual=actual_hash,
|
||||
)
|
||||
snapshot = self.project.load()
|
||||
self._require_mutable(document, snapshot)
|
||||
receipt = self._write_state(
|
||||
changeset_id,
|
||||
{
|
||||
"status": "abandoned",
|
||||
"changeset_hash": actual_hash,
|
||||
"reason": reason.strip(),
|
||||
"revision": snapshot.revision,
|
||||
"source_hash": snapshot.source_hash,
|
||||
},
|
||||
)
|
||||
return self._result(
|
||||
snapshot,
|
||||
document,
|
||||
base_state=self._base_state(document, snapshot),
|
||||
lifecycle=receipt,
|
||||
)
|
||||
|
||||
def diff(self, changeset_id: str) -> dict[str, object]:
|
||||
|
|
@ -313,7 +491,12 @@ class ChangesetStore:
|
|||
"Canonical applier identity is not configured for this store",
|
||||
)
|
||||
with self._lock():
|
||||
snapshot, document, nodes, edges = self._validate_locked(changeset_id)
|
||||
document = self._read(self._path(changeset_id))
|
||||
snapshot = self.project.load()
|
||||
self._require_mutable(document, snapshot)
|
||||
self._check_base(document, snapshot)
|
||||
nodes, edges = self.projector.project(snapshot, document)
|
||||
self._check_proposal_conflicts(document, snapshot)
|
||||
actual_hash = document_hash(document)
|
||||
if actual_hash != expected_changeset_hash:
|
||||
raise DocForgeError(
|
||||
|
|
@ -344,11 +527,21 @@ class ChangesetStore:
|
|||
tuple(cast(Mapping[str, object], item) for item in document["operations"]),
|
||||
)
|
||||
current = self.project.load()
|
||||
lifecycle = self._write_state(
|
||||
changeset_id,
|
||||
{
|
||||
"status": "applied",
|
||||
"changeset_hash": actual_hash,
|
||||
"revision": current.revision,
|
||||
"source_hash": current.source_hash,
|
||||
},
|
||||
)
|
||||
return self._result(
|
||||
current,
|
||||
document,
|
||||
valid=True,
|
||||
applied=True,
|
||||
lifecycle=lifecycle,
|
||||
applied_from_revision=snapshot.revision,
|
||||
applied_from_source_hash=snapshot.source_hash,
|
||||
**payload,
|
||||
|
|
@ -383,6 +576,8 @@ class ChangesetStore:
|
|||
owner=document["creator"],
|
||||
writer=writer.writer_id,
|
||||
)
|
||||
snapshot = self.project.load()
|
||||
self._require_mutable(document, snapshot)
|
||||
if (
|
||||
len(document["operations"])
|
||||
>= self.project.descriptor.limits.max_changeset_operations
|
||||
|
|
@ -398,7 +593,6 @@ class ChangesetStore:
|
|||
node_id=normalized["node_id"],
|
||||
)
|
||||
candidate = {**document, "operations": [*document["operations"], normalized]}
|
||||
snapshot = self.project.load()
|
||||
self._check_base(candidate, snapshot)
|
||||
nodes, edges = self.projector.project(snapshot, candidate)
|
||||
self._check_proposal_conflicts(candidate, snapshot)
|
||||
|
|
@ -411,6 +605,51 @@ class ChangesetStore:
|
|||
projected_edge_count=len(edges),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _complete_operation(
|
||||
operation: dict[str, Any],
|
||||
nodes: dict[str, Node],
|
||||
) -> dict[str, Any]:
|
||||
allowed = {
|
||||
"operation",
|
||||
"node_id",
|
||||
"expected_content_hash",
|
||||
"target_source",
|
||||
"metadata",
|
||||
"content",
|
||||
"relationship_changes",
|
||||
"rationale",
|
||||
}
|
||||
unknown = sorted(set(operation) - allowed)
|
||||
if unknown:
|
||||
raise DocForgeError(
|
||||
"invalid_operation",
|
||||
"Operation has unknown fields",
|
||||
fields=unknown,
|
||||
)
|
||||
kind = operation.get("operation")
|
||||
node_id = operation.get("node_id")
|
||||
expected = operation.get("expected_content_hash")
|
||||
if kind != "create" and expected is None and isinstance(node_id, str):
|
||||
node = nodes.get(node_id)
|
||||
if node is None:
|
||||
raise DocForgeError(
|
||||
"missing_node",
|
||||
"No node has the requested stable ID",
|
||||
node_id=node_id,
|
||||
)
|
||||
expected = node.content_hash
|
||||
return {
|
||||
"operation": kind,
|
||||
"node_id": node_id,
|
||||
"expected_content_hash": expected,
|
||||
"target_source": operation.get("target_source"),
|
||||
"metadata": operation.get("metadata"),
|
||||
"content": operation.get("content"),
|
||||
"relationship_changes": operation.get("relationship_changes", []),
|
||||
"rationale": operation.get("rationale"),
|
||||
}
|
||||
|
||||
def _validate_locked(
|
||||
self, changeset_id: str
|
||||
) -> tuple[ProjectSnapshot, dict[str, Any], dict[str, Node], set[tuple[str, str, str]]]:
|
||||
|
|
@ -441,6 +680,57 @@ class ChangesetStore:
|
|||
return "current"
|
||||
return "stale"
|
||||
|
||||
def _lifecycle(
|
||||
self,
|
||||
document: dict[str, Any],
|
||||
base_state: str,
|
||||
) -> dict[str, object]:
|
||||
state_path = self._state_root() / f"{document['changeset_id']}.json"
|
||||
if state_path.is_file() and not state_path.is_symlink():
|
||||
try:
|
||||
parsed: object = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise DocForgeError(
|
||||
"invalid_changeset_state",
|
||||
"Changeset lifecycle record is unreadable",
|
||||
changeset_id=document["changeset_id"],
|
||||
) from error
|
||||
if not isinstance(parsed, dict):
|
||||
raise DocForgeError(
|
||||
"invalid_changeset_state",
|
||||
"Changeset lifecycle record does not match its proposal",
|
||||
changeset_id=document["changeset_id"],
|
||||
)
|
||||
payload = cast(dict[str, object], parsed)
|
||||
if payload.get("changeset_hash") != document_hash(document) or payload.get(
|
||||
"status"
|
||||
) not in {"applied", "abandoned"}:
|
||||
raise DocForgeError(
|
||||
"invalid_changeset_state",
|
||||
"Changeset lifecycle record does not match its proposal",
|
||||
changeset_id=document["changeset_id"],
|
||||
)
|
||||
return payload
|
||||
if base_state == "stale":
|
||||
return {"status": "stale"}
|
||||
if not document["operations"]:
|
||||
return {"status": "draft"}
|
||||
return {"status": "ready"}
|
||||
|
||||
def _require_mutable(
|
||||
self,
|
||||
document: dict[str, Any],
|
||||
snapshot: ProjectSnapshot,
|
||||
) -> None:
|
||||
lifecycle = self._lifecycle(document, self._base_state(document, snapshot))
|
||||
if lifecycle["status"] in {"applied", "abandoned"}:
|
||||
raise DocForgeError(
|
||||
"changeset_closed",
|
||||
"Applied or abandoned changesets cannot be modified",
|
||||
changeset_id=document["changeset_id"],
|
||||
lifecycle=lifecycle["status"],
|
||||
)
|
||||
|
||||
def _check_proposal_conflicts(
|
||||
self, document: dict[str, Any], snapshot: ProjectSnapshot
|
||||
) -> None:
|
||||
|
|
@ -452,6 +742,12 @@ class ChangesetStore:
|
|||
other = self._read(path)
|
||||
if other["base_source_hash"] != document["base_source_hash"]:
|
||||
continue
|
||||
other_lifecycle = self._lifecycle(
|
||||
other,
|
||||
self._base_state(other, snapshot),
|
||||
)
|
||||
if other_lifecycle["status"] in {"applied", "abandoned"}:
|
||||
continue
|
||||
other_nodes, other_sources = self.projector.touches(other, snapshot)
|
||||
shared_nodes = sorted(nodes & other_nodes)
|
||||
shared_sources = sorted(sources & other_sources)
|
||||
|
|
@ -605,6 +901,43 @@ class ChangesetStore:
|
|||
raise DocForgeError("path_escape", "Changeset root changed or resolves unexpectedly")
|
||||
return root
|
||||
|
||||
def _state_root(self) -> Path:
|
||||
root = self._root() / ".state"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
if (
|
||||
not root.is_dir()
|
||||
or root.is_symlink()
|
||||
or not root.resolve().is_relative_to(self._root())
|
||||
):
|
||||
raise DocForgeError("path_escape", "Changeset state root is not safe")
|
||||
return root
|
||||
|
||||
def _write_state(
|
||||
self,
|
||||
changeset_id: str,
|
||||
payload: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
root = self._state_root()
|
||||
path = root / f"{changeset_id}.json"
|
||||
raw = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=".state-", dir=root)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(raw)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
directory_descriptor = os.open(root, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_descriptor)
|
||||
finally:
|
||||
os.close(directory_descriptor)
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
return payload
|
||||
|
||||
@contextmanager
|
||||
def _lock(self) -> Generator[None]:
|
||||
root = self._root()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue