T020: ApplyAdapter protocol + InstallState

This commit is contained in:
m2 (AI Agent) 2026-07-02 02:41:59 +02:00
parent c607bc04b7
commit 86cdcdd82a
2 changed files with 163 additions and 0 deletions

View file

@ -0,0 +1,35 @@
"""Apply adapter factory (contracts/apply-adapter.md §Selection)."""
from .base import ApplyAdapter, ApplyResult, Changeset, ChangesetItem, InstallState, RailsNotLanded
__all__ = [
"ApplyAdapter",
"ApplyResult",
"Changeset",
"ChangesetItem",
"InstallState",
"RailsNotLanded",
"get_adapter",
]
_ADAPTERS = {
"local": ("m2_market.apply.local", "LocalApplyAdapter"),
"m2core-sync": ("m2_market.apply.m2core_sync", "M2CoreSyncApplyAdapter"),
}
def get_adapter(name: str) -> ApplyAdapter:
"""Look up an ApplyAdapter implementation by name, importing it lazily.
Raises KeyError for unknown adapter names.
"""
try:
module_name, class_name = _ADAPTERS[name]
except KeyError:
raise KeyError(f"unknown apply adapter: {name!r} (known: {sorted(_ADAPTERS)})") from None
import importlib
module = importlib.import_module(module_name)
adapter_cls = getattr(module, class_name)
return adapter_cls()

View file

@ -0,0 +1,128 @@
"""Apply Adapter protocol + InstallState (contracts/apply-adapter.md, frozen v1).
The only interface through which anything installs a Solution bundle onto a machine.
"""
from __future__ import annotations
import json
import os
import tempfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal, Protocol
ChangesetItemKind = Literal["placement", "config_merge", "check"]
InstallStatus = Literal["applied", "failed", "rolled_back"]
DEFAULT_STATE_PATH = Path.home() / ".m2-market" / "state.json"
@dataclass
class ChangesetItem:
kind: ChangesetItemKind
src: str
dest: str
backup: str | None = None
@dataclass
class Changeset:
listing_id: str
version: str
items: list[ChangesetItem]
changeset_hash: str
@dataclass
class ApplyResult:
ok: bool
applied: list[ChangesetItem] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
class RailsNotLanded(Exception):
"""Raised by adapters (e.g. m2core-sync) whose fedlearn rails haven't landed yet."""
class InstallState:
"""Per-machine install ledger at ~/.m2-market/state.json (data-model.md §InstallState).
Path is overridable via the M2_MARKET_STATE env var. Writes are atomic
(tmp file + rename) so a crash mid-write never corrupts the state file.
"""
def __init__(self, path: Path | None = None):
self.path = path or self._default_path()
self.installs: dict[str, dict] = {}
if self.path.exists():
self._load()
@staticmethod
def _default_path() -> Path:
override = os.environ.get("M2_MARKET_STATE")
return Path(override) if override else DEFAULT_STATE_PATH
def _load(self) -> None:
with open(self.path, encoding="utf-8") as f:
data = json.load(f)
self.installs = data.get("installs", {})
def load(self) -> None:
"""Re-read state from disk, discarding any in-memory changes."""
if self.path.exists():
self._load()
else:
self.installs = {}
def save(self) -> None:
"""Atomically write current state to disk (tmp file + rename)."""
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"installs": self.installs}
fd, tmp_path = tempfile.mkstemp(
dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, sort_keys=True)
f.write("\n")
os.replace(tmp_path, self.path)
except BaseException:
if os.path.exists(tmp_path):
os.remove(tmp_path)
raise
def record_install(
self,
listing_id: str,
version: str,
grant_id: str,
changeset_hash: str,
status: InstallStatus,
) -> None:
self.installs[listing_id] = {
"version": version,
"grant_id": grant_id,
"ts": datetime.now(timezone.utc).isoformat(),
"changeset_hash": changeset_hash,
"status": status,
}
self.save()
def get(self, listing_id: str) -> dict | None:
return self.installs.get(listing_id)
class ApplyAdapter(Protocol):
"""The seam through which any adapter installs a Solution bundle (idempotent, reversible)."""
name: str
def plan(self, bundle: Path, state: InstallState) -> Changeset: ...
def apply(self, changeset: Changeset) -> ApplyResult: ...
def verify(self, bundle: Path, state: InstallState) -> bool: ...
def rollback(self, changeset: Changeset) -> None: ...