1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/src/docforge/application.py

448 lines
17 KiB
Python
Raw Normal View History

"""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 .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,
) -> 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.rendering = RenderService(project, self.changesets)
@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
if config is not None:
for view in config.views:
try:
2026-07-29 04:42:55 -04:00
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(),
}
)
return {
**applied,
"derived_refresh": {
"status": "degraded" if refresh_errors else "ok",
"index": index_result,
"check": index_check,
"renders": renders,
"errors": refresh_errors,
},
}