merge_hooks.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
14 days ago
| 1 | """Generic pre-merge hook framework — Phase 4.4. |
| 2 | |
| 3 | A small extension point that lets domain plugins register *out-of-band* |
| 4 | side-effects fired around a ``muse merge`` invocation. The intent is |
| 5 | **non-blocking**: hooks may kick off background work (HTTP requests to a |
| 6 | local daemon, file-system probes, IPC messages) but the merge engine |
| 7 | must never wait on them, and a hook crash must never abort the merge. |
| 8 | |
| 9 | The first consumer is the Knowtation domain (see |
| 10 | :mod:`muse.plugins.knowtation.hooks`), which uses this framework to fire |
| 11 | ``POST /api/v1/memory/consolidate`` against the local Knowtation hub when |
| 12 | the user passes ``muse merge --consolidate``. Per the |
| 13 | :doc:`Q3 recommendation <plans/knowtation_domain_in_muse>`, |
| 14 | consolidation is *external process today, declarative pre-merge hook |
| 15 | later* — the merge UX is never blocked on LLM latency. |
| 16 | |
| 17 | Design contract |
| 18 | --------------- |
| 19 | |
| 20 | 1. **Out-of-band by default.** Hooks that perform any I/O whose latency |
| 21 | is not bounded by the merge engine itself must spawn a background |
| 22 | thread (or otherwise dispatch async work) and return immediately with |
| 23 | :attr:`MergeHookResult.out_of_band` set to ``True``. |
| 24 | |
| 25 | 2. **Errors are non-fatal.** Any exception inside a hook implementation |
| 26 | is captured by :meth:`MergeHookRegistry.run_pre_merge` and reported in |
| 27 | :attr:`MergeHookResult.error`. The merge caller never sees the |
| 28 | exception and never fails because of a hook failure. |
| 29 | |
| 30 | 3. **Domains are isolated.** Each registered plugin has its own domain |
| 31 | string key. The registry never invokes a hook for a domain that did |
| 32 | not register it. |
| 33 | |
| 34 | 4. **Thread-safe.** The module-level singleton uses an internal |
| 35 | :class:`threading.Lock` to serialise registration so concurrent |
| 36 | registrations (e.g. lazy-loaded plugins) cannot lose entries. |
| 37 | """ |
| 38 | |
| 39 | from __future__ import annotations |
| 40 | |
| 41 | import logging |
| 42 | import pathlib |
| 43 | import threading |
| 44 | from dataclasses import dataclass |
| 45 | from typing import Protocol, runtime_checkable |
| 46 | |
| 47 | logger = logging.getLogger(__name__) |
| 48 | |
| 49 | |
| 50 | # --------------------------------------------------------------------------- |
| 51 | # Result type |
| 52 | # --------------------------------------------------------------------------- |
| 53 | |
| 54 | |
| 55 | @dataclass(frozen=True) |
| 56 | class MergeHookResult: |
| 57 | """Result of a single pre-merge hook invocation. |
| 58 | |
| 59 | Frozen dataclass so callers cannot mutate the result after the hook |
| 60 | returns; this guarantees the merge caller and the audit log see the |
| 61 | same value the hook produced. |
| 62 | |
| 63 | Attributes: |
| 64 | hook_name: Stable identifier for the hook (``"<domain>-<purpose>"``, |
| 65 | e.g. ``"knowtation-consolidate"``). Used for logging and for |
| 66 | the ``consolidation_hook`` field in JSON CLI output. |
| 67 | fired: ``True`` when the hook actually executed any logic. |
| 68 | ``False`` when the hook chose to no-op (e.g. an opt-in flag |
| 69 | was not set, or a precondition failed without being an error). |
| 70 | out_of_band: ``True`` when the hook dispatched its real work to a |
| 71 | background thread (or otherwise asynchronously) and the merge |
| 72 | engine MUST NOT wait on it. By contract every Knowtation |
| 73 | consolidation hook returns ``out_of_band=True``. |
| 74 | message: Short human-readable status (≤ 200 chars). Surfaced in |
| 75 | ``muse merge`` text output and in the JSON |
| 76 | ``consolidation_hook.message`` field. |
| 77 | error: ``None`` on success. When non-``None``, the hook |
| 78 | encountered an error that was caught by the registry and |
| 79 | logged at WARNING level. The merge proceeded anyway; this |
| 80 | field exists for diagnostics, not flow-control. |
| 81 | """ |
| 82 | |
| 83 | hook_name: str |
| 84 | fired: bool |
| 85 | out_of_band: bool |
| 86 | message: str |
| 87 | error: str | None |
| 88 | |
| 89 | |
| 90 | # --------------------------------------------------------------------------- |
| 91 | # Plugin protocol |
| 92 | # --------------------------------------------------------------------------- |
| 93 | |
| 94 | |
| 95 | @runtime_checkable |
| 96 | class MergeHookPlugin(Protocol): |
| 97 | """Structural type for a pre-merge hook provider. |
| 98 | |
| 99 | A plugin is any object exposing :meth:`pre_merge_hook` with the |
| 100 | signature below. Domain plugins typically expose this in a |
| 101 | sub-module (``muse/plugins/<domain>/hooks.py``) and register a |
| 102 | singleton via the domain's ``register_*_hook()`` helper. |
| 103 | |
| 104 | Implementations MUST: |
| 105 | |
| 106 | * Be safe to call from any thread (the merge engine may invoke |
| 107 | multiple hooks concurrently in the future, even though today they |
| 108 | run serially). |
| 109 | * Return a :class:`MergeHookResult` with ``out_of_band=True`` if any |
| 110 | I/O is performed asynchronously. |
| 111 | * Never raise — any exceptional condition belongs in |
| 112 | :attr:`MergeHookResult.error`. |
| 113 | """ |
| 114 | |
| 115 | def pre_merge_hook( |
| 116 | self, |
| 117 | root: pathlib.Path, |
| 118 | ours_commit_id: str, |
| 119 | theirs_commit_id: str, |
| 120 | branch: str, |
| 121 | *, |
| 122 | consolidate: bool = False, |
| 123 | ) -> "MergeHookResult": |
| 124 | """Invoke the hook for a single merge operation. |
| 125 | |
| 126 | Args: |
| 127 | root: Repository root directory (contains ``.muse/``). |
| 128 | ours_commit_id: Commit ID of HEAD of the current branch |
| 129 | immediately *before* the merge engine writes the merge |
| 130 | commit. Hooks MUST treat this as opaque. |
| 131 | theirs_commit_id: Commit ID of HEAD of the branch being |
| 132 | merged in. Same opacity contract as *ours_commit_id*. |
| 133 | branch: Name of the branch being merged in (the "theirs" |
| 134 | branch). Already validated by the merge command. |
| 135 | consolidate: Domain-specific feature flag — when ``False`` |
| 136 | the hook should return a no-op result with |
| 137 | ``fired=False``. Plumbed through from |
| 138 | ``muse merge --consolidate``. |
| 139 | |
| 140 | Returns: |
| 141 | A :class:`MergeHookResult` describing what the hook did. |
| 142 | Implementations MUST NOT raise; convert exceptions to |
| 143 | ``MergeHookResult(error=str(exc), ...)``. |
| 144 | """ |
| 145 | ... |
| 146 | |
| 147 | |
| 148 | # --------------------------------------------------------------------------- |
| 149 | # Registry |
| 150 | # --------------------------------------------------------------------------- |
| 151 | |
| 152 | |
| 153 | class MergeHookRegistry: |
| 154 | """Thread-safe registry mapping domain names to hook plugins. |
| 155 | |
| 156 | Instances are usually obtained via :func:`get_hook_registry` so all |
| 157 | callers share the same singleton. A fresh ``MergeHookRegistry()`` |
| 158 | instance is useful in tests where isolation between cases matters |
| 159 | more than process-wide visibility. |
| 160 | """ |
| 161 | |
| 162 | def __init__(self) -> None: |
| 163 | """Initialise an empty registry with its own lock.""" |
| 164 | self._plugins: dict[str, MergeHookPlugin] = {} |
| 165 | self._lock: threading.Lock = threading.Lock() |
| 166 | |
| 167 | def register(self, domain: str, plugin: MergeHookPlugin) -> None: |
| 168 | """Register *plugin* under *domain*, replacing any prior entry. |
| 169 | |
| 170 | Args: |
| 171 | domain: Domain name string (e.g. ``"knowtation"``). Used as |
| 172 | the lookup key; case-sensitive. |
| 173 | plugin: An object implementing the |
| 174 | :class:`MergeHookPlugin` protocol. Not validated |
| 175 | structurally — duck-typing is sufficient because |
| 176 | :meth:`run_pre_merge` only calls |
| 177 | :meth:`MergeHookPlugin.pre_merge_hook`. |
| 178 | |
| 179 | Raises: |
| 180 | ValueError: If *domain* is empty or not a string. |
| 181 | """ |
| 182 | if not isinstance(domain, str) or not domain: |
| 183 | raise ValueError("domain must be a non-empty string") |
| 184 | with self._lock: |
| 185 | self._plugins[domain] = plugin |
| 186 | |
| 187 | def get(self, domain: str) -> MergeHookPlugin | None: |
| 188 | """Return the registered plugin for *domain* or ``None``. |
| 189 | |
| 190 | Args: |
| 191 | domain: Domain name string (e.g. ``"knowtation"``). |
| 192 | |
| 193 | Returns: |
| 194 | The :class:`MergeHookPlugin` registered for *domain*, or |
| 195 | ``None`` when no plugin has been registered. Never raises. |
| 196 | """ |
| 197 | with self._lock: |
| 198 | return self._plugins.get(domain) |
| 199 | |
| 200 | def unregister(self, domain: str) -> bool: |
| 201 | """Remove the plugin registered under *domain*, if any. |
| 202 | |
| 203 | Useful in tests that need to restore a clean registry state |
| 204 | between cases. |
| 205 | |
| 206 | Args: |
| 207 | domain: Domain name string. |
| 208 | |
| 209 | Returns: |
| 210 | ``True`` if a plugin was removed, ``False`` when no plugin |
| 211 | was registered for *domain*. |
| 212 | """ |
| 213 | with self._lock: |
| 214 | return self._plugins.pop(domain, None) is not None |
| 215 | |
| 216 | def run_pre_merge( |
| 217 | self, |
| 218 | root: pathlib.Path, |
| 219 | domain: str, |
| 220 | ours_commit_id: str, |
| 221 | theirs_commit_id: str, |
| 222 | branch: str, |
| 223 | *, |
| 224 | consolidate: bool = False, |
| 225 | ) -> list[MergeHookResult]: |
| 226 | """Invoke the registered hook for *domain* and return its result. |
| 227 | |
| 228 | Returns a list (rather than a single result) so future versions |
| 229 | can fan out to multiple hooks per domain without breaking the |
| 230 | caller signature. Today the list contains zero results (no |
| 231 | plugin registered) or one result (the registered plugin's |
| 232 | return value). |
| 233 | |
| 234 | The merge engine MUST NOT wait on any work the hook dispatched — |
| 235 | results with :attr:`MergeHookResult.out_of_band` set to ``True`` |
| 236 | carry the audit trail of the *dispatch*, not the eventual |
| 237 | completion. Errors raised inside the plugin's |
| 238 | :meth:`MergeHookPlugin.pre_merge_hook` are caught, logged at |
| 239 | WARNING level, and returned as a synthetic |
| 240 | :class:`MergeHookResult` with the exception text in |
| 241 | :attr:`MergeHookResult.error` — the merge call site never sees |
| 242 | the exception. |
| 243 | |
| 244 | Args: |
| 245 | root: Repository root directory. |
| 246 | domain: Domain name string used to look up the plugin. |
| 247 | ours_commit_id: HEAD of the current branch before merge. |
| 248 | theirs_commit_id: HEAD of the branch being merged in. |
| 249 | branch: Name of the branch being merged in. |
| 250 | consolidate: Domain-specific feature flag plumbed through |
| 251 | from ``muse merge --consolidate``. |
| 252 | |
| 253 | Returns: |
| 254 | A list of :class:`MergeHookResult` (length 0 or 1 today). |
| 255 | Empty when no plugin is registered for *domain*. |
| 256 | """ |
| 257 | plugin = self.get(domain) |
| 258 | if plugin is None: |
| 259 | return [] |
| 260 | try: |
| 261 | result = plugin.pre_merge_hook( |
| 262 | root, |
| 263 | ours_commit_id, |
| 264 | theirs_commit_id, |
| 265 | branch, |
| 266 | consolidate=consolidate, |
| 267 | ) |
| 268 | except Exception as exc: # noqa: BLE001 — see docstring contract |
| 269 | logger.warning( |
| 270 | "merge_hooks: domain=%s plugin=%s raised %s: %s", |
| 271 | domain, |
| 272 | type(plugin).__name__, |
| 273 | type(exc).__name__, |
| 274 | exc, |
| 275 | ) |
| 276 | return [ |
| 277 | MergeHookResult( |
| 278 | hook_name=f"{domain}-unknown", |
| 279 | fired=False, |
| 280 | out_of_band=False, |
| 281 | message="hook raised an exception (caught)", |
| 282 | error=f"{type(exc).__name__}: {exc}", |
| 283 | ) |
| 284 | ] |
| 285 | if not isinstance(result, MergeHookResult): |
| 286 | logger.warning( |
| 287 | "merge_hooks: domain=%s plugin=%s returned non-MergeHookResult: %r", |
| 288 | domain, |
| 289 | type(plugin).__name__, |
| 290 | type(result).__name__, |
| 291 | ) |
| 292 | return [ |
| 293 | MergeHookResult( |
| 294 | hook_name=f"{domain}-unknown", |
| 295 | fired=False, |
| 296 | out_of_band=False, |
| 297 | message="hook returned wrong type (ignored)", |
| 298 | error=f"expected MergeHookResult, got {type(result).__name__}", |
| 299 | ) |
| 300 | ] |
| 301 | return [result] |
| 302 | |
| 303 | |
| 304 | # --------------------------------------------------------------------------- |
| 305 | # Singleton accessor |
| 306 | # --------------------------------------------------------------------------- |
| 307 | |
| 308 | |
| 309 | _REGISTRY_LOCK: threading.Lock = threading.Lock() |
| 310 | _REGISTRY: MergeHookRegistry | None = None |
| 311 | |
| 312 | |
| 313 | def get_hook_registry() -> MergeHookRegistry: |
| 314 | """Return the process-wide :class:`MergeHookRegistry` singleton. |
| 315 | |
| 316 | The singleton is constructed lazily under a module-level lock the |
| 317 | first time this function is called, so concurrent callers in |
| 318 | different threads always observe the same instance. Tests that |
| 319 | need isolation should construct a fresh |
| 320 | :class:`MergeHookRegistry` rather than mutating the singleton. |
| 321 | |
| 322 | Returns: |
| 323 | The :class:`MergeHookRegistry` shared by all callers. |
| 324 | """ |
| 325 | global _REGISTRY |
| 326 | if _REGISTRY is not None: |
| 327 | return _REGISTRY |
| 328 | with _REGISTRY_LOCK: |
| 329 | if _REGISTRY is None: |
| 330 | _REGISTRY = MergeHookRegistry() |
| 331 | return _REGISTRY |
| 332 | |
| 333 | |
| 334 | __all__ = [ |
| 335 | "MergeHookPlugin", |
| 336 | "MergeHookRegistry", |
| 337 | "MergeHookResult", |
| 338 | "get_hook_registry", |
| 339 | ] |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
14 days ago