push_hooks.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
| 1 | """Generic post-push hook framework — Phase 7.4. |
| 2 | |
| 3 | Mirror of :mod:`muse.core.merge_hooks` but for the ``muse push`` lifecycle. |
| 4 | Domain plugins use it to dispatch *out-of-band* side-effects after a |
| 5 | successful push — typically anchoring commits in an external trust root |
| 6 | (ICP canister, blockchain, AIR ledger). |
| 7 | |
| 8 | The first consumer is :class:`muse.plugins.knowtation.icp_push_hook.KnowtationICPPushHook` |
| 9 | which writes attestation records to the immutable ICP canister |
| 10 | ``dejku-syaaa-aaaaa-qgy3q-cai`` after a knowtation vault push. |
| 11 | |
| 12 | Design contract (identical to merge_hooks) |
| 13 | ------------------------------------------ |
| 14 | |
| 15 | 1. **Out-of-band by default.** Hooks performing network I/O MUST spawn a |
| 16 | background thread and return immediately with |
| 17 | :attr:`PushHookResult.out_of_band` set to ``True``. |
| 18 | 2. **Errors are non-fatal.** Any exception inside the hook is caught and |
| 19 | reported in :attr:`PushHookResult.error`. The push command never fails |
| 20 | because of a hook failure. |
| 21 | 3. **Domain isolation.** Each plugin registers under a domain string; the |
| 22 | registry never invokes a hook for a domain that did not register it. |
| 23 | 4. **Thread-safe registry.** Module-level singleton serialises registration |
| 24 | so concurrent registrations cannot lose entries. |
| 25 | 5. **Idempotent.** Re-running the same hook on the same push payload MUST |
| 26 | produce the same effect — relevant for the ICP canister's "already |
| 27 | exists" rejection path. |
| 28 | """ |
| 29 | |
| 30 | from __future__ import annotations |
| 31 | |
| 32 | import logging |
| 33 | import pathlib |
| 34 | import threading |
| 35 | from dataclasses import dataclass, field |
| 36 | from typing import Protocol, runtime_checkable |
| 37 | |
| 38 | logger = logging.getLogger(__name__) |
| 39 | |
| 40 | |
| 41 | # --------------------------------------------------------------------------- |
| 42 | # Result type |
| 43 | # --------------------------------------------------------------------------- |
| 44 | |
| 45 | |
| 46 | @dataclass(frozen=True) |
| 47 | class PushHookResult: |
| 48 | """Result of a single post-push hook invocation. |
| 49 | |
| 50 | Attributes: |
| 51 | hook_name: Stable identifier — typically ``"<domain>-<purpose>"``. |
| 52 | Used for logging and for the ``push_hook`` field in JSON CLI |
| 53 | output. |
| 54 | fired: ``True`` when the hook actually executed any logic. ``False`` |
| 55 | when the hook chose to no-op (e.g. an opt-in flag was not set or |
| 56 | a precondition failed without being an error). |
| 57 | out_of_band: ``True`` when the hook dispatched its real work to a |
| 58 | background thread. By contract every ICP push hook returns |
| 59 | ``out_of_band=True``. |
| 60 | skipped: ``True`` when the hook chose not to fire because of a |
| 61 | documented condition (e.g. ICP unreachable, MUSE_ICP_ENABLED=0). |
| 62 | Distinct from ``fired=False`` (no opt-in) and ``error`` (failure). |
| 63 | reason: Short machine-readable token explaining ``skipped=True``. |
| 64 | Example values: ``"ICP_UNREACHABLE"``, ``"DISABLED"``, |
| 65 | ``"NO_ATTESTATION_IN_COMMIT"``. Empty when not skipped. |
| 66 | message: Human-readable status (≤ 200 chars). Surfaced in |
| 67 | ``muse push`` text output. |
| 68 | error: ``None`` on success. When non-``None``, the hook encountered |
| 69 | an error that was caught by the registry and logged at WARNING. |
| 70 | anchored_count: Number of attestation records the hook successfully |
| 71 | anchored. ``0`` when nothing to anchor or when ``skipped=True``. |
| 72 | failed_count: Number of attestation records the hook failed to |
| 73 | anchor (e.g. transient ICP errors). ``failed_count > 0`` does |
| 74 | NOT mean the hook itself failed — the records are simply not |
| 75 | yet anchored and may be retried later. |
| 76 | """ |
| 77 | |
| 78 | hook_name: str |
| 79 | fired: bool |
| 80 | out_of_band: bool |
| 81 | skipped: bool |
| 82 | reason: str |
| 83 | message: str |
| 84 | error: str | None |
| 85 | anchored_count: int = 0 |
| 86 | failed_count: int = 0 |
| 87 | |
| 88 | |
| 89 | # --------------------------------------------------------------------------- |
| 90 | # Plugin protocol |
| 91 | # --------------------------------------------------------------------------- |
| 92 | |
| 93 | |
| 94 | @runtime_checkable |
| 95 | class PushHookPlugin(Protocol): |
| 96 | """Structural type for a post-push hook provider. |
| 97 | |
| 98 | Implementations MUST: |
| 99 | |
| 100 | * Be safe to call from any thread. |
| 101 | * Return a :class:`PushHookResult` with ``out_of_band=True`` if any |
| 102 | I/O is dispatched asynchronously. |
| 103 | * Never raise — exceptional conditions belong in :attr:`PushHookResult.error`. |
| 104 | """ |
| 105 | |
| 106 | def run_post_push( |
| 107 | self, |
| 108 | repo_path: pathlib.Path, |
| 109 | push_result: dict[str, object], |
| 110 | domain: str, |
| 111 | config: dict[str, object] | None = None, |
| 112 | ) -> PushHookResult: |
| 113 | """Invoke the hook for a single push operation. |
| 114 | |
| 115 | Args: |
| 116 | repo_path: Repository root. |
| 117 | push_result: Dict describing what was pushed: ``branches`` (list |
| 118 | of branch names), ``commits`` (list of commit IDs newly |
| 119 | pushed to the remote — domain-specific), ``remote`` (remote |
| 120 | name string). Hooks MUST treat any non-documented keys as |
| 121 | opaque. |
| 122 | domain: Domain string the hook is registered under. |
| 123 | config: Domain-specific configuration dict (e.g. ICP canister |
| 124 | ID, identity PEM path). Hooks MUST validate any field they |
| 125 | read; unrecognised keys are ignored. |
| 126 | |
| 127 | Returns: |
| 128 | A :class:`PushHookResult` describing what the hook did. |
| 129 | Implementations MUST NOT raise. |
| 130 | """ |
| 131 | ... |
| 132 | |
| 133 | |
| 134 | # --------------------------------------------------------------------------- |
| 135 | # Registry |
| 136 | # --------------------------------------------------------------------------- |
| 137 | |
| 138 | |
| 139 | class PushHookRegistry: |
| 140 | """Thread-safe registry mapping domain names to push hook plugins. |
| 141 | |
| 142 | Use :func:`get_push_hook_registry` to obtain the process-wide singleton. |
| 143 | Construct a fresh ``PushHookRegistry()`` for tests that need isolation. |
| 144 | """ |
| 145 | |
| 146 | def __init__(self) -> None: |
| 147 | """Initialise an empty registry with its own lock.""" |
| 148 | self._plugins: dict[str, PushHookPlugin] = {} |
| 149 | self._lock: threading.Lock = threading.Lock() |
| 150 | |
| 151 | def register(self, domain: str, plugin: PushHookPlugin) -> None: |
| 152 | """Register *plugin* under *domain*, replacing any prior entry. |
| 153 | |
| 154 | Args: |
| 155 | domain: Non-empty domain name string. |
| 156 | plugin: An object implementing :class:`PushHookPlugin`. |
| 157 | |
| 158 | Raises: |
| 159 | ValueError: If *domain* is empty or not a string. |
| 160 | """ |
| 161 | if not isinstance(domain, str) or not domain: |
| 162 | raise ValueError("domain must be a non-empty string") |
| 163 | with self._lock: |
| 164 | self._plugins[domain] = plugin |
| 165 | |
| 166 | def get(self, domain: str) -> PushHookPlugin | None: |
| 167 | """Return the registered plugin for *domain* or ``None``. |
| 168 | |
| 169 | Args: |
| 170 | domain: Domain name string. |
| 171 | |
| 172 | Returns: |
| 173 | The registered :class:`PushHookPlugin`, or ``None`` when no |
| 174 | plugin has been registered for *domain*. |
| 175 | """ |
| 176 | with self._lock: |
| 177 | return self._plugins.get(domain) |
| 178 | |
| 179 | def unregister(self, domain: str) -> bool: |
| 180 | """Remove the plugin registered under *domain*, if any. |
| 181 | |
| 182 | Returns: |
| 183 | ``True`` if a plugin was removed, ``False`` otherwise. |
| 184 | """ |
| 185 | with self._lock: |
| 186 | return self._plugins.pop(domain, None) is not None |
| 187 | |
| 188 | def run_post_push( |
| 189 | self, |
| 190 | repo_path: pathlib.Path, |
| 191 | domain: str, |
| 192 | push_result: dict[str, object], |
| 193 | config: dict[str, object] | None = None, |
| 194 | ) -> list[PushHookResult]: |
| 195 | """Invoke the registered hook for *domain* and return its result(s). |
| 196 | |
| 197 | Returns a list (rather than a single result) so future versions can |
| 198 | fan out to multiple hooks per domain without breaking the caller |
| 199 | signature. Today the list contains zero results (no plugin |
| 200 | registered) or one result (the registered plugin's return value). |
| 201 | |
| 202 | Errors raised inside the plugin's :meth:`PushHookPlugin.run_post_push` |
| 203 | are caught, logged at WARNING level, and returned as a synthetic |
| 204 | :class:`PushHookResult` with the exception text in |
| 205 | :attr:`PushHookResult.error` — the push call site never sees the |
| 206 | exception. |
| 207 | |
| 208 | Args: |
| 209 | repo_path: Repository root. |
| 210 | domain: Domain name string used to look up the plugin. |
| 211 | push_result: See :meth:`PushHookPlugin.run_post_push`. |
| 212 | config: Domain-specific configuration dict. |
| 213 | |
| 214 | Returns: |
| 215 | A list of :class:`PushHookResult` (length 0 or 1 today). |
| 216 | """ |
| 217 | plugin = self.get(domain) |
| 218 | if plugin is None: |
| 219 | return [] |
| 220 | try: |
| 221 | result = plugin.run_post_push(repo_path, push_result, domain, config) |
| 222 | except Exception as exc: # noqa: BLE001 — see docstring contract |
| 223 | logger.warning( |
| 224 | "push_hooks: domain=%s plugin=%s raised %s: %s", |
| 225 | domain, |
| 226 | type(plugin).__name__, |
| 227 | type(exc).__name__, |
| 228 | exc, |
| 229 | ) |
| 230 | return [PushHookResult( |
| 231 | hook_name=f"{domain}-unknown", |
| 232 | fired=False, |
| 233 | out_of_band=False, |
| 234 | skipped=False, |
| 235 | reason="", |
| 236 | message="hook raised an exception (caught)", |
| 237 | error=f"{type(exc).__name__}: {exc}", |
| 238 | )] |
| 239 | if not isinstance(result, PushHookResult): |
| 240 | logger.warning( |
| 241 | "push_hooks: domain=%s plugin=%s returned non-PushHookResult: %r", |
| 242 | domain, |
| 243 | type(plugin).__name__, |
| 244 | type(result).__name__, |
| 245 | ) |
| 246 | return [PushHookResult( |
| 247 | hook_name=f"{domain}-unknown", |
| 248 | fired=False, |
| 249 | out_of_band=False, |
| 250 | skipped=False, |
| 251 | reason="", |
| 252 | message="hook returned wrong type (ignored)", |
| 253 | error=f"expected PushHookResult, got {type(result).__name__}", |
| 254 | )] |
| 255 | return [result] |
| 256 | |
| 257 | |
| 258 | # --------------------------------------------------------------------------- |
| 259 | # Singleton accessor |
| 260 | # --------------------------------------------------------------------------- |
| 261 | |
| 262 | |
| 263 | _REGISTRY_LOCK: threading.Lock = threading.Lock() |
| 264 | _REGISTRY: PushHookRegistry | None = None |
| 265 | |
| 266 | |
| 267 | def get_push_hook_registry() -> PushHookRegistry: |
| 268 | """Return the process-wide :class:`PushHookRegistry` singleton. |
| 269 | |
| 270 | Constructed lazily under a module-level lock the first time this |
| 271 | function is called, so concurrent callers in different threads always |
| 272 | observe the same instance. Tests that need isolation should construct |
| 273 | a fresh :class:`PushHookRegistry` rather than mutating the singleton. |
| 274 | |
| 275 | Returns: |
| 276 | The :class:`PushHookRegistry` shared by all callers. |
| 277 | """ |
| 278 | global _REGISTRY |
| 279 | if _REGISTRY is not None: |
| 280 | return _REGISTRY |
| 281 | with _REGISTRY_LOCK: |
| 282 | if _REGISTRY is None: |
| 283 | _REGISTRY = PushHookRegistry() |
| 284 | return _REGISTRY |
| 285 | |
| 286 | |
| 287 | __all__ = [ |
| 288 | "PushHookPlugin", |
| 289 | "PushHookRegistry", |
| 290 | "PushHookResult", |
| 291 | "get_push_hook_registry", |
| 292 | ] |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
33 days ago