464 lines
18 KiB
Python
464 lines
18 KiB
Python
"""Fail-closed canonical changeset application and derived-state refresh."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from collections import defaultdict
|
|
from collections.abc import Mapping, Sequence
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
from typing import Protocol, cast
|
|
|
|
from .changesets import ChangesetStore
|
|
from .errors import DocForgeError
|
|
from .index import ProjectIndex
|
|
from .models import Node, ProjectService, ProjectSnapshot
|
|
from .projection_policy import ManualProjectionMode, validate_manual_projection_mode
|
|
from .rendering import RenderService
|
|
|
|
|
|
class CanonicalApplier(Protocol):
|
|
"""Project-owned serializer for one already validated proposal projection."""
|
|
|
|
def apply(
|
|
self,
|
|
base: ProjectSnapshot,
|
|
projected: ProjectSnapshot,
|
|
operations: tuple[Mapping[str, object], ...],
|
|
) -> dict[str, object]: ...
|
|
|
|
|
|
class GenericCanonicalApplier:
|
|
"""Apply generic Markdown/TOML projections inside declared content roots."""
|
|
|
|
def __init__(self, project: ProjectService) -> None:
|
|
self.project = project
|
|
|
|
def apply(
|
|
self,
|
|
base: ProjectSnapshot,
|
|
projected: ProjectSnapshot,
|
|
operations: tuple[Mapping[str, object], ...],
|
|
) -> dict[str, object]:
|
|
del operations
|
|
changed_sources = self._changed_sources(base, projected)
|
|
if not changed_sources:
|
|
raise DocForgeError("empty_changeset", "Changeset produces no canonical changes")
|
|
projected_by_source = self._nodes_by_source(projected)
|
|
staged: dict[Path, Path] = {}
|
|
previous: dict[Path, bytes | None] = {}
|
|
created_directories: list[Path] = []
|
|
targets = {relative: self._target(relative) for relative in sorted(changed_sources)}
|
|
try:
|
|
for relative, target in targets.items():
|
|
previous[target] = target.read_bytes() if target.is_file() else None
|
|
nodes = projected_by_source.get(relative, ())
|
|
if not nodes:
|
|
continue
|
|
self._prepare_parent(target.parent, created_directories)
|
|
raw = self._serialize_source(projected, relative, nodes)
|
|
if len(raw) > base.descriptor.limits.max_source_bytes:
|
|
raise DocForgeError(
|
|
"source_too_large",
|
|
"Applied canonical source exceeds the configured limit",
|
|
source=relative,
|
|
)
|
|
descriptor, temporary_name = tempfile.mkstemp(
|
|
prefix=".docforge-apply-",
|
|
dir=target.parent,
|
|
)
|
|
temporary = Path(temporary_name)
|
|
with os.fdopen(descriptor, "wb") as handle:
|
|
handle.write(raw)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
staged[target] = temporary
|
|
|
|
current = self.project.load()
|
|
if current.source_hash != base.source_hash or current.revision != base.revision:
|
|
raise DocForgeError(
|
|
"base_conflict",
|
|
"Canonical project changed while the changeset was being staged",
|
|
expected_revision=base.revision,
|
|
actual_revision=current.revision,
|
|
expected_source_hash=base.source_hash,
|
|
actual_source_hash=current.source_hash,
|
|
)
|
|
for target in targets.values():
|
|
if target.is_symlink():
|
|
raise DocForgeError("path_escape", "Canonical target became a symbolic link")
|
|
for target in sorted(targets.values(), key=str):
|
|
temporary = staged.get(target)
|
|
if temporary is None:
|
|
target.unlink(missing_ok=True)
|
|
else:
|
|
os.replace(temporary, target)
|
|
self._fsync_directory(target.parent)
|
|
|
|
applied = self.project.load()
|
|
if self._semantic_snapshot(applied) != self._semantic_snapshot(projected):
|
|
raise DocForgeError(
|
|
"application_mismatch",
|
|
"Applied canonical files do not reproduce the validated proposal",
|
|
)
|
|
except Exception:
|
|
for temporary in staged.values():
|
|
temporary.unlink(missing_ok=True)
|
|
self._restore(previous)
|
|
self._remove_empty_directories(created_directories)
|
|
raise
|
|
return {
|
|
"applied_sources": sorted(changed_sources),
|
|
"removed_sources": sorted(
|
|
source for source in changed_sources if source not in projected_by_source
|
|
),
|
|
}
|
|
|
|
def _target(self, relative: str) -> Path:
|
|
candidate = Path(relative)
|
|
if candidate.is_absolute() or ".." in candidate.parts:
|
|
raise DocForgeError("path_escape", "Canonical target path is unsafe", source=relative)
|
|
root = self.project.descriptor.root
|
|
target = (root / candidate).resolve(strict=False)
|
|
if (
|
|
not target.is_relative_to(root)
|
|
or not any(
|
|
target.is_relative_to(item) for item in self.project.descriptor.content_roots
|
|
)
|
|
or target.suffix not in {".md", ".toml"}
|
|
):
|
|
raise DocForgeError(
|
|
"path_escape",
|
|
"Canonical target is outside a declared content root",
|
|
source=relative,
|
|
)
|
|
if target.exists() and (target.is_symlink() or not target.is_file()):
|
|
raise DocForgeError("path_escape", "Canonical target is not a regular file")
|
|
return target
|
|
|
|
def _prepare_parent(self, parent: Path, created: list[Path]) -> None:
|
|
root = self.project.descriptor.root
|
|
missing: list[Path] = []
|
|
cursor = parent
|
|
while not cursor.exists():
|
|
missing.append(cursor)
|
|
cursor = cursor.parent
|
|
if cursor.is_symlink() or cursor.resolve(strict=True) != cursor or not cursor.is_dir():
|
|
raise DocForgeError("path_escape", "Canonical target parent is unsafe")
|
|
if not cursor.is_relative_to(root):
|
|
raise DocForgeError("path_escape", "Canonical target parent escaped the project root")
|
|
parent.mkdir(parents=True, exist_ok=True)
|
|
if parent.resolve(strict=True) != parent:
|
|
raise DocForgeError("path_escape", "Canonical target parent resolves unexpectedly")
|
|
created.extend(reversed(missing))
|
|
|
|
@staticmethod
|
|
def _fsync_directory(path: Path) -> None:
|
|
descriptor = os.open(path, os.O_RDONLY)
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
def _restore(self, previous: dict[Path, bytes | None]) -> None:
|
|
for target in sorted(previous, key=str):
|
|
raw = previous[target]
|
|
if raw is None:
|
|
target.unlink(missing_ok=True)
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor, temporary_name = tempfile.mkstemp(
|
|
prefix=".docforge-rollback-",
|
|
dir=target.parent,
|
|
)
|
|
temporary = Path(temporary_name)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as handle:
|
|
handle.write(raw)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary, target)
|
|
self._fsync_directory(target.parent)
|
|
except Exception:
|
|
temporary.unlink(missing_ok=True)
|
|
raise
|
|
|
|
@staticmethod
|
|
def _remove_empty_directories(paths: Sequence[Path]) -> None:
|
|
for path in reversed(paths):
|
|
with suppress(OSError):
|
|
path.rmdir()
|
|
|
|
@staticmethod
|
|
def _nodes_by_source(snapshot: ProjectSnapshot) -> dict[str, tuple[Node, ...]]:
|
|
grouped: dict[str, list[Node]] = defaultdict(list)
|
|
for node in snapshot.nodes:
|
|
grouped[node.source_path].append(node)
|
|
return {
|
|
source: tuple(sorted(nodes, key=lambda node: node.node_id))
|
|
for source, nodes in grouped.items()
|
|
}
|
|
|
|
def _changed_sources(
|
|
self,
|
|
base: ProjectSnapshot,
|
|
projected: ProjectSnapshot,
|
|
) -> set[str]:
|
|
base_sources = self._source_signatures(base)
|
|
projected_sources = self._source_signatures(projected)
|
|
return {
|
|
source
|
|
for source in set(base_sources) | set(projected_sources)
|
|
if base_sources.get(source) != projected_sources.get(source)
|
|
}
|
|
|
|
@staticmethod
|
|
def _source_signatures(snapshot: ProjectSnapshot) -> dict[str, object]:
|
|
outgoing: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
|
for edge in snapshot.edges:
|
|
outgoing[edge.source_id].append((edge.relation, edge.target_id))
|
|
signatures: dict[str, list[object]] = defaultdict(list)
|
|
for node in snapshot.nodes:
|
|
signatures[node.source_path].append(
|
|
(
|
|
node.node_id,
|
|
node.title,
|
|
node.family,
|
|
node.authority,
|
|
node.status,
|
|
node.tags,
|
|
node.summary,
|
|
node.content,
|
|
node.source_anchor,
|
|
tuple(sorted(outgoing[node.node_id])),
|
|
)
|
|
)
|
|
return {source: tuple(items) for source, items in signatures.items()}
|
|
|
|
@classmethod
|
|
def _semantic_snapshot(cls, snapshot: ProjectSnapshot) -> tuple[object, object]:
|
|
nodes = tuple(
|
|
(
|
|
node.node_id,
|
|
node.title,
|
|
node.family,
|
|
node.authority,
|
|
node.status,
|
|
node.tags,
|
|
node.summary,
|
|
node.content,
|
|
node.source_path,
|
|
node.source_anchor,
|
|
)
|
|
for node in snapshot.nodes
|
|
)
|
|
edges = tuple((edge.source_id, edge.relation, edge.target_id) for edge in snapshot.edges)
|
|
return nodes, edges
|
|
|
|
def _serialize_source(
|
|
self,
|
|
snapshot: ProjectSnapshot,
|
|
relative: str,
|
|
nodes: tuple[Node, ...],
|
|
) -> bytes:
|
|
if Path(relative).suffix == ".md":
|
|
if len(nodes) != 1:
|
|
raise DocForgeError(
|
|
"source_conflict",
|
|
"Markdown canonical sources may contain only one node",
|
|
source=relative,
|
|
)
|
|
node = nodes[0]
|
|
metadata = self._record(snapshot, node, include_content=False)
|
|
lines = ["+++", *self._toml_record(metadata), "+++", "", node.content.strip(), ""]
|
|
return "\n".join(lines).encode("utf-8")
|
|
lines: list[str] = []
|
|
for index, node in enumerate(nodes):
|
|
if index:
|
|
lines.append("")
|
|
lines.append("[[nodes]]")
|
|
lines.extend(self._toml_record(self._record(snapshot, node, include_content=True)))
|
|
lines.append("")
|
|
return "\n".join(lines).encode("utf-8")
|
|
|
|
@staticmethod
|
|
def _record(
|
|
snapshot: ProjectSnapshot,
|
|
node: Node,
|
|
*,
|
|
include_content: bool,
|
|
) -> dict[str, object]:
|
|
record: dict[str, object] = {
|
|
"schema_version": 1,
|
|
"id": node.node_id,
|
|
"title": node.title,
|
|
"family": node.family,
|
|
"authority": node.authority,
|
|
"status": node.status,
|
|
"tags": list(node.tags),
|
|
"summary": node.summary,
|
|
}
|
|
if node.source_anchor is not None:
|
|
record["source_anchor"] = node.source_anchor
|
|
for relation in snapshot.descriptor.allowed_relations:
|
|
targets = sorted(
|
|
edge.target_id
|
|
for edge in snapshot.edges
|
|
if edge.source_id == node.node_id and edge.relation == relation
|
|
)
|
|
if targets:
|
|
record[relation] = targets
|
|
if include_content:
|
|
record["content"] = node.content
|
|
return record
|
|
|
|
@staticmethod
|
|
def _toml_record(record: dict[str, object]) -> list[str]:
|
|
lines: list[str] = []
|
|
for key, value in record.items():
|
|
if isinstance(value, int):
|
|
encoded = str(value)
|
|
elif isinstance(value, str):
|
|
encoded = json.dumps(value, ensure_ascii=False)
|
|
elif isinstance(value, list):
|
|
string_items: list[str] = []
|
|
for item in cast(list[object], value):
|
|
if not isinstance(item, str):
|
|
raise DocForgeError(
|
|
"application_mismatch",
|
|
"Generic canonical list values must contain only strings",
|
|
field=key,
|
|
)
|
|
string_items.append(item)
|
|
items = ", ".join(json.dumps(item, ensure_ascii=False) for item in string_items)
|
|
encoded = f"[{items}]"
|
|
else:
|
|
raise DocForgeError(
|
|
"application_mismatch",
|
|
"Generic canonical serialization encountered an unsupported value",
|
|
field=key,
|
|
)
|
|
lines.append(f"{key} = {encoded}")
|
|
return lines
|
|
|
|
|
|
class CanonicalApplicationService:
|
|
"""Apply one hash-bound changeset, then refresh all declared derived state."""
|
|
|
|
def __init__(
|
|
self,
|
|
project: ProjectService,
|
|
*,
|
|
applier_id: str | None,
|
|
applier: CanonicalApplier | None,
|
|
index: ProjectIndex | None = None,
|
|
manual_policy: ManualProjectionMode = "auto",
|
|
) -> None:
|
|
self.project = project
|
|
self.applier_id = applier_id
|
|
self.applier = applier
|
|
self.changesets = ChangesetStore(project, applier_id)
|
|
self.index = index or ProjectIndex(project)
|
|
self.manual_policy = validate_manual_projection_mode(manual_policy)
|
|
self.rendering = RenderService(
|
|
project,
|
|
self.changesets,
|
|
manual_policy=self.manual_policy,
|
|
)
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return self.applier_id is not None and self.applier is not None
|
|
|
|
def access(self) -> dict[str, object]:
|
|
return {
|
|
"enabled": self.enabled,
|
|
"applier": self.applier_id if self.enabled else None,
|
|
}
|
|
|
|
def apply(self, changeset_id: str, expected_changeset_hash: str) -> dict[str, object]:
|
|
if not self.enabled or self.applier is None or self.applier_id is None:
|
|
raise DocForgeError(
|
|
"canonical_application_disabled",
|
|
"Server has no configured canonical applier",
|
|
)
|
|
applied = self.changesets.apply(
|
|
changeset_id=changeset_id,
|
|
expected_changeset_hash=expected_changeset_hash,
|
|
applier_id=self.applier_id,
|
|
application=self.applier.apply,
|
|
)
|
|
refresh_errors: list[dict[str, object]] = []
|
|
index_result: dict[str, object] | None = None
|
|
index_check: dict[str, object] | None = None
|
|
try:
|
|
index_result = self.index.build()
|
|
index_check = self.index.check()
|
|
except DocForgeError as error:
|
|
refresh_errors.append(
|
|
{
|
|
"component": "index",
|
|
"error": error.as_dict(),
|
|
"remediation": {
|
|
"tool": "docforge_sync",
|
|
"arguments": {},
|
|
},
|
|
}
|
|
)
|
|
renders: list[dict[str, object]] = []
|
|
config = self.project.descriptor.render
|
|
render_action = "not_configured"
|
|
if config is not None and self.manual_policy == "auto":
|
|
render_action = "rendered"
|
|
for view in config.views:
|
|
try:
|
|
rendered = self.rendering.render(view.view_id)
|
|
renders.append(rendered)
|
|
if rendered.get("state") == "degraded":
|
|
receipt = rendered.get("receipt")
|
|
refresh_errors.append(
|
|
{
|
|
"component": "render_receipt",
|
|
"view_id": view.view_id,
|
|
"error": (
|
|
cast(Mapping[str, object], receipt).get("error")
|
|
if isinstance(receipt, dict)
|
|
else {
|
|
"code": "render_receipt_failure",
|
|
"message": (
|
|
"Rendered output was published without a "
|
|
"verification receipt"
|
|
),
|
|
"details": {},
|
|
}
|
|
),
|
|
}
|
|
)
|
|
except DocForgeError as error:
|
|
refresh_errors.append(
|
|
{
|
|
"component": "render",
|
|
"view_id": view.view_id,
|
|
"error": error.as_dict(),
|
|
}
|
|
)
|
|
elif config is not None:
|
|
render_action = (
|
|
"skipped_explicit" if self.manual_policy == "explicit" else "skipped_disabled"
|
|
)
|
|
return {
|
|
**applied,
|
|
"derived_refresh": {
|
|
"status": "degraded" if refresh_errors else "ok",
|
|
"index": index_result,
|
|
"check": index_check,
|
|
"renders": renders,
|
|
"render_policy": {
|
|
"mode": self.manual_policy,
|
|
"action": render_action,
|
|
},
|
|
"errors": refresh_errors,
|
|
},
|
|
}
|