generated from coulomb/repo-seed
feat(coordination): apply-under-drift (WP-0008 T4)
OverlayEngine.apply: read-only target → KEPT_DRAFT; base_rev==current → fast-forward write-through (APPLIED, MERGE_DECIDED closes overlay); drift → REFUSED_DRIFT (no clobber, I-5). 5 tests green, pyflakes clean. (blueprint §8.6) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,12 @@ from shard_wiki.coordination.decision_log import (
|
|||||||
DecisionLog,
|
DecisionLog,
|
||||||
EventType,
|
EventType,
|
||||||
)
|
)
|
||||||
from shard_wiki.coordination.overlay import Overlay, OverlayEngine
|
from shard_wiki.coordination.overlay import (
|
||||||
|
ApplyResult,
|
||||||
|
ApplyStatus,
|
||||||
|
Overlay,
|
||||||
|
OverlayEngine,
|
||||||
|
)
|
||||||
from shard_wiki.coordination.patch import Patch, render_patch
|
from shard_wiki.coordination.patch import Patch, render_patch
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -16,6 +21,8 @@ __all__ = [
|
|||||||
"CoordinationState",
|
"CoordinationState",
|
||||||
"Overlay",
|
"Overlay",
|
||||||
"OverlayEngine",
|
"OverlayEngine",
|
||||||
|
"ApplyStatus",
|
||||||
|
"ApplyResult",
|
||||||
"Patch",
|
"Patch",
|
||||||
"render_patch",
|
"render_patch",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from shard_wiki.adapters import ShardAdapter
|
||||||
from shard_wiki.coordination.decision_log import DecisionLog, EventType
|
from shard_wiki.coordination.decision_log import DecisionLog, EventType
|
||||||
from shard_wiki.model import Identity
|
from shard_wiki.model import Identity, Page, Verb
|
||||||
from shard_wiki.provenance import OverlayState
|
from shard_wiki.provenance import OverlayState
|
||||||
|
|
||||||
__all__ = ["Overlay", "OverlayEngine"]
|
__all__ = ["Overlay", "OverlayEngine", "ApplyStatus", "ApplyResult"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -51,6 +53,20 @@ class Overlay:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyStatus(Enum):
|
||||||
|
APPLIED = "applied" # fast-forwarded and written through
|
||||||
|
REFUSED_DRIFT = "refused-drift" # source moved under the overlay; no clobber
|
||||||
|
KEPT_DRAFT = "kept-draft" # target read-only; overlay remains the local truth
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApplyResult:
|
||||||
|
status: ApplyStatus
|
||||||
|
overlay_id: str
|
||||||
|
page: Page | None = None
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
class OverlayEngine:
|
class OverlayEngine:
|
||||||
def __init__(self, space: str, log: DecisionLog) -> None:
|
def __init__(self, space: str, log: DecisionLog) -> None:
|
||||||
self.space = space
|
self.space = space
|
||||||
@@ -75,3 +91,44 @@ class OverlayEngine:
|
|||||||
def open_overlays(self) -> tuple[Overlay, ...]:
|
def open_overlays(self) -> tuple[Overlay, ...]:
|
||||||
state = self.log.fold(self.space)
|
state = self.log.fold(self.space)
|
||||||
return tuple(Overlay.from_payload(p) for p in state.open_overlays.values())
|
return tuple(Overlay.from_payload(p) for p in state.open_overlays.values())
|
||||||
|
|
||||||
|
def apply(self, overlay_id: str, adapter: ShardAdapter) -> ApplyResult:
|
||||||
|
"""Resolve an overlay against its target shard with apply-under-drift semantics (§8.6).
|
||||||
|
|
||||||
|
Read-only target → ``KEPT_DRAFT`` (the overlay stays the local truth). Otherwise compare
|
||||||
|
the overlay's ``base_rev`` to the shard's current rev: equal → fast-forward write-through
|
||||||
|
(``APPLIED``); changed → ``REFUSED_DRIFT`` (never clobber, I-5). Applying records a
|
||||||
|
``MERGE_DECIDED`` event that closes the overlay in the fold.
|
||||||
|
"""
|
||||||
|
overlay = self.get(overlay_id)
|
||||||
|
if overlay is None:
|
||||||
|
raise KeyError(overlay_id)
|
||||||
|
if adapter.shard_id != overlay.target.shard:
|
||||||
|
raise ValueError(f"adapter {adapter.shard_id!r} != target {overlay.target.shard!r}")
|
||||||
|
|
||||||
|
if not adapter.profile().supports(Verb.WRITE):
|
||||||
|
return ApplyResult(
|
||||||
|
ApplyStatus.KEPT_DRAFT, overlay_id, detail="target is read-only; overlay retained"
|
||||||
|
)
|
||||||
|
|
||||||
|
current = _current_rev(adapter, overlay.target.key)
|
||||||
|
if current != overlay.base_rev:
|
||||||
|
return ApplyResult(
|
||||||
|
ApplyStatus.REFUSED_DRIFT,
|
||||||
|
overlay_id,
|
||||||
|
detail=f"base_rev {overlay.base_rev!r} != current {current!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
page = adapter.write(overlay.target.key, overlay.body)
|
||||||
|
self.log.append(
|
||||||
|
self.space,
|
||||||
|
EventType.MERGE_DECIDED,
|
||||||
|
{"overlay_id": overlay_id, "outcome": "applied"},
|
||||||
|
)
|
||||||
|
return ApplyResult(ApplyStatus.APPLIED, overlay_id, page=page)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_rev(adapter: ShardAdapter, key: str) -> str | None:
|
||||||
|
"""Best-effort current-revision probe; adapters without one are treated as no-rev."""
|
||||||
|
probe = getattr(adapter, "current_rev", None)
|
||||||
|
return probe(key) if callable(probe) else None
|
||||||
|
|||||||
66
tests/test_apply.py
Normal file
66
tests/test_apply.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Tests for apply-under-drift (SHARD-WP-0008 T4)."""
|
||||||
|
|
||||||
|
from shard_wiki.adapters import FolderAdapter
|
||||||
|
from shard_wiki.coordination import ApplyStatus, DecisionLog, OverlayEngine
|
||||||
|
from shard_wiki.model import Identity
|
||||||
|
|
||||||
|
|
||||||
|
def _writable(tmp_path, files):
|
||||||
|
for rel, text in files.items():
|
||||||
|
(tmp_path / rel).write_text(text, encoding="utf-8")
|
||||||
|
return FolderAdapter("w", tmp_path, writable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fast_forward_apply_writes_through(tmp_path):
|
||||||
|
shard = _writable(tmp_path, {"Home.md": "old"})
|
||||||
|
eng = OverlayEngine("space", DecisionLog())
|
||||||
|
base = shard.current_rev("Home")
|
||||||
|
ov = eng.draft(Identity("w", "Home"), "new", base_rev=base)
|
||||||
|
|
||||||
|
result = eng.apply(ov.overlay_id, shard)
|
||||||
|
assert result.status is ApplyStatus.APPLIED
|
||||||
|
assert shard.read("Home").body == "new" # written through
|
||||||
|
assert eng.get(ov.overlay_id) is None # overlay closed in the fold
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_refuses_without_clobber(tmp_path):
|
||||||
|
shard = _writable(tmp_path, {"Home.md": "old"})
|
||||||
|
eng = OverlayEngine("space", DecisionLog())
|
||||||
|
ov = eng.draft(Identity("w", "Home"), "mine", base_rev="STALE-REV")
|
||||||
|
|
||||||
|
result = eng.apply(ov.overlay_id, shard)
|
||||||
|
assert result.status is ApplyStatus.REFUSED_DRIFT
|
||||||
|
assert shard.read("Home").body == "old" # not clobbered (I-5)
|
||||||
|
assert eng.get(ov.overlay_id) is not None # overlay still open
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_only_target_keeps_draft(tmp_path):
|
||||||
|
(tmp_path / "Home.md").write_text("canon", encoding="utf-8")
|
||||||
|
ro = FolderAdapter("ro", tmp_path) # not writable
|
||||||
|
eng = OverlayEngine("space", DecisionLog())
|
||||||
|
ov = eng.draft(Identity("ro", "Home"), "my edit", base_rev=ro.current_rev("Home"))
|
||||||
|
|
||||||
|
result = eng.apply(ov.overlay_id, ro)
|
||||||
|
assert result.status is ApplyStatus.KEPT_DRAFT
|
||||||
|
assert ro.read("Home").body == "canon" # source untouched
|
||||||
|
assert eng.get(ov.overlay_id) is not None # local truth retained
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_page_fast_forwards(tmp_path):
|
||||||
|
shard = _writable(tmp_path, {})
|
||||||
|
eng = OverlayEngine("space", DecisionLog())
|
||||||
|
ov = eng.draft(Identity("w", "Fresh"), "brand new", base_rev=None) # didn't exist
|
||||||
|
result = eng.apply(ov.overlay_id, shard)
|
||||||
|
assert result.status is ApplyStatus.APPLIED
|
||||||
|
assert shard.read("Fresh").body == "brand new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_adapter_is_rejected(tmp_path):
|
||||||
|
shard = _writable(tmp_path, {"Home.md": "x"})
|
||||||
|
eng = OverlayEngine("space", DecisionLog())
|
||||||
|
ov = eng.draft(Identity("other", "Home"), "y", base_rev=None)
|
||||||
|
try:
|
||||||
|
eng.apply(ov.overlay_id, shard)
|
||||||
|
raise AssertionError("expected ValueError")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
@@ -85,7 +85,7 @@ empty patch when unchanged.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0008-T4
|
id: SHARD-WP-0008-T4
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "2a0179b1-802e-44e6-883d-9f1babefee80"
|
state_hub_task_id: "2a0179b1-802e-44e6-883d-9f1babefee80"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user