honestcode 0.5.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.
- honestcode/__init__.py +19 -0
- honestcode/cli.py +428 -0
- honestcode/graph/__init__.py +13 -0
- honestcode/graph/graph_store.py +276 -0
- honestcode/graph/watcher.py +116 -0
- honestcode/guards/__init__.py +104 -0
- honestcode/guards/guard.py +777 -0
- honestcode/guards/store.py +457 -0
- honestcode/guards/taxonomy.py +228 -0
- honestcode/honest/__init__.py +19 -0
- honestcode/honest/project_binding.py +200 -0
- honestcode/honest/router.py +205 -0
- honestcode/honest/symbol_index.py +446 -0
- honestcode/integrations/__init__.py +14 -0
- honestcode/integrations/pydantic_ai.py +304 -0
- honestcode/mcp/__init__.py +46 -0
- honestcode/mcp/knowledge_base.py +290 -0
- honestcode/mcp/server.py +357 -0
- honestcode/mcp/tools.py +2002 -0
- honestcode/py.typed +0 -0
- honestcode/sandbox/__init__.py +334 -0
- honestcode/structure/__init__.py +6 -0
- honestcode/structure/extractor.py +353 -0
- honestcode/structure/multi_lang.py +267 -0
- honestcode/structure/relations.py +47 -0
- honestcode/structure/utils.py +262 -0
- honestcode/verify/__init__.py +19 -0
- honestcode/verify/evidence.py +195 -0
- honestcode/verify/surface.py +497 -0
- honestcode/verify/verify.py +1074 -0
- honestcode-0.5.0.dist-info/METADATA +669 -0
- honestcode-0.5.0.dist-info/RECORD +36 -0
- honestcode-0.5.0.dist-info/WHEEL +5 -0
- honestcode-0.5.0.dist-info/entry_points.txt +3 -0
- honestcode-0.5.0.dist-info/licenses/LICENSE +21 -0
- honestcode-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
"""The side-effect guard: the crash-safe wrapper around an agent's tool calls.
|
|
2
|
+
|
|
3
|
+
Three defects in the earlier implementation this derives from are fixed here
|
|
4
|
+
rather than carried over, and each is called out at the site where it matters:
|
|
5
|
+
|
|
6
|
+
1. **The idempotency key was computed and discarded.** The earlier code wrote
|
|
7
|
+
``let _idempotency_key = ...`` and then compared raw ``request_hash``
|
|
8
|
+
values in memory. Here the key is derived, persisted, and enforced by a
|
|
9
|
+
``UNIQUE`` constraint, so it survives a restart — which is the only
|
|
10
|
+
situation in which idempotency is worth anything.
|
|
11
|
+
2. **Failure was recorded as success.** It set ``status = Committed``,
|
|
12
|
+
*then* checked ``if !result.success { status = Failed }``, so a throwing
|
|
13
|
+
executor left a committed record and the next attempt served a receipt for
|
|
14
|
+
work that never happened. The order is inverted here.
|
|
15
|
+
3. **The state machine had no gap between "not started" and "done".** This
|
|
16
|
+
module adds ``EXECUTING`` and commits it *before* the outside world is
|
|
17
|
+
touched, so a crash mid-call leaves evidence that the outcome is unknown
|
|
18
|
+
instead of evidence that nothing happened.
|
|
19
|
+
|
|
20
|
+
The public surface is two calls: :meth:`SideEffectGuard.run` for the common
|
|
21
|
+
case, and :meth:`SideEffectGuard.recover` after a restart.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import uuid
|
|
29
|
+
from contextlib import suppress
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
35
|
+
|
|
36
|
+
from honestcode.guards.store import (
|
|
37
|
+
COMMITTED,
|
|
38
|
+
COMPENSATED,
|
|
39
|
+
EXECUTING,
|
|
40
|
+
FAILED,
|
|
41
|
+
PENDING,
|
|
42
|
+
EffectRecord,
|
|
43
|
+
EffectStatus,
|
|
44
|
+
EffectStore,
|
|
45
|
+
StoreError,
|
|
46
|
+
)
|
|
47
|
+
from honestcode.guards.taxonomy import EffectClass, RecoveryStrategy, classify
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"ExecutionOutcome",
|
|
51
|
+
"GuardError",
|
|
52
|
+
"PreconditionFailed",
|
|
53
|
+
"EffectNotRunnable",
|
|
54
|
+
"RecoveryDecision",
|
|
55
|
+
"SideEffectGuard",
|
|
56
|
+
"durable_scope",
|
|
57
|
+
"precondition",
|
|
58
|
+
"strategy_for",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
T = TypeVar("T")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── errors ──────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GuardError(RuntimeError):
|
|
68
|
+
"""Base class for every refusal this module makes."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PreconditionFailed(GuardError):
|
|
72
|
+
"""A caller-supplied precondition refused the effect before it ran."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, reason: str, *, precondition: str = "") -> None:
|
|
75
|
+
self.reason = reason
|
|
76
|
+
self.precondition = precondition
|
|
77
|
+
detail = f"precondition failed: {reason}" if reason else "precondition failed"
|
|
78
|
+
if precondition:
|
|
79
|
+
detail += f" ({precondition})"
|
|
80
|
+
super().__init__(detail)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class EffectNotRunnable(GuardError):
|
|
84
|
+
"""The effect cannot be run in its current state.
|
|
85
|
+
|
|
86
|
+
Raised for a record that is already committed (retrieve its receipt
|
|
87
|
+
instead), already executing (another attempt owns it), or in an unknown
|
|
88
|
+
state after a crash.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, effect_id: str, status: EffectStatus, detail: str = "") -> None:
|
|
92
|
+
self.effect_id = effect_id
|
|
93
|
+
self.status = status
|
|
94
|
+
message = f"effect {effect_id} is {status}, not runnable"
|
|
95
|
+
if detail:
|
|
96
|
+
message += f": {detail}"
|
|
97
|
+
super().__init__(message)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── preconditions ───────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class Precondition:
|
|
105
|
+
"""A named check that must pass before an effect is allowed to run.
|
|
106
|
+
|
|
107
|
+
Upstream's implementation was ``if !precond.is_empty() && precond == "deny"``
|
|
108
|
+
— a comparison against one magic string, which meant every other
|
|
109
|
+
precondition value was silently ignored. A precondition that is silently
|
|
110
|
+
ignored is worse than no precondition at all, because it is documented
|
|
111
|
+
protection that does not exist.
|
|
112
|
+
|
|
113
|
+
The contract here: ``check`` is called with the caller's context mapping and
|
|
114
|
+
returns truthy to permit the effect. ``reason`` is what a refused caller is
|
|
115
|
+
told, so it should name the actual limit — "budget exhausted", not "denied".
|
|
116
|
+
A precondition that raises is treated as a refusal, because a check that
|
|
117
|
+
cannot complete has not established that the effect is safe.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
name: str
|
|
121
|
+
check: Callable[[Mapping[str, Any]], bool]
|
|
122
|
+
reason: str = ""
|
|
123
|
+
|
|
124
|
+
def evaluate(self, context: Mapping[str, Any]) -> None:
|
|
125
|
+
try:
|
|
126
|
+
allowed = bool(self.check(context))
|
|
127
|
+
except Exception as exc: # noqa: BLE001 - a failed check is not a pass
|
|
128
|
+
raise PreconditionFailed(f"precondition raised: {exc}", precondition=self.name) from exc
|
|
129
|
+
if not allowed:
|
|
130
|
+
raise PreconditionFailed(
|
|
131
|
+
self.reason or f"{self.name} refused the effect", precondition=self.name
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def precondition(
|
|
136
|
+
name: str, reason: str = ""
|
|
137
|
+
) -> Callable[[Callable[[Mapping[str, Any]], bool]], Precondition]:
|
|
138
|
+
"""Decorator form: ``@precondition("budget", "budget exhausted")``."""
|
|
139
|
+
|
|
140
|
+
def wrap(fn: Callable[[Mapping[str, Any]], bool]) -> Precondition:
|
|
141
|
+
return Precondition(name=name, check=fn, reason=reason)
|
|
142
|
+
|
|
143
|
+
return wrap
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── results ─────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class ExecutionOutcome:
|
|
151
|
+
"""What :meth:`SideEffectGuard.run` returns.
|
|
152
|
+
|
|
153
|
+
``executed`` distinguishes "your callable ran this time" from "we served a
|
|
154
|
+
receipt for a call that already happened". That distinction is the entire
|
|
155
|
+
product: a caller can log, bill, or branch on it, and cannot get it wrong by
|
|
156
|
+
accident.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
record: EffectRecord
|
|
160
|
+
value: Any = None
|
|
161
|
+
executed: bool = False
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def effect_id(self) -> str:
|
|
165
|
+
return self.record.effect_id
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def status(self) -> EffectStatus:
|
|
169
|
+
return self.record.status
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def deduplicated(self) -> bool:
|
|
173
|
+
"""True when this call adopted an existing effect instead of running one."""
|
|
174
|
+
return not self.executed
|
|
175
|
+
|
|
176
|
+
def to_dict(self) -> dict[str, Any]:
|
|
177
|
+
out = self.record.to_dict()
|
|
178
|
+
out["executed"] = self.executed
|
|
179
|
+
if self.value is not None:
|
|
180
|
+
out["value"] = self.value
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class RecoveryDecision:
|
|
186
|
+
"""What to do about an effect whose fate is unknown after a restart."""
|
|
187
|
+
|
|
188
|
+
record: EffectRecord
|
|
189
|
+
strategy: RecoveryStrategy
|
|
190
|
+
action: str
|
|
191
|
+
detail: str
|
|
192
|
+
|
|
193
|
+
def to_dict(self) -> dict[str, Any]:
|
|
194
|
+
return {
|
|
195
|
+
"effect_id": self.record.effect_id,
|
|
196
|
+
"status": self.record.status.bare,
|
|
197
|
+
"effect_class": self.record.effect_class.bare,
|
|
198
|
+
"strategy": self.strategy.bare,
|
|
199
|
+
"action": self.action,
|
|
200
|
+
"detail": self.detail,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── the guard ───────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _hash_payload(payload: Any) -> str:
|
|
208
|
+
"""Stable content hash for the effect's arguments.
|
|
209
|
+
|
|
210
|
+
``sort_keys`` plus ``default=repr`` so a dict with mixed value types still
|
|
211
|
+
hashes deterministically — an unstable hash would make every retry look
|
|
212
|
+
like a new effect, which defeats the guard.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
blob = json.dumps(payload, sort_keys=True, default=repr, separators=(",", ":"))
|
|
216
|
+
except (TypeError, ValueError):
|
|
217
|
+
blob = repr(payload)
|
|
218
|
+
return hashlib.blake2b(blob.encode("utf-8"), digest_size=16).hexdigest()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _idempotency_key(session_id: str, action_type: str, target: str, payload: Any) -> str:
|
|
222
|
+
"""The key that makes an effect idempotent *within a session*.
|
|
223
|
+
|
|
224
|
+
Derived from *what the effect is*, not from when it was requested, so two
|
|
225
|
+
attempts at the same logical action produce the same key and the second one
|
|
226
|
+
is recognised. Upstream's ``{session_hex}:{request_hash}`` had the right
|
|
227
|
+
shape; it just never used it.
|
|
228
|
+
|
|
229
|
+
Session-scoped, so it answers "has this session already done this". For the
|
|
230
|
+
cross-restart question use :func:`_scope_key` via ``run(scope=...)``.
|
|
231
|
+
"""
|
|
232
|
+
digest = hashlib.blake2b(
|
|
233
|
+
f"{action_type}\x00{target}\x00{_hash_payload(payload)}".encode(),
|
|
234
|
+
digest_size=16,
|
|
235
|
+
).hexdigest()
|
|
236
|
+
return f"{session_id}:{digest}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def durable_scope(run_id: str, *parts: str) -> str:
|
|
240
|
+
"""Build the ``scope`` argument for :meth:`SideEffectGuard.run`.
|
|
241
|
+
|
|
242
|
+
``run_id`` is the caller's durability boundary — the identifier that means
|
|
243
|
+
the same thing after a crash. A workflow run id, a job id, an order id:
|
|
244
|
+
whatever the caller can reproduce on the retry. It must *not* be the session
|
|
245
|
+
id, because a restart produces a new session and the whole point is to
|
|
246
|
+
recognise the retry as the same effect.
|
|
247
|
+
|
|
248
|
+
The remaining ``parts`` narrow the scope to one effect within that run.
|
|
249
|
+
|
|
250
|
+
**Scope is authoritative.** Passing a scope makes effect identity depend on
|
|
251
|
+
the scope and the effect's own arguments, and *not* on the session — so the
|
|
252
|
+
same scope in a different session resolves to the same record. That is what
|
|
253
|
+
makes a crash-and-retry safe. It also means the caller owns the isolation
|
|
254
|
+
question: if two independent agents must not share dedup state, they must
|
|
255
|
+
have different ``run_id``s.
|
|
256
|
+
"""
|
|
257
|
+
if not run_id:
|
|
258
|
+
raise ValueError("run_id must not be empty")
|
|
259
|
+
suffix = _hash_payload(list(parts)) if parts else ""
|
|
260
|
+
return f"{run_id}:{suffix}" if suffix else run_id
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _scope_namespace(scope: str) -> str:
|
|
264
|
+
"""The record-view namespace for a scoped effect.
|
|
265
|
+
|
|
266
|
+
Scoped effects are stored under a namespace derived from the scope rather
|
|
267
|
+
than from the session id, because the session is exactly what changes across
|
|
268
|
+
a restart. It is namespaced rather than left empty so one store can hold
|
|
269
|
+
both scoped and session-scoped effects without the two kinds colliding.
|
|
270
|
+
"""
|
|
271
|
+
digest = hashlib.blake2b(scope.encode(), digest_size=16).hexdigest()
|
|
272
|
+
return f"scope-ns:{digest}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _scope_key(action_type: str, target: str, payload: Any) -> str:
|
|
276
|
+
"""Key for a scoped effect. Identity comes from the effect itself; the
|
|
277
|
+
scope lives in the namespace, so the two together are the full identity."""
|
|
278
|
+
digest = hashlib.blake2b(
|
|
279
|
+
f"{action_type}\x00{target}\x00{_hash_payload(payload)}".encode(),
|
|
280
|
+
digest_size=16,
|
|
281
|
+
).hexdigest()
|
|
282
|
+
return f"scope:{digest}"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class SideEffectGuard:
|
|
286
|
+
"""Wraps instrumented tool calls so that repeating one is safe.
|
|
287
|
+
|
|
288
|
+
Usage::
|
|
289
|
+
|
|
290
|
+
guard = SideEffectGuard(session_id="sess-1", store=EffectStore("effects.db"))
|
|
291
|
+
|
|
292
|
+
outcome = guard.run(
|
|
293
|
+
"charge_card",
|
|
294
|
+
target="cus_123",
|
|
295
|
+
payload={"amount_cents": 100},
|
|
296
|
+
execute=lambda: stripe.charge(customer="cus_123", amount=100),
|
|
297
|
+
preconditions=[within_budget],
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if outcome.deduplicated:
|
|
301
|
+
log.info("already charged, reusing receipt %s", outcome.effect_id)
|
|
302
|
+
|
|
303
|
+
One guard instance owns one session. Every method is safe to call
|
|
304
|
+
concurrently; the store serialises writes and the in-process lock covers the
|
|
305
|
+
read-modify-write sequences.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def __init__(
|
|
309
|
+
self,
|
|
310
|
+
*,
|
|
311
|
+
session_id: str,
|
|
312
|
+
store: EffectStore | None = None,
|
|
313
|
+
store_path: str = ":memory:",
|
|
314
|
+
approve_irreversible: bool = False,
|
|
315
|
+
) -> None:
|
|
316
|
+
if not session_id:
|
|
317
|
+
raise ValueError("session_id must not be empty")
|
|
318
|
+
self.session_id = session_id
|
|
319
|
+
self.store = store if store is not None else EffectStore(store_path)
|
|
320
|
+
self.approve_irreversible = approve_irreversible
|
|
321
|
+
|
|
322
|
+
def close(self) -> None:
|
|
323
|
+
self.store.close()
|
|
324
|
+
|
|
325
|
+
# ── the main path ───────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
def run(
|
|
328
|
+
self,
|
|
329
|
+
action_type: str,
|
|
330
|
+
*,
|
|
331
|
+
execute: Callable[[], T],
|
|
332
|
+
target: str = "",
|
|
333
|
+
payload: Any = None,
|
|
334
|
+
effect_class: EffectClass | None = None,
|
|
335
|
+
preconditions: tuple[Precondition, ...] = (),
|
|
336
|
+
context: Mapping[str, Any] | None = None,
|
|
337
|
+
approve_irreversible: bool | None = None,
|
|
338
|
+
scope: str | None = None,
|
|
339
|
+
) -> ExecutionOutcome:
|
|
340
|
+
"""Run *execute* at most once for this logical effect.
|
|
341
|
+
|
|
342
|
+
Returns an :class:`ExecutionOutcome` whose ``executed`` flag says
|
|
343
|
+
whether the callable ran. Raises :class:`PreconditionFailed` before
|
|
344
|
+
touching the outside world if any precondition refuses.
|
|
345
|
+
|
|
346
|
+
``scope`` is the durability boundary. When omitted the key is derived
|
|
347
|
+
from the session, which is right for "has *this session* already done
|
|
348
|
+
this". Pass it when the effect must stay recognisable across sessions —
|
|
349
|
+
a crash and restart produces a new session id, and a key derived from
|
|
350
|
+
the session would therefore treat the retry as a brand-new effect, which
|
|
351
|
+
is exactly the case this guard exists for. See
|
|
352
|
+
:func:`durable_scope` for the usual construction.
|
|
353
|
+
"""
|
|
354
|
+
resolved = effect_class or classify(action_type, target)
|
|
355
|
+
approved = (
|
|
356
|
+
self.approve_irreversible if approve_irreversible is None else approve_irreversible
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
record, created = self._claim(
|
|
360
|
+
action_type=action_type,
|
|
361
|
+
target=target,
|
|
362
|
+
payload=payload,
|
|
363
|
+
effect_class=resolved,
|
|
364
|
+
preconditions=preconditions,
|
|
365
|
+
context=context or {},
|
|
366
|
+
approve_irreversible=approved,
|
|
367
|
+
scope=scope,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if not created:
|
|
371
|
+
return self._adopt(record)
|
|
372
|
+
|
|
373
|
+
# The effect is claimed and durable. Failures past this point must not
|
|
374
|
+
# leave the record looking unstarted.
|
|
375
|
+
try:
|
|
376
|
+
self.store.set_status(record.effect_id, EXECUTING)
|
|
377
|
+
value = execute()
|
|
378
|
+
except Exception:
|
|
379
|
+
self._mark_failed(record.effect_id)
|
|
380
|
+
raise
|
|
381
|
+
|
|
382
|
+
committed = self.store.set_status(
|
|
383
|
+
record.effect_id,
|
|
384
|
+
COMMITTED,
|
|
385
|
+
response_hash=_hash_payload(_receipt_value(value)),
|
|
386
|
+
)
|
|
387
|
+
return ExecutionOutcome(record=committed, value=value, executed=True)
|
|
388
|
+
|
|
389
|
+
async def arun(
|
|
390
|
+
self,
|
|
391
|
+
action_type: str,
|
|
392
|
+
*,
|
|
393
|
+
execute: Callable[[], Awaitable[T]],
|
|
394
|
+
target: str = "",
|
|
395
|
+
payload: Any = None,
|
|
396
|
+
effect_class: EffectClass | None = None,
|
|
397
|
+
preconditions: tuple[Precondition, ...] = (),
|
|
398
|
+
context: Mapping[str, Any] | None = None,
|
|
399
|
+
approve_irreversible: bool | None = None,
|
|
400
|
+
scope: str | None = None,
|
|
401
|
+
) -> ExecutionOutcome:
|
|
402
|
+
"""Await *execute* at most once for this logical effect.
|
|
403
|
+
|
|
404
|
+
The same lifecycle as :meth:`run`, for callers that are already async.
|
|
405
|
+
The claim is written *before* the await, so a process that dies while the
|
|
406
|
+
coroutine is suspended leaves an ``executing`` record — the outcome is
|
|
407
|
+
recorded as unknown rather than as never-happened, which is what stops
|
|
408
|
+
the next attempt from repeating an external mutation.
|
|
409
|
+
|
|
410
|
+
Use this rather than :meth:`run` from async code. Because ``run`` is
|
|
411
|
+
synchronous it has to drive an awaitable to completion itself, which it
|
|
412
|
+
cannot do on a loop that is already running.
|
|
413
|
+
"""
|
|
414
|
+
resolved = effect_class or classify(action_type, target)
|
|
415
|
+
approved = (
|
|
416
|
+
self.approve_irreversible if approve_irreversible is None else approve_irreversible
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
record, created = self._claim(
|
|
420
|
+
action_type=action_type,
|
|
421
|
+
target=target,
|
|
422
|
+
payload=payload,
|
|
423
|
+
effect_class=resolved,
|
|
424
|
+
preconditions=preconditions,
|
|
425
|
+
context=context or {},
|
|
426
|
+
approve_irreversible=approved,
|
|
427
|
+
scope=scope,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if not created:
|
|
431
|
+
return self._adopt(record)
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
self.store.set_status(record.effect_id, EXECUTING)
|
|
435
|
+
value = await execute()
|
|
436
|
+
except Exception:
|
|
437
|
+
self._mark_failed(record.effect_id)
|
|
438
|
+
raise
|
|
439
|
+
|
|
440
|
+
committed = self.store.set_status(
|
|
441
|
+
record.effect_id,
|
|
442
|
+
COMMITTED,
|
|
443
|
+
response_hash=_hash_payload(_receipt_value(value)),
|
|
444
|
+
)
|
|
445
|
+
return ExecutionOutcome(record=committed, value=value, executed=True)
|
|
446
|
+
|
|
447
|
+
def _claim(
|
|
448
|
+
self,
|
|
449
|
+
*,
|
|
450
|
+
action_type: str,
|
|
451
|
+
target: str,
|
|
452
|
+
payload: Any,
|
|
453
|
+
effect_class: EffectClass,
|
|
454
|
+
preconditions: tuple[Precondition, ...],
|
|
455
|
+
context: Mapping[str, Any],
|
|
456
|
+
approve_irreversible: bool,
|
|
457
|
+
scope: str | None = None,
|
|
458
|
+
) -> tuple[EffectRecord, bool]:
|
|
459
|
+
"""Reserve the effect, or return the existing record for it.
|
|
460
|
+
|
|
461
|
+
Order matters: the durable claim happens *before* preconditions are
|
|
462
|
+
evaluated only in the sense that a duplicate is detected first. A
|
|
463
|
+
refusal raises before any record is written, so a precondition that
|
|
464
|
+
fails does not consume the idempotency key.
|
|
465
|
+
"""
|
|
466
|
+
if scope:
|
|
467
|
+
# Scope is authoritative: the record lives in a namespace derived
|
|
468
|
+
# from the scope, so the same scope resolves to the same record
|
|
469
|
+
# regardless of which session is asking. Using the session id here
|
|
470
|
+
# would defeat the purpose, since a restart is precisely a new
|
|
471
|
+
# session id.
|
|
472
|
+
session_id = _scope_namespace(scope)
|
|
473
|
+
key = _scope_key(action_type, target, payload)
|
|
474
|
+
else:
|
|
475
|
+
session_id = self.session_id
|
|
476
|
+
key = _idempotency_key(self.session_id, action_type, target, payload)
|
|
477
|
+
|
|
478
|
+
existing = self.store.find_by_key(session_id, key)
|
|
479
|
+
if existing is not None and existing.is_active:
|
|
480
|
+
return existing, False
|
|
481
|
+
|
|
482
|
+
self._check_preconditions(effect_class, preconditions, context, approve_irreversible)
|
|
483
|
+
|
|
484
|
+
if existing is not None:
|
|
485
|
+
# A previous attempt failed or was compensated. Re-issuing a failed
|
|
486
|
+
# irreversible effect would be a second real-world event, so the
|
|
487
|
+
# caller is made to look at it rather than having it silently
|
|
488
|
+
# retried.
|
|
489
|
+
if existing.status == FAILED and effect_class is EffectClass.IRREVERSIBLE:
|
|
490
|
+
raise EffectNotRunnable(
|
|
491
|
+
existing.effect_id,
|
|
492
|
+
existing.status,
|
|
493
|
+
"an irreversible effect that already failed must be resolved by "
|
|
494
|
+
"hand — its partial effects cannot be assumed away",
|
|
495
|
+
)
|
|
496
|
+
# The transition is conditional on the status we just read: two
|
|
497
|
+
# concurrent retries both used to see FAILED, both reset it to
|
|
498
|
+
# PENDING, and both ran the effect. Only the caller whose UPDATE
|
|
499
|
+
# changes a row may execute.
|
|
500
|
+
if not self.store.transition_if_status(
|
|
501
|
+
existing.effect_id, (FAILED, COMPENSATED), PENDING
|
|
502
|
+
):
|
|
503
|
+
winner = self.store.find_by_key(session_id, key)
|
|
504
|
+
if winner is None: # pragma: no cover - row cannot vanish
|
|
505
|
+
raise StoreError(f"effect key {key} vanished during retry")
|
|
506
|
+
return winner, False
|
|
507
|
+
return self.store.get(existing.effect_id) or existing, True
|
|
508
|
+
|
|
509
|
+
effect_id = f"se_{uuid.uuid4().hex}"
|
|
510
|
+
inserted = self.store.insert(
|
|
511
|
+
effect_id=effect_id,
|
|
512
|
+
session_id=session_id,
|
|
513
|
+
idempotency_key=key,
|
|
514
|
+
action_type=action_type,
|
|
515
|
+
target=target,
|
|
516
|
+
effect_class=effect_class,
|
|
517
|
+
payload_hash=_hash_payload(payload),
|
|
518
|
+
preconditions=tuple(p.name for p in preconditions),
|
|
519
|
+
scope=scope,
|
|
520
|
+
)
|
|
521
|
+
if inserted is None:
|
|
522
|
+
# Lost a race with a concurrent caller for the same effect. Adopt
|
|
523
|
+
# the winner's record; do not run.
|
|
524
|
+
winner = self.store.find_by_key(session_id, key)
|
|
525
|
+
if winner is None: # pragma: no cover - would mean the schema lied
|
|
526
|
+
raise StoreError(f"effect key {key} vanished after a uniqueness conflict")
|
|
527
|
+
return winner, False
|
|
528
|
+
return inserted, True
|
|
529
|
+
|
|
530
|
+
def _check_preconditions(
|
|
531
|
+
self,
|
|
532
|
+
effect_class: EffectClass,
|
|
533
|
+
preconditions: tuple[Precondition, ...],
|
|
534
|
+
context: Mapping[str, Any],
|
|
535
|
+
approve_irreversible: bool,
|
|
536
|
+
) -> None:
|
|
537
|
+
if effect_class is EffectClass.IRREVERSIBLE and not approve_irreversible:
|
|
538
|
+
raise PreconditionFailed(
|
|
539
|
+
"irreversible effects require explicit approval "
|
|
540
|
+
"(pass approve_irreversible=True once the action has been reviewed)",
|
|
541
|
+
precondition="irreversible_approval",
|
|
542
|
+
)
|
|
543
|
+
for check in preconditions:
|
|
544
|
+
check.evaluate(context)
|
|
545
|
+
|
|
546
|
+
def _adopt(self, record: EffectRecord) -> ExecutionOutcome:
|
|
547
|
+
"""Serve an existing effect instead of running it again."""
|
|
548
|
+
if record.status == COMMITTED:
|
|
549
|
+
return ExecutionOutcome(record=record, value=record.response_hash, executed=False)
|
|
550
|
+
if record.status == EXECUTING:
|
|
551
|
+
# Claimed before a crash. The outside world may already have been
|
|
552
|
+
# changed, so this is not a safe place to resume automatically.
|
|
553
|
+
raise EffectNotRunnable(
|
|
554
|
+
record.effect_id,
|
|
555
|
+
record.status,
|
|
556
|
+
"a previous attempt may have reached the outside world; call "
|
|
557
|
+
"recover() — or recover(scope=...) for a scoped effect — to get "
|
|
558
|
+
"a decision for this effect",
|
|
559
|
+
)
|
|
560
|
+
raise EffectNotRunnable(record.effect_id, record.status, "the effect is already claimed")
|
|
561
|
+
|
|
562
|
+
def _mark_failed(self, effect_id: str) -> None:
|
|
563
|
+
with suppress(StoreError): # store gone mid-flight
|
|
564
|
+
self.store.set_status(effect_id, FAILED)
|
|
565
|
+
|
|
566
|
+
# ── recovery ────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
def recover(
|
|
569
|
+
self, session_id: str | None = None, *, scope: str | None = None
|
|
570
|
+
) -> list[RecoveryDecision]:
|
|
571
|
+
"""Decide what to do about every effect whose outcome is unknown.
|
|
572
|
+
|
|
573
|
+
Call this at session start, before any new work. Each decision names the
|
|
574
|
+
action that is safe for that effect's class, and the caller is expected
|
|
575
|
+
to carry it out or escalate — this method deliberately does not execute
|
|
576
|
+
anything, because the safe action for an irreversible effect is a human
|
|
577
|
+
looking at the vendor's dashboard.
|
|
578
|
+
|
|
579
|
+
Pass *scope* (the same value given to :meth:`run`) to recover effects
|
|
580
|
+
that were written under ``durable_scope``. Those records live in a
|
|
581
|
+
namespace derived from the scope, not under the current session id, so
|
|
582
|
+
the session-only query used to return nothing for exactly the
|
|
583
|
+
cross-restart case the scope exists to make safe.
|
|
584
|
+
"""
|
|
585
|
+
if scope is not None:
|
|
586
|
+
records = self.store.unresolved(scope=scope)
|
|
587
|
+
else:
|
|
588
|
+
sid = session_id or self.session_id
|
|
589
|
+
records = self.store.unresolved(sid)
|
|
590
|
+
return [self.decide(record) for record in records]
|
|
591
|
+
|
|
592
|
+
def decide(self, record: EffectRecord) -> RecoveryDecision:
|
|
593
|
+
"""Map one record to its recovery action.
|
|
594
|
+
|
|
595
|
+
The table is the port of upstream's ``recover_effect``, with the
|
|
596
|
+
strategy/action split kept so callers can group by class (``strategy``)
|
|
597
|
+
while still seeing the concrete instruction (``action``).
|
|
598
|
+
"""
|
|
599
|
+
cls = record.effect_class
|
|
600
|
+
if record.status == COMMITTED:
|
|
601
|
+
return RecoveryDecision(
|
|
602
|
+
record,
|
|
603
|
+
RecoveryStrategy.REPLAY,
|
|
604
|
+
"use_cached",
|
|
605
|
+
"the effect already ran; serve the recorded response and do not call again",
|
|
606
|
+
)
|
|
607
|
+
if record.status == COMPENSATED:
|
|
608
|
+
return RecoveryDecision(
|
|
609
|
+
record,
|
|
610
|
+
strategy_for(cls),
|
|
611
|
+
"replay",
|
|
612
|
+
"the effect was undone; running it again is safe",
|
|
613
|
+
)
|
|
614
|
+
if record.status == FAILED:
|
|
615
|
+
if cls is EffectClass.IRREVERSIBLE:
|
|
616
|
+
return RecoveryDecision(
|
|
617
|
+
record,
|
|
618
|
+
RecoveryStrategy.QUERY_AND_CONFIRM,
|
|
619
|
+
"query_external",
|
|
620
|
+
"an irreversible effect that failed may have partially applied; "
|
|
621
|
+
"confirm against the external system before any retry",
|
|
622
|
+
)
|
|
623
|
+
return RecoveryDecision(
|
|
624
|
+
record, strategy_for(cls), "retry", "the effect raised before committing"
|
|
625
|
+
)
|
|
626
|
+
# PENDING or EXECUTING: the crash case.
|
|
627
|
+
if cls is EffectClass.IRREVERSIBLE:
|
|
628
|
+
return RecoveryDecision(
|
|
629
|
+
record,
|
|
630
|
+
RecoveryStrategy.QUERY_AND_CONFIRM,
|
|
631
|
+
"query_external",
|
|
632
|
+
"the outcome of an irreversible effect is unknown; confirm against "
|
|
633
|
+
"the external system before retrying — retrying blindly risks a "
|
|
634
|
+
"second real-world event",
|
|
635
|
+
)
|
|
636
|
+
if cls is EffectClass.REVERSIBLE:
|
|
637
|
+
if record.compensation is not None:
|
|
638
|
+
return RecoveryDecision(
|
|
639
|
+
record,
|
|
640
|
+
RecoveryStrategy.COMPENSATE,
|
|
641
|
+
"compensate_and_replay",
|
|
642
|
+
"compensation data is recorded; undo the partial change first",
|
|
643
|
+
)
|
|
644
|
+
return RecoveryDecision(
|
|
645
|
+
record,
|
|
646
|
+
RecoveryStrategy.REPLAY,
|
|
647
|
+
"replay",
|
|
648
|
+
"reversible with no compensation data; replaying converges on the intended state",
|
|
649
|
+
)
|
|
650
|
+
# PURE and IDEMPOTENT.
|
|
651
|
+
return RecoveryDecision(
|
|
652
|
+
record,
|
|
653
|
+
RecoveryStrategy.REPLAY,
|
|
654
|
+
"replay",
|
|
655
|
+
"safe to repeat without limit",
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
def resolve(
|
|
659
|
+
self,
|
|
660
|
+
effect_id: str,
|
|
661
|
+
status: EffectStatus,
|
|
662
|
+
*,
|
|
663
|
+
response_hash: str | None = None,
|
|
664
|
+
) -> EffectRecord:
|
|
665
|
+
"""Close out an effect by hand after a recovery decision.
|
|
666
|
+
|
|
667
|
+
This is the human-in-the-loop end of :meth:`recover`: an operator who
|
|
668
|
+
has checked the vendor dashboard calls this to record what they found.
|
|
669
|
+
"""
|
|
670
|
+
return self.store.set_status(effect_id, status, response_hash=response_hash)
|
|
671
|
+
|
|
672
|
+
async def recovered_retry(
|
|
673
|
+
self,
|
|
674
|
+
effect_id: str,
|
|
675
|
+
*,
|
|
676
|
+
execute: Callable[[], Awaitable[T]],
|
|
677
|
+
approve_irreversible: bool = True,
|
|
678
|
+
) -> ExecutionOutcome:
|
|
679
|
+
"""Retry an effect whose outcome an operator has already resolved.
|
|
680
|
+
|
|
681
|
+
The missing step between :meth:`recover` and getting on with the work.
|
|
682
|
+
:meth:`recover` yields decisions; an operator acts on one; this carries
|
|
683
|
+
the effect through under the answer they reached.
|
|
684
|
+
|
|
685
|
+
What it refuses is the whole point:
|
|
686
|
+
|
|
687
|
+
* ``committed`` — returns the existing record without running. Re-running
|
|
688
|
+
a completed effect is the one thing this module exists to prevent.
|
|
689
|
+
* ``executing`` — raises. Nobody resolved it, and an unknown outcome is
|
|
690
|
+
exactly what must not be retried blind: it may already have reached
|
|
691
|
+
the outside world.
|
|
692
|
+
* ``pending`` — raises. :meth:`run` handles a fresh attempt; going
|
|
693
|
+
through this method would skip the guard's own bookkeeping.
|
|
694
|
+
|
|
695
|
+
``approve_irreversible`` defaults to **True** here, unlike :meth:`run`.
|
|
696
|
+
That is deliberate and narrow: this method requires an ``effect_id``,
|
|
697
|
+
which only comes from a recovery review, so the approval already
|
|
698
|
+
happened in the real world and re-asking would be theatre. A caller who
|
|
699
|
+
did not do that review gains nothing, because they cannot name the effect.
|
|
700
|
+
"""
|
|
701
|
+
record = self.store.get(effect_id)
|
|
702
|
+
if record is None:
|
|
703
|
+
raise StoreError(f"no such effect: {effect_id}")
|
|
704
|
+
|
|
705
|
+
if record.status == COMMITTED:
|
|
706
|
+
return ExecutionOutcome(record=record, value=record.response_hash, executed=False)
|
|
707
|
+
if record.status == EXECUTING:
|
|
708
|
+
raise EffectNotRunnable(
|
|
709
|
+
effect_id,
|
|
710
|
+
record.status,
|
|
711
|
+
"this effect has not been resolved — its outcome is still unknown. "
|
|
712
|
+
"Confirm against the external system with resolve() first",
|
|
713
|
+
)
|
|
714
|
+
if record.status == PENDING:
|
|
715
|
+
raise EffectNotRunnable(
|
|
716
|
+
effect_id,
|
|
717
|
+
record.status,
|
|
718
|
+
"use run() for a fresh attempt; this method is for an effect a "
|
|
719
|
+
"recovery review has already settled",
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# FAILED or COMPENSATED: both mean "did not land", so retrying is safe.
|
|
723
|
+
self.store.set_status(effect_id, PENDING)
|
|
724
|
+
self.store.set_status(effect_id, EXECUTING)
|
|
725
|
+
try:
|
|
726
|
+
value = await execute()
|
|
727
|
+
except Exception:
|
|
728
|
+
self._mark_failed(effect_id)
|
|
729
|
+
raise
|
|
730
|
+
|
|
731
|
+
committed = self.store.set_status(
|
|
732
|
+
effect_id,
|
|
733
|
+
COMMITTED,
|
|
734
|
+
response_hash=_hash_payload(_receipt_value(value)),
|
|
735
|
+
)
|
|
736
|
+
return ExecutionOutcome(record=committed, value=value, executed=True)
|
|
737
|
+
|
|
738
|
+
# ── introspection ───────────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
def effects(
|
|
741
|
+
self, *, status: EffectStatus | None = None, scope: str | None = None
|
|
742
|
+
) -> list[EffectRecord]:
|
|
743
|
+
if scope is not None:
|
|
744
|
+
return self.store.list_records(status=status, scope=scope)
|
|
745
|
+
return self.store.list_records(session_id=self.session_id, status=status)
|
|
746
|
+
|
|
747
|
+
def summary(self, *, scope: str | None = None) -> dict[str, int]:
|
|
748
|
+
if scope is not None:
|
|
749
|
+
return self.store.count_by_status(scope=scope)
|
|
750
|
+
return self.store.count_by_status(self.session_id)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def strategy_for(effect_class: EffectClass) -> RecoveryStrategy:
|
|
754
|
+
"""Class → strategy. Port of ``SideEffectClass::recovery_strategy``."""
|
|
755
|
+
if effect_class in (EffectClass.PURE, EffectClass.IDEMPOTENT):
|
|
756
|
+
return RecoveryStrategy.REPLAY
|
|
757
|
+
if effect_class is EffectClass.REVERSIBLE:
|
|
758
|
+
return RecoveryStrategy.COMPENSATE
|
|
759
|
+
return RecoveryStrategy.QUERY_AND_CONFIRM
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _receipt_value(value: Any) -> Any:
|
|
763
|
+
"""Normalise an executor's return value into something hashable.
|
|
764
|
+
|
|
765
|
+
The response hash is what proves two attempts produced the same result, so
|
|
766
|
+
it must be stable for equal values and different for different ones.
|
|
767
|
+
"""
|
|
768
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
769
|
+
return value
|
|
770
|
+
if isinstance(value, (list, tuple)):
|
|
771
|
+
return [_receipt_value(v) for v in value]
|
|
772
|
+
if isinstance(value, dict):
|
|
773
|
+
return {str(k): _receipt_value(v) for k, v in value.items()}
|
|
774
|
+
to_dict = getattr(value, "to_dict", None)
|
|
775
|
+
if callable(to_dict):
|
|
776
|
+
return to_dict()
|
|
777
|
+
return repr(value)
|