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.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- 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"]
|