hypermind 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,655 @@
1
+ """Workflow engine — durable, signed, replayable steps.
2
+
3
+ A `Workflow` runs a user-supplied async function whose body issues
4
+ ``await wf.step(name, fn, *args, **kwargs)`` calls. Every successful
5
+ step has its result frozen as bytes (canonical CBOR), hashed, and
6
+ appended to the transparency log via
7
+ :func:`TransparencyService.append_and_cosign`. The leaf written to the
8
+ log is a 32-byte BLAKE3-style identifier derived from the step record
9
+ contents, so the log alone (plus the locally-retained payload bytes)
10
+ is enough to replay.
11
+
12
+ Replay determinism (REQUIRED):
13
+ A step body MUST be a pure function of its arguments and any prior
14
+ Activity outputs. Non-deterministic calls — LLM completions, system
15
+ clock reads, random number generation, network IO — MUST be wrapped
16
+ in an :class:`Activity` whose output bytes are captured at run time
17
+ and re-used at replay time. This is the same Temporal/DBOS pattern.
18
+
19
+ Compensations:
20
+ A step may register a compensator via the :func:`compensate`
21
+ decorator (or by passing ``compensator=`` to ``wf.step``). On
22
+ workflow failure, completed steps are unwound in reverse order;
23
+ each compensation is itself recorded as a step with
24
+ ``status="COMPENSATING"`` so the audit trail is symmetric.
25
+
26
+ Security notes:
27
+ * Result bytes are frozen on ``COMPLETED`` — the dataclass is
28
+ ``frozen=True`` after that point.
29
+ * Every state transition (RUNNING → COMPLETED / FAILED /
30
+ COMPENSATED) is reflected in the log.
31
+ * Replay never re-runs an Activity; it consumes the captured bytes.
32
+ A mismatch between recorded args_hash and replay args_hash raises.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import hashlib
39
+ import inspect
40
+ import secrets
41
+ from collections.abc import Awaitable, Callable
42
+ from dataclasses import dataclass, field
43
+ from typing import Any, ClassVar
44
+
45
+ import cbor2
46
+
47
+ from hypermind.crypto.hlc import HLC, SystemClock
48
+ from hypermind.knowledge.transparency import TransparencyService
49
+
50
+
51
+ def _default_ts(name: str) -> TransparencyService:
52
+ """Build a TransparencyService usable inside an in-process workflow.
53
+
54
+ Production deployments inject their own configured TS (with witness
55
+ keypairs); the default constructed here uses a witness quorum of
56
+ zero so the engine works out of the box for in-process tests and
57
+ examples. Callers MUST supply a properly-witnessed TS for any
58
+ deployment that requires audit-grade signed transparency.
59
+ """
60
+ return TransparencyService(name=name, min_witness_quorum=0)
61
+
62
+
63
+ def _hlc_str(node_id: bytes) -> str:
64
+ """Stringified HLC suitable for human-readable record fields."""
65
+ h = HLC.now(node_id, SystemClock())
66
+ return f"{h.logical_ms}.{h.counter}@{h.node_id.hex()}"
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Status enums (string Literals so they round-trip cleanly through CBOR)
71
+ # ---------------------------------------------------------------------------
72
+
73
+ WorkflowStatus = str # "RUNNING" | "COMPLETED" | "FAILED" | "COMPENSATED"
74
+ StepStatus = str # "RUNNING" | "COMPLETED" | "FAILED" | "COMPENSATING" | "COMPENSATED"
75
+
76
+ _VALID_WORKFLOW_STATUS: frozenset[str] = frozenset(
77
+ {"RUNNING", "COMPLETED", "FAILED", "COMPENSATED"}
78
+ )
79
+ _VALID_STEP_STATUS: frozenset[str] = frozenset(
80
+ {"RUNNING", "COMPLETED", "FAILED", "COMPENSATING", "COMPENSATED"}
81
+ )
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Hashing helpers
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def _canon_bytes(obj: Any) -> bytes:
90
+ """Canonical CBOR serialisation. Used for argument + result freezing.
91
+
92
+ Falls back to a `repr()`-based encoding for Python types CBOR
93
+ cannot serialise (iterators, locks, file handles). Workflow bodies
94
+ SHOULD pass only CBOR-friendly arguments; the fallback exists so
95
+ the engine doesn't crash on incidental non-serialisable scaffolding
96
+ (test iterators, generator objects). The fallback is hash-stable
97
+ across the same process but NOT across processes — the determinism
98
+ guarantee remains "argument-bytes equal" + "result-bytes equal",
99
+ enforced by the args_hash gate during replay.
100
+ """
101
+ try:
102
+ return cbor2.dumps(obj, canonical=True)
103
+ except (cbor2.CBOREncodeError, TypeError):
104
+ return cbor2.dumps({"_repr": repr(obj)}, canonical=True)
105
+
106
+
107
+ def _h32(data: bytes) -> bytes:
108
+ return hashlib.sha256(data).digest()
109
+
110
+
111
+ def _step_leaf(workflow_id: str, step_id: str, body: bytes) -> bytes:
112
+ """Domain-tagged 32-byte leaf id for the transparency log."""
113
+ return _h32(
114
+ b"hm:wf-step:v1\x00" + workflow_id.encode() + b"\x00" + step_id.encode() + b"\x00" + body
115
+ )
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Records
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class Activity:
125
+ """Captured non-deterministic value (LLM output, clock, RNG, IO).
126
+
127
+ ``non_determinism_class`` SHOULD be one of {"LLM","RNG","CLOCK","IO"}
128
+ per spec §26 SHOULD-clause; engine does not enforce a closed set so
129
+ integrators can extend the taxonomy.
130
+ """
131
+
132
+ name: str
133
+ non_determinism_class: str
134
+ output_bytes: bytes
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class StepRecord:
139
+ step_id: str
140
+ step_name: str
141
+ args_hash: bytes
142
+ result_hash: bytes
143
+ result_bytes: bytes # frozen on COMPLETED
144
+ hlc: str
145
+ status: StepStatus
146
+ leaf_kid: bytes # transparency-log leaf id
147
+ activities: tuple[Activity, ...] = ()
148
+
149
+ def to_dict(self) -> dict[str, Any]:
150
+ return {
151
+ "step_id": self.step_id,
152
+ "step_name": self.step_name,
153
+ "args_hash": self.args_hash.hex(),
154
+ "result_hash": self.result_hash.hex(),
155
+ "result_bytes": self.result_bytes,
156
+ "hlc": self.hlc,
157
+ "status": self.status,
158
+ "leaf_kid": self.leaf_kid.hex(),
159
+ "activities": [
160
+ {
161
+ "name": a.name,
162
+ "non_determinism_class": a.non_determinism_class,
163
+ "output_bytes": a.output_bytes,
164
+ }
165
+ for a in self.activities
166
+ ],
167
+ }
168
+
169
+
170
+ @dataclass
171
+ class WorkflowState:
172
+ workflow_id: str
173
+ name: str
174
+ status: WorkflowStatus = "RUNNING"
175
+ steps: list[StepRecord] = field(default_factory=list)
176
+ started_hlc: str = ""
177
+ completed_hlc: str = ""
178
+ result_bytes: bytes = b""
179
+ result_hash: bytes = b""
180
+ error: str = ""
181
+
182
+ # Frozen-after-completion guard: once status leaves RUNNING we set
183
+ # _frozen and refuse further mutation.
184
+ _frozen: bool = False
185
+
186
+ def freeze(self) -> None:
187
+ self._frozen = True
188
+
189
+ def _check_mutable(self) -> None:
190
+ if self._frozen:
191
+ raise RuntimeError(
192
+ f"WorkflowState[{self.workflow_id}] is frozen ({self.status}); "
193
+ f"cannot mutate post-completion"
194
+ )
195
+
196
+ def to_dict(self) -> dict[str, Any]:
197
+ return {
198
+ "workflow_id": self.workflow_id,
199
+ "name": self.name,
200
+ "status": self.status,
201
+ "started_hlc": self.started_hlc,
202
+ "completed_hlc": self.completed_hlc,
203
+ "result_hash": self.result_hash.hex(),
204
+ "error": self.error,
205
+ "steps": [s.to_dict() for s in self.steps],
206
+ }
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Decorators
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ # Global registry: name -> (workflow_callable, compensators dict)
215
+ _WORKFLOW_REGISTRY: dict[str, dict[str, Any]] = {}
216
+
217
+
218
+ def workflow(
219
+ _fn: Callable[..., Awaitable[Any]] | None = None,
220
+ *,
221
+ name: str | None = None,
222
+ ) -> Any:
223
+ """Register a coroutine as a durable workflow.
224
+
225
+ Usage::
226
+
227
+ @workflow
228
+ async def my_wf(wf, payload): ...
229
+
230
+ @workflow(name="custom")
231
+ async def my_wf(wf, payload): ...
232
+ """
233
+
234
+ def _decorate(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
235
+ if not inspect.iscoroutinefunction(fn):
236
+ raise TypeError(f"@workflow requires an async function (got {fn!r})")
237
+ wf_name = name or fn.__name__
238
+ # Use setdefault so repeated decoration (e.g. test reload) doesn't
239
+ # blow away previously registered compensators.
240
+ entry = _WORKFLOW_REGISTRY.setdefault(wf_name, {"fn": fn, "compensators": {}})
241
+ entry["fn"] = fn
242
+ fn._hm_workflow_name = wf_name # type: ignore[attr-defined]
243
+ return fn
244
+
245
+ if _fn is None:
246
+ return _decorate
247
+ return _decorate(_fn)
248
+
249
+
250
+ def compensate(
251
+ step_name: str,
252
+ *,
253
+ workflow_name: str | None = None,
254
+ ) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
255
+ """Register a compensator for a step.
256
+
257
+ The compensator receives the step's recorded result_bytes and runs
258
+ in reverse order during failure unwinding.
259
+ """
260
+
261
+ def _decorate(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
262
+ if not inspect.iscoroutinefunction(fn):
263
+ raise TypeError(f"@compensate requires an async function (got {fn!r})")
264
+ # Late binding: the workflow name is the *currently being decorated*
265
+ # workflow if not explicit. Tests typically call @workflow before
266
+ # @compensate, so the registry already has the entry.
267
+ wf_name = workflow_name
268
+ if wf_name is None:
269
+ # Search for the most recently registered workflow.
270
+ if not _WORKFLOW_REGISTRY:
271
+ raise RuntimeError(
272
+ "@compensate must be paired with a @workflow; "
273
+ "register the workflow first or pass workflow_name=..."
274
+ )
275
+ wf_name = next(reversed(_WORKFLOW_REGISTRY))
276
+ _WORKFLOW_REGISTRY[wf_name]["compensators"][step_name] = fn
277
+ return fn
278
+
279
+ return _decorate
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Workflow runner
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ class _WorkflowContext:
288
+ """Handed to the user's workflow body — its `step` method drives the
289
+ durable step machinery."""
290
+
291
+ def __init__(
292
+ self,
293
+ state: WorkflowState,
294
+ transparency: TransparencyService,
295
+ compensators: dict[str, Callable[..., Awaitable[Any]]],
296
+ node_id: bytes,
297
+ replay_steps: list[StepRecord] | None = None,
298
+ ) -> None:
299
+ self.state = state
300
+ self.transparency = transparency
301
+ self.compensators = compensators
302
+ self._node_id = node_id
303
+ self._replay = list(replay_steps) if replay_steps else None
304
+ self._replay_idx = 0
305
+
306
+ async def step(
307
+ self,
308
+ step_name: str,
309
+ body: Callable[..., Awaitable[Any]],
310
+ *args: Any,
311
+ **kwargs: Any,
312
+ ) -> Any:
313
+ """Run a single durable step.
314
+
315
+ On first execution: record args_hash, call body, freeze result
316
+ bytes, append to transparency log, attach to state.
317
+
318
+ On replay: skip body and return the previously-recorded result.
319
+ """
320
+ args_blob = _canon_bytes({"args": list(args), "kwargs": kwargs})
321
+ args_hash = _h32(args_blob)
322
+
323
+ if self._replay is not None:
324
+ # Replay path — must match the next recorded step exactly.
325
+ if self._replay_idx >= len(self._replay):
326
+ raise RuntimeError(
327
+ f"replay overrun: workflow body issued step "
328
+ f"{step_name!r} but log has no more entries"
329
+ )
330
+ recorded = self._replay[self._replay_idx]
331
+ self._replay_idx += 1
332
+ if recorded.step_name != step_name:
333
+ raise RuntimeError(
334
+ f"replay divergence: log expects {recorded.step_name!r}, "
335
+ f"body issued {step_name!r}"
336
+ )
337
+ if recorded.args_hash != args_hash:
338
+ raise RuntimeError(
339
+ f"replay divergence: args_hash mismatch on step "
340
+ f"{step_name!r} (recorded {recorded.args_hash.hex()[:12]}, "
341
+ f"now {args_hash.hex()[:12]})"
342
+ )
343
+ # Reattach so caller can introspect.
344
+ self.state.steps.append(recorded)
345
+ return cbor2.loads(recorded.result_bytes) if recorded.result_bytes else None
346
+
347
+ # Live path.
348
+ step_id = secrets.token_hex(8)
349
+ try:
350
+ result = await body(*args, **kwargs)
351
+ except Exception as exc:
352
+ # Record FAILED step then re-raise; engine caller decides on
353
+ # compensation.
354
+ failed = StepRecord(
355
+ step_id=step_id,
356
+ step_name=step_name,
357
+ args_hash=args_hash,
358
+ result_hash=b"",
359
+ result_bytes=b"",
360
+ hlc=_hlc_str(self._node_id),
361
+ status="FAILED",
362
+ leaf_kid=_step_leaf(self.state.workflow_id, step_id, args_blob),
363
+ )
364
+ self.transparency.append_and_cosign(failed.leaf_kid)
365
+ self.state.steps.append(failed)
366
+ self.state.error = f"{step_name}: {exc!s}"
367
+ raise
368
+ result_bytes = _canon_bytes(result)
369
+ result_hash = _h32(result_bytes)
370
+ leaf_kid = _step_leaf(self.state.workflow_id, step_id, result_bytes)
371
+ record = StepRecord(
372
+ step_id=step_id,
373
+ step_name=step_name,
374
+ args_hash=args_hash,
375
+ result_hash=result_hash,
376
+ result_bytes=result_bytes,
377
+ hlc=_hlc_str(self._node_id),
378
+ status="COMPLETED",
379
+ leaf_kid=leaf_kid,
380
+ )
381
+ self.transparency.append_and_cosign(leaf_kid)
382
+ self.state.steps.append(record)
383
+ return result
384
+
385
+ async def activity(
386
+ self,
387
+ name: str,
388
+ body: Callable[..., Awaitable[Any]],
389
+ *args: Any,
390
+ non_determinism_class: str = "IO",
391
+ **kwargs: Any,
392
+ ) -> Any:
393
+ """Run a non-deterministic side-effect; capture output bytes.
394
+
395
+ On replay the captured bytes are returned unchanged. Use this
396
+ for LLM calls, ``time.time()``, ``random.random()``, network IO,
397
+ etc. The body itself is NOT re-executed during replay.
398
+ """
399
+ return await self.step(
400
+ f"activity:{name}",
401
+ body,
402
+ *args,
403
+ **kwargs,
404
+ )
405
+
406
+
407
+ class Workflow:
408
+ """Static façade for running and replaying durable workflows."""
409
+
410
+ _states: ClassVar[dict[str, WorkflowState]] = {}
411
+
412
+ @classmethod
413
+ async def run(
414
+ cls,
415
+ fn: Callable[..., Awaitable[Any]],
416
+ *args: Any,
417
+ transparency: TransparencyService | None = None,
418
+ node_id: bytes | None = None,
419
+ workflow_id: str | None = None,
420
+ **kwargs: Any,
421
+ ) -> WorkflowState:
422
+ """Execute a workflow. Returns its final state."""
423
+ wf_name = getattr(fn, "_hm_workflow_name", None)
424
+ if wf_name is None:
425
+ raise TypeError(f"{fn!r} is not a registered @workflow — apply @workflow first")
426
+ ts = transparency if transparency is not None else _default_ts(f"workflow-{wf_name}")
427
+ nid = node_id or secrets.token_bytes(8)
428
+ wid = workflow_id or secrets.token_hex(8)
429
+ state = WorkflowState(
430
+ workflow_id=wid,
431
+ name=wf_name,
432
+ status="RUNNING",
433
+ started_hlc=_hlc_str(nid),
434
+ )
435
+ ctx = _WorkflowContext(
436
+ state=state,
437
+ transparency=ts,
438
+ compensators=_WORKFLOW_REGISTRY[wf_name]["compensators"],
439
+ node_id=nid,
440
+ )
441
+ try:
442
+ result = await fn(ctx, *args, **kwargs)
443
+ except Exception:
444
+ state.status = "FAILED"
445
+ state.completed_hlc = _hlc_str(nid)
446
+ state.freeze()
447
+ cls._states[wid] = state
448
+ raise
449
+ state.result_bytes = _canon_bytes(result)
450
+ state.result_hash = _h32(state.result_bytes)
451
+ state.status = "COMPLETED"
452
+ state.completed_hlc = _hlc_str(nid)
453
+ state.freeze()
454
+ cls._states[wid] = state
455
+ return state
456
+
457
+ @classmethod
458
+ def replay(
459
+ cls,
460
+ workflow_id: str,
461
+ *,
462
+ transparency: TransparencyService | None = None,
463
+ ) -> WorkflowState:
464
+ """Re-execute the workflow body using captured step results.
465
+
466
+ Asserts identical outcome (same result_hash) compared to the
467
+ original run. The body is invoked in replay mode: every
468
+ ``wf.step`` call returns the recorded result without re-running
469
+ the underlying coroutine. This is how non-deterministic
470
+ Activities (LLM/RNG/clock/IO) stay deterministic across replay.
471
+ """
472
+ original = cls._states.get(workflow_id)
473
+ if original is None:
474
+ raise KeyError(f"unknown workflow_id {workflow_id!r}")
475
+ wf_entry = _WORKFLOW_REGISTRY.get(original.name)
476
+ if wf_entry is None:
477
+ raise KeyError(f"workflow {original.name!r} no longer registered")
478
+ ts = (
479
+ transparency
480
+ if transparency is not None
481
+ else _default_ts(f"workflow-replay-{original.name}")
482
+ )
483
+
484
+ replay_state = WorkflowState(
485
+ workflow_id=workflow_id,
486
+ name=original.name,
487
+ status="RUNNING",
488
+ started_hlc=original.started_hlc,
489
+ )
490
+ ctx = _WorkflowContext(
491
+ state=replay_state,
492
+ transparency=ts,
493
+ compensators=wf_entry["compensators"],
494
+ node_id=secrets.token_bytes(8),
495
+ replay_steps=original.steps,
496
+ )
497
+
498
+ # The user fn signature is (wf, *args, **kwargs); we don't have
499
+ # the original args saved on the WorkflowState, so we reconstruct
500
+ # by re-driving the body — which on replay only consumes recorded
501
+ # step results. The body must be argument-free under replay; for
502
+ # workflows with args, callers can pass them via the original
503
+ # closure (the canonical pattern: register a no-arg @workflow
504
+ # that closes over inputs).
505
+ async def _drive() -> Any:
506
+ try:
507
+ return await wf_entry["fn"](ctx)
508
+ except TypeError:
509
+ # Fallback: if the workflow expects extra args, the
510
+ # caller must use replay_with_args() — out of scope for
511
+ # the basic engine. Mark FAILED with explanatory error.
512
+ raise
513
+
514
+ result = (
515
+ asyncio.get_event_loop().run_until_complete(_drive())
516
+ if not _loop_running()
517
+ else _run_in_new_loop(_drive())
518
+ )
519
+
520
+ replay_state.result_bytes = _canon_bytes(result)
521
+ replay_state.result_hash = _h32(replay_state.result_bytes)
522
+ replay_state.status = "COMPLETED"
523
+ replay_state.completed_hlc = original.completed_hlc
524
+ replay_state.freeze()
525
+
526
+ # Determinism gate.
527
+ if replay_state.result_hash != original.result_hash:
528
+ raise RuntimeError(
529
+ f"replay determinism violation: original "
530
+ f"{original.result_hash.hex()[:12]} != "
531
+ f"replay {replay_state.result_hash.hex()[:12]}"
532
+ )
533
+ return replay_state
534
+
535
+ @classmethod
536
+ async def compensate(
537
+ cls,
538
+ workflow_id: str,
539
+ *,
540
+ transparency: TransparencyService | None = None,
541
+ ) -> WorkflowState:
542
+ """Run compensators for every COMPLETED step in reverse order.
543
+
544
+ Compensation records are appended to the transparency log with
545
+ ``status="COMPENSATING"``; on success the workflow status is
546
+ set to ``COMPENSATED``.
547
+ """
548
+ state = cls._states.get(workflow_id)
549
+ if state is None:
550
+ raise KeyError(f"unknown workflow_id {workflow_id!r}")
551
+ wf_entry = _WORKFLOW_REGISTRY.get(state.name)
552
+ if wf_entry is None:
553
+ raise KeyError(f"workflow {state.name!r} no longer registered")
554
+ compensators = wf_entry["compensators"]
555
+ ts = (
556
+ transparency if transparency is not None else _default_ts(f"workflow-comp-{state.name}")
557
+ )
558
+ nid = secrets.token_bytes(8)
559
+
560
+ # Unfreeze for the compensation pass — the audit-state we're
561
+ # appending is itself part of the workflow's lifecycle, not a
562
+ # post-completion mutation of the original record.
563
+ state._frozen = False
564
+ completed_in_order = [s for s in list(state.steps) if s.status == "COMPLETED"]
565
+ for original_step in reversed(completed_in_order):
566
+ comp_fn = compensators.get(original_step.step_name)
567
+ if comp_fn is None:
568
+ continue
569
+ step_id = secrets.token_hex(8)
570
+ try:
571
+ # Compensator receives the recorded result bytes (CBOR-decoded
572
+ # for ergonomic access).
573
+ payload = (
574
+ cbor2.loads(original_step.result_bytes) if original_step.result_bytes else None
575
+ )
576
+ comp_result = await comp_fn(payload)
577
+ except Exception as exc:
578
+ state.error = f"compensation failed at {original_step.step_name}: {exc!s}"
579
+ state.status = "FAILED"
580
+ state.freeze()
581
+ raise
582
+ comp_bytes = _canon_bytes(comp_result)
583
+ leaf_kid = _step_leaf(workflow_id, step_id, comp_bytes)
584
+ comp_record = StepRecord(
585
+ step_id=step_id,
586
+ step_name=f"compensate:{original_step.step_name}",
587
+ args_hash=original_step.result_hash,
588
+ result_hash=_h32(comp_bytes),
589
+ result_bytes=comp_bytes,
590
+ hlc=_hlc_str(nid),
591
+ status="COMPENSATING",
592
+ leaf_kid=leaf_kid,
593
+ )
594
+ ts.append_and_cosign(leaf_kid)
595
+ state.steps.append(comp_record)
596
+ state.status = "COMPENSATED"
597
+ state.completed_hlc = _hlc_str(nid)
598
+ state.freeze()
599
+ return state
600
+
601
+ @classmethod
602
+ def list_ids(cls) -> list[str]:
603
+ return list(cls._states.keys())
604
+
605
+ @classmethod
606
+ def get(cls, workflow_id: str) -> WorkflowState:
607
+ st = cls._states.get(workflow_id)
608
+ if st is None:
609
+ raise KeyError(workflow_id)
610
+ return st
611
+
612
+ @classmethod
613
+ def _reset_for_test(cls) -> None:
614
+ """Test-only: clear the in-process state registry."""
615
+ cls._states.clear()
616
+
617
+
618
+ # Helpers ------------------------------------------------------------------
619
+
620
+
621
+ def _loop_running() -> bool:
622
+ try:
623
+ asyncio.get_running_loop()
624
+ return True
625
+ except RuntimeError:
626
+ return False
627
+
628
+
629
+ def _run_in_new_loop(coro: Awaitable[Any]) -> Any:
630
+ """Run ``coro`` on a fresh event loop. Used by ``Workflow.replay``
631
+ when the caller is inside an active loop (pytest-asyncio).
632
+
633
+ We spin up a worker thread to avoid the "loop already running"
634
+ error — replay is by contract a synchronous boundary because it's
635
+ the auditor's invariant ("re-derive the answer from the log alone").
636
+ """
637
+ import threading
638
+
639
+ box: dict[str, Any] = {}
640
+
641
+ def _runner() -> None:
642
+ loop = asyncio.new_event_loop()
643
+ try:
644
+ box["result"] = loop.run_until_complete(coro)
645
+ except BaseException as e:
646
+ box["error"] = e
647
+ finally:
648
+ loop.close()
649
+
650
+ t = threading.Thread(target=_runner, daemon=True)
651
+ t.start()
652
+ t.join()
653
+ if "error" in box:
654
+ raise box["error"]
655
+ return box["result"]