push_hooks.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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 ]