ctrlrun 0.1.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.
- ctrlrun/__init__.py +67 -0
- ctrlrun/action.py +161 -0
- ctrlrun/approval.py +447 -0
- ctrlrun/cli/__init__.py +1 -0
- ctrlrun/cli/demo.py +300 -0
- ctrlrun/cli/main.py +260 -0
- ctrlrun/control.py +822 -0
- ctrlrun/effect.py +296 -0
- ctrlrun/errors.py +114 -0
- ctrlrun/policy.py +389 -0
- ctrlrun/py.typed +0 -0
- ctrlrun/receipt.py +229 -0
- ctrlrun/state.py +1131 -0
- ctrlrun-0.1.0.dist-info/METADATA +164 -0
- ctrlrun-0.1.0.dist-info/RECORD +19 -0
- ctrlrun-0.1.0.dist-info/WHEEL +5 -0
- ctrlrun-0.1.0.dist-info/entry_points.txt +2 -0
- ctrlrun-0.1.0.dist-info/licenses/LICENSE +202 -0
- ctrlrun-0.1.0.dist-info/top_level.txt +1 -0
ctrlrun/state.py
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
"""StateStore protocol, SQLite and in-memory stores. Build-list item 6; SPEC-v0.1 §5.3.
|
|
2
|
+
|
|
3
|
+
`SQLiteStateStore` is the store for anything that matters: it holds approvals, effects and
|
|
4
|
+
evidence in one file, and it is where E1 lives — `reserve_effect` succeeds for at most one
|
|
5
|
+
caller per effect key, across threads *and processes*. That is `BEGIN IMMEDIATE` plus a
|
|
6
|
+
`UNIQUE(effect_key)` constraint, with `busy_timeout` making contenders wait instead of fail.
|
|
7
|
+
|
|
8
|
+
Both stores decide with the same two pure functions — `plan_reservation` (§5.4) and
|
|
9
|
+
`check_consumable` (§4.2) — and then only write. The rules therefore live in one place, and
|
|
10
|
+
`InMemoryStateStore` cannot drift into permitting something SQLite refuses.
|
|
11
|
+
|
|
12
|
+
Approval consumption and effect reservation happen in one transaction (§4.2 A4): nothing is
|
|
13
|
+
written until both have been decided, so a refused reservation leaves the approval granted.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import sqlite3
|
|
22
|
+
import threading
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import replace
|
|
25
|
+
from datetime import UTC, datetime, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Final, Protocol, TypeVar
|
|
28
|
+
|
|
29
|
+
from .action import Action, Principal
|
|
30
|
+
from .approval import (
|
|
31
|
+
Approval,
|
|
32
|
+
ApprovalRecord,
|
|
33
|
+
ApprovalRequest,
|
|
34
|
+
ApprovalStatus,
|
|
35
|
+
ApprovalStore,
|
|
36
|
+
check_answerable,
|
|
37
|
+
check_consumable,
|
|
38
|
+
)
|
|
39
|
+
from .effect import (
|
|
40
|
+
COMMITTED_EFFECT,
|
|
41
|
+
DEFAULT_LEASE,
|
|
42
|
+
IN_PROGRESS_EFFECT,
|
|
43
|
+
LEASE_EXPIRED,
|
|
44
|
+
EffectRecord,
|
|
45
|
+
EffectState,
|
|
46
|
+
Reservation,
|
|
47
|
+
ReservationPlan,
|
|
48
|
+
plan_reservation,
|
|
49
|
+
)
|
|
50
|
+
from .errors import AmbiguousEffect, DuplicateEffect, InvalidArgument
|
|
51
|
+
from .receipt import Event, EventLog, EventType, Receipt
|
|
52
|
+
|
|
53
|
+
_LOG = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
#: SPEC-v0.1 §5.3 E1 — a contender waits this long for the write lock before giving up.
|
|
56
|
+
BUSY_TIMEOUT_MS: Final = 5000
|
|
57
|
+
|
|
58
|
+
_RESERVED: Final = frozenset({EffectState.RESERVED})
|
|
59
|
+
_EXECUTING: Final = frozenset({EffectState.EXECUTING})
|
|
60
|
+
#: `mark_ambiguous` accepts a reservation that never began (a crash between the two) and is
|
|
61
|
+
#: idempotent, so recording an unknown outcome can never itself fail (SPEC §5.5).
|
|
62
|
+
_UNFINISHED: Final = frozenset({EffectState.RESERVED, EffectState.EXECUTING, EffectState.AMBIGUOUS})
|
|
63
|
+
#: SPEC-v0.1 §5.2 — `AMBIGUOUS` is the only state a human resolves, and these are the two
|
|
64
|
+
#: answers available: what actually happened at the remote was one or the other.
|
|
65
|
+
RESOLUTIONS: Final = frozenset({EffectState.COMMITTED, EffectState.FAILED})
|
|
66
|
+
|
|
67
|
+
_SCHEMA: Final = """
|
|
68
|
+
CREATE TABLE IF NOT EXISTS effects(
|
|
69
|
+
effect_key TEXT PRIMARY KEY,
|
|
70
|
+
state TEXT NOT NULL,
|
|
71
|
+
action_id TEXT NOT NULL,
|
|
72
|
+
attempt INTEGER NOT NULL DEFAULT 1,
|
|
73
|
+
lease_expires_at TEXT,
|
|
74
|
+
result_json TEXT,
|
|
75
|
+
error TEXT,
|
|
76
|
+
created_at TEXT,
|
|
77
|
+
updated_at TEXT
|
|
78
|
+
);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS approvals(
|
|
80
|
+
approval_id TEXT PRIMARY KEY,
|
|
81
|
+
action_hash TEXT NOT NULL,
|
|
82
|
+
status TEXT NOT NULL,
|
|
83
|
+
action_json TEXT NOT NULL,
|
|
84
|
+
approver TEXT,
|
|
85
|
+
created_at TEXT,
|
|
86
|
+
granted_at TEXT,
|
|
87
|
+
expires_at TEXT,
|
|
88
|
+
consumed_at TEXT
|
|
89
|
+
);
|
|
90
|
+
CREATE TABLE IF NOT EXISTS receipts(
|
|
91
|
+
receipt_id TEXT PRIMARY KEY,
|
|
92
|
+
action_id TEXT,
|
|
93
|
+
effect_key TEXT,
|
|
94
|
+
result TEXT,
|
|
95
|
+
json TEXT,
|
|
96
|
+
ts TEXT
|
|
97
|
+
);
|
|
98
|
+
CREATE TABLE IF NOT EXISTS events(
|
|
99
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
100
|
+
ts TEXT,
|
|
101
|
+
type TEXT,
|
|
102
|
+
action_id TEXT,
|
|
103
|
+
effect_key TEXT,
|
|
104
|
+
approval_id TEXT,
|
|
105
|
+
data_json TEXT
|
|
106
|
+
);
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _utc_now() -> datetime:
|
|
111
|
+
return datetime.now(UTC)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _iso(moment: datetime) -> str:
|
|
115
|
+
"""A stored timestamp: UTC ISO-8601 at full precision, so it round-trips exactly."""
|
|
116
|
+
return moment.astimezone(UTC).isoformat()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _at(text: str | None) -> datetime | None:
|
|
120
|
+
return None if text is None else datetime.fromisoformat(text)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _result_json(result: Any) -> str | None:
|
|
124
|
+
"""Serialize an executor's return value. Never raises: the effect *did* commit.
|
|
125
|
+
|
|
126
|
+
An unserializable result is stored as its `repr`. Losing the shape of a return value is
|
|
127
|
+
a cosmetic loss; failing here would turn a committed effect into an error.
|
|
128
|
+
"""
|
|
129
|
+
if result is None:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
return json.dumps(result, ensure_ascii=False, separators=(",", ":"), default=repr)
|
|
133
|
+
except (TypeError, ValueError, RecursionError):
|
|
134
|
+
return json.dumps({"repr": repr(result)}, ensure_ascii=False, separators=(",", ":"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _result_value(text: str | None) -> Any:
|
|
138
|
+
return None if text is None else json.loads(text)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _action_json(action: Action) -> str:
|
|
142
|
+
"""An Action as stored JSON. Round-trips to the same `action_hash` (SPEC-v0.1 §2.2)."""
|
|
143
|
+
return json.dumps(
|
|
144
|
+
{
|
|
145
|
+
"action_id": action.action_id,
|
|
146
|
+
"name": action.name,
|
|
147
|
+
"arguments": action.canonical_arguments,
|
|
148
|
+
"principal": {"agent": action.principal.agent, "user": action.principal.user},
|
|
149
|
+
"resource": action.resource,
|
|
150
|
+
"environment": action.environment,
|
|
151
|
+
},
|
|
152
|
+
ensure_ascii=False,
|
|
153
|
+
separators=(",", ":"),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _action_from_json(text: str) -> Action:
|
|
158
|
+
document = json.loads(text)
|
|
159
|
+
principal = document["principal"]
|
|
160
|
+
return Action(
|
|
161
|
+
name=document["name"],
|
|
162
|
+
arguments=document["arguments"],
|
|
163
|
+
principal=Principal(agent=principal["agent"], user=principal["user"]),
|
|
164
|
+
resource=document["resource"],
|
|
165
|
+
environment=document["environment"],
|
|
166
|
+
action_id=document["action_id"],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _checked(
|
|
171
|
+
record: EffectRecord | None,
|
|
172
|
+
effect_key: str,
|
|
173
|
+
action_id: str,
|
|
174
|
+
expected: frozenset[EffectState],
|
|
175
|
+
now: datetime,
|
|
176
|
+
) -> EffectRecord:
|
|
177
|
+
"""The record, if this attempt may make this transition. Otherwise refuse, fail-closed.
|
|
178
|
+
|
|
179
|
+
A record that moved on belongs to something else now: `AMBIGUOUS` needs a human, a
|
|
180
|
+
committed effect is done, and a live lease belongs to another attempt. Anything else is
|
|
181
|
+
a transition no record could make — a wiring bug, not a race.
|
|
182
|
+
"""
|
|
183
|
+
if record is None:
|
|
184
|
+
raise InvalidArgument(f"no reservation for effect {effect_key!r}")
|
|
185
|
+
if record.action_id == action_id and record.state in expected:
|
|
186
|
+
return record
|
|
187
|
+
if record.state is EffectState.AMBIGUOUS:
|
|
188
|
+
raise AmbiguousEffect(
|
|
189
|
+
f"effect {effect_key!r} has an unknown outcome; resolve it with "
|
|
190
|
+
f"'ctrlrun resolve {effect_key}'",
|
|
191
|
+
effect_key=effect_key,
|
|
192
|
+
action_id=record.action_id,
|
|
193
|
+
)
|
|
194
|
+
if record.state is EffectState.COMMITTED:
|
|
195
|
+
raise DuplicateEffect(
|
|
196
|
+
f"effect {effect_key!r} was already committed by {record.action_id}",
|
|
197
|
+
state=COMMITTED_EFFECT,
|
|
198
|
+
effect_key=effect_key,
|
|
199
|
+
)
|
|
200
|
+
if record.action_id != action_id and record.lease_is_live(now):
|
|
201
|
+
raise DuplicateEffect(
|
|
202
|
+
f"effect {effect_key!r} is {record.state} under {record.action_id}, not {action_id}",
|
|
203
|
+
state=IN_PROGRESS_EFFECT,
|
|
204
|
+
effect_key=effect_key,
|
|
205
|
+
)
|
|
206
|
+
raise InvalidArgument(
|
|
207
|
+
f"effect {effect_key!r} is {record.state} under {record.action_id}; this transition "
|
|
208
|
+
f"needs {'|'.join(sorted(expected))} under {action_id}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _transitioned(
|
|
213
|
+
record: EffectRecord,
|
|
214
|
+
state: EffectState,
|
|
215
|
+
now: datetime,
|
|
216
|
+
*,
|
|
217
|
+
result: Any = None,
|
|
218
|
+
error: str | None = None,
|
|
219
|
+
) -> EffectRecord:
|
|
220
|
+
"""The record after one outcome transition (SPEC-v0.1 §5.2).
|
|
221
|
+
|
|
222
|
+
The lease survives only into `EXECUTING`; every other state here is terminal for this
|
|
223
|
+
attempt, and a terminal record holding a lease would be a lie about work in flight.
|
|
224
|
+
"""
|
|
225
|
+
return replace(
|
|
226
|
+
record,
|
|
227
|
+
state=state,
|
|
228
|
+
updated_at=now,
|
|
229
|
+
lease_expires_at=record.lease_expires_at if state is EffectState.EXECUTING else None,
|
|
230
|
+
result=result,
|
|
231
|
+
error=error,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _resolvable(record: EffectRecord | None, effect_key: str, state: EffectState) -> EffectRecord:
|
|
236
|
+
"""The record, if a human may move it to `state` (SPEC-v0.1 §5.2).
|
|
237
|
+
|
|
238
|
+
Only `AMBIGUOUS` is resolvable, and only to `COMMITTED` or `FAILED`. Every other record
|
|
239
|
+
is either terminal or belongs to an attempt in flight; a `resolve` that could overwrite
|
|
240
|
+
one would be a way to release a live reservation, which §5.3 says there is none of.
|
|
241
|
+
"""
|
|
242
|
+
if state not in RESOLUTIONS:
|
|
243
|
+
raise InvalidArgument(f"an effect resolves to {'|'.join(sorted(RESOLUTIONS))}, not {state}")
|
|
244
|
+
if record is None:
|
|
245
|
+
raise InvalidArgument(f"no effect {effect_key!r} to resolve")
|
|
246
|
+
if record.state is not EffectState.AMBIGUOUS:
|
|
247
|
+
raise InvalidArgument(
|
|
248
|
+
f"effect {effect_key!r} is {record.state}, not ambiguous; only an effect with an "
|
|
249
|
+
"unknown outcome is resolved by hand"
|
|
250
|
+
)
|
|
251
|
+
return record
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _resolved(
|
|
255
|
+
record: EffectRecord, state: EffectState, resolver: str, now: datetime
|
|
256
|
+
) -> EffectRecord:
|
|
257
|
+
"""The record after a human answered what the executor could not (SPEC-v0.1 §5.2).
|
|
258
|
+
|
|
259
|
+
The unknown that made it ambiguous stays on the record: a resolution is a human's claim
|
|
260
|
+
about what happened, and the evidence should say it was one.
|
|
261
|
+
"""
|
|
262
|
+
note = f"resolved {state} by {resolver}"
|
|
263
|
+
return _transitioned(
|
|
264
|
+
record,
|
|
265
|
+
state,
|
|
266
|
+
now,
|
|
267
|
+
result=record.result,
|
|
268
|
+
error=note if record.error is None else f"{note} (was: {record.error})",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _reserved(
|
|
273
|
+
reservation: Reservation, previous: EffectRecord | None, now: datetime
|
|
274
|
+
) -> EffectRecord:
|
|
275
|
+
"""The record a won reservation writes. A retry keeps the effect's creation time."""
|
|
276
|
+
return EffectRecord(
|
|
277
|
+
effect_key=reservation.effect_key,
|
|
278
|
+
state=EffectState.RESERVED,
|
|
279
|
+
action_id=reservation.action_id,
|
|
280
|
+
attempt=reservation.attempt,
|
|
281
|
+
created_at=now if previous is None else previous.created_at,
|
|
282
|
+
updated_at=now,
|
|
283
|
+
lease_expires_at=reservation.lease_expires_at,
|
|
284
|
+
result=None,
|
|
285
|
+
error=None,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class StateStore(ApprovalStore, Protocol):
|
|
290
|
+
"""Durable state behind a `Control` (SPEC-v0.1 §5.3): approvals, effects, evidence."""
|
|
291
|
+
|
|
292
|
+
def reserve_effect(
|
|
293
|
+
self, effect_key: str, action_id: str, lease: timedelta = DEFAULT_LEASE
|
|
294
|
+
) -> Reservation:
|
|
295
|
+
"""Claim an effect key for one attempt. At most one caller wins (§5.3 E1).
|
|
296
|
+
|
|
297
|
+
Refuses per the retry table of §5.4: `DuplicateEffect` for a committed effect or a
|
|
298
|
+
live reservation, `AmbiguousEffect` for an unresolved or lease-expired one.
|
|
299
|
+
"""
|
|
300
|
+
...
|
|
301
|
+
|
|
302
|
+
def consume_approval_and_reserve(
|
|
303
|
+
self,
|
|
304
|
+
approval_id: str,
|
|
305
|
+
action_hash: str,
|
|
306
|
+
effect_key: str,
|
|
307
|
+
action_id: str,
|
|
308
|
+
lease: timedelta = DEFAULT_LEASE,
|
|
309
|
+
) -> tuple[Approval, Reservation]:
|
|
310
|
+
"""Consume the approval and reserve the effect in one transaction (§4.2 A4).
|
|
311
|
+
|
|
312
|
+
The approval is checked first, so its refusal is the one raised when both would
|
|
313
|
+
apply (acceptance test T4). If the reservation is refused, nothing is consumed.
|
|
314
|
+
"""
|
|
315
|
+
...
|
|
316
|
+
|
|
317
|
+
def begin_execution(self, effect_key: str, action_id: str) -> None:
|
|
318
|
+
"""Move a reservation to `EXECUTING`, just before the executor runs."""
|
|
319
|
+
...
|
|
320
|
+
|
|
321
|
+
def commit_effect(self, effect_key: str, action_id: str, result: Any) -> None:
|
|
322
|
+
"""Record that the effect happened."""
|
|
323
|
+
...
|
|
324
|
+
|
|
325
|
+
def fail_effect(self, effect_key: str, action_id: str, error: str) -> None:
|
|
326
|
+
"""Record that the effect provably did *not* happen (§5.5); a retry is permitted."""
|
|
327
|
+
...
|
|
328
|
+
|
|
329
|
+
def mark_ambiguous(self, effect_key: str, action_id: str, error: str) -> None:
|
|
330
|
+
"""Record that the outcome is unknown. Only a human moves it on (§5.2)."""
|
|
331
|
+
...
|
|
332
|
+
|
|
333
|
+
def resolve_effect(self, effect_key: str, state: EffectState, resolver: str) -> EffectRecord:
|
|
334
|
+
"""Move an `AMBIGUOUS` record to `COMMITTED` or `FAILED` (SPEC-v0.1 §5.2).
|
|
335
|
+
|
|
336
|
+
The only transition out of `AMBIGUOUS`, and the only one a human drives —
|
|
337
|
+
`ctrlrun resolve`. Anything else raises `InvalidArgument`.
|
|
338
|
+
"""
|
|
339
|
+
...
|
|
340
|
+
|
|
341
|
+
def get_effect(self, effect_key: str) -> EffectRecord | None:
|
|
342
|
+
"""The record for this key, or `None`. A read: it never transitions anything."""
|
|
343
|
+
...
|
|
344
|
+
|
|
345
|
+
def list_effects(self, state: EffectState | None = None) -> tuple[EffectRecord, ...]:
|
|
346
|
+
"""Every effect record, oldest first, optionally narrowed to one state."""
|
|
347
|
+
...
|
|
348
|
+
|
|
349
|
+
def append_event(self, event: Event) -> None:
|
|
350
|
+
"""Append an event, assigning it the next `event_id`."""
|
|
351
|
+
...
|
|
352
|
+
|
|
353
|
+
def put_receipt(self, receipt: Receipt) -> None:
|
|
354
|
+
"""Record a receipt for an action that reached a terminal state."""
|
|
355
|
+
...
|
|
356
|
+
|
|
357
|
+
def close(self) -> None:
|
|
358
|
+
"""Release whatever this store holds open."""
|
|
359
|
+
...
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class InMemoryStateStore:
|
|
363
|
+
"""Everything held in process memory: for tests and `ctrlrun demo`.
|
|
364
|
+
|
|
365
|
+
Nothing here survives the process, and nothing here is shared between processes, so this
|
|
366
|
+
store cannot provide the cross-process half of SPEC-v0.1 §5.3 E1 — `SQLiteStateStore` is
|
|
367
|
+
the store for anything that matters. Within one process it refuses exactly what SQLite
|
|
368
|
+
refuses: the same `plan_reservation` and `check_consumable` decide, under one lock that
|
|
369
|
+
covers each whole check-and-write.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
def __init__(self, *, clock: Callable[[], datetime] = _utc_now) -> None:
|
|
373
|
+
self._lock = threading.Lock()
|
|
374
|
+
self._clock = clock
|
|
375
|
+
self._events: list[Event] = []
|
|
376
|
+
self._receipts: list[Receipt] = []
|
|
377
|
+
self._approvals: dict[str, ApprovalRecord] = {}
|
|
378
|
+
self._effects: dict[str, EffectRecord] = {}
|
|
379
|
+
|
|
380
|
+
def close(self) -> None:
|
|
381
|
+
"""Nothing to release; here so either store can be closed the same way."""
|
|
382
|
+
|
|
383
|
+
# --- evidence ---------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
def append_event(self, event: Event) -> None:
|
|
386
|
+
with self._lock:
|
|
387
|
+
self._events.append(replace(event, event_id=len(self._events) + 1))
|
|
388
|
+
|
|
389
|
+
def put_receipt(self, receipt: Receipt) -> None:
|
|
390
|
+
with self._lock:
|
|
391
|
+
self._receipts.append(receipt)
|
|
392
|
+
|
|
393
|
+
def events(self) -> tuple[Event, ...]:
|
|
394
|
+
"""An immutable snapshot of the event log, in append order."""
|
|
395
|
+
with self._lock:
|
|
396
|
+
return tuple(self._events)
|
|
397
|
+
|
|
398
|
+
def receipts(self) -> tuple[Receipt, ...]:
|
|
399
|
+
"""An immutable snapshot of the receipts, in write order."""
|
|
400
|
+
with self._lock:
|
|
401
|
+
return tuple(self._receipts)
|
|
402
|
+
|
|
403
|
+
# --- approvals (SPEC-v0.1 §4.2) ---------------------------------------------------
|
|
404
|
+
|
|
405
|
+
def put_approval_request(self, request: ApprovalRequest) -> None:
|
|
406
|
+
with self._lock:
|
|
407
|
+
if request.request_id in self._approvals:
|
|
408
|
+
raise InvalidArgument(f"approval {request.request_id} already exists")
|
|
409
|
+
self._approvals[request.request_id] = ApprovalRecord(
|
|
410
|
+
request=request, status=ApprovalStatus.PENDING
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def get_approval(self, approval_id: str) -> ApprovalRecord | None:
|
|
414
|
+
with self._lock:
|
|
415
|
+
return self._approvals.get(approval_id)
|
|
416
|
+
|
|
417
|
+
def grant_approval(self, approval_id: str, approver: str) -> Approval:
|
|
418
|
+
approver = _approver(approver)
|
|
419
|
+
with self._lock:
|
|
420
|
+
record = self._answerable(approval_id)
|
|
421
|
+
granted = replace(
|
|
422
|
+
record,
|
|
423
|
+
status=ApprovalStatus.GRANTED,
|
|
424
|
+
approver=approver,
|
|
425
|
+
granted_at=self._clock(),
|
|
426
|
+
)
|
|
427
|
+
self._approvals[approval_id] = granted
|
|
428
|
+
return granted.as_approval()
|
|
429
|
+
|
|
430
|
+
def deny_approval(self, approval_id: str, approver: str) -> None:
|
|
431
|
+
approver = _approver(approver)
|
|
432
|
+
with self._lock:
|
|
433
|
+
record = self._answerable(approval_id)
|
|
434
|
+
self._approvals[approval_id] = replace(
|
|
435
|
+
record, status=ApprovalStatus.DENIED, approver=approver
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def consume_approval(self, approval_id: str, action_hash: str) -> Approval:
|
|
439
|
+
"""Take a granted approval for exactly this action, once (SPEC-v0.1 §4.2)."""
|
|
440
|
+
approval, _ = self._authorize_and_reserve(
|
|
441
|
+
approval_id, action_hash, None, None, DEFAULT_LEASE
|
|
442
|
+
)
|
|
443
|
+
return _only(approval, "approval")
|
|
444
|
+
|
|
445
|
+
def _answerable(self, approval_id: str) -> ApprovalRecord:
|
|
446
|
+
"""The record for `approval_id`, if it still awaits an answer. Caller holds the lock."""
|
|
447
|
+
verdict = check_answerable(self._approvals.get(approval_id), approval_id, self._clock())
|
|
448
|
+
if verdict.refusal is not None:
|
|
449
|
+
if verdict.expire:
|
|
450
|
+
self._expire_locked(approval_id)
|
|
451
|
+
raise verdict.refusal
|
|
452
|
+
return _only(verdict.record, "approval record")
|
|
453
|
+
|
|
454
|
+
def _expire_locked(self, approval_id: str) -> None:
|
|
455
|
+
record = self._approvals[approval_id]
|
|
456
|
+
self._approvals[approval_id] = replace(record, status=ApprovalStatus.EXPIRED)
|
|
457
|
+
|
|
458
|
+
def _consume_locked(self, approval_id: str, now: datetime) -> None:
|
|
459
|
+
record = self._approvals[approval_id]
|
|
460
|
+
self._approvals[approval_id] = replace(
|
|
461
|
+
record, status=ApprovalStatus.CONSUMED, consumed_at=now
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# --- effects (SPEC-v0.1 §5.3) -----------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def reserve_effect(
|
|
467
|
+
self, effect_key: str, action_id: str, lease: timedelta = DEFAULT_LEASE
|
|
468
|
+
) -> Reservation:
|
|
469
|
+
_, reservation = self._authorize_and_reserve(None, None, effect_key, action_id, lease)
|
|
470
|
+
return _only(reservation, "reservation")
|
|
471
|
+
|
|
472
|
+
def consume_approval_and_reserve(
|
|
473
|
+
self,
|
|
474
|
+
approval_id: str,
|
|
475
|
+
action_hash: str,
|
|
476
|
+
effect_key: str,
|
|
477
|
+
action_id: str,
|
|
478
|
+
lease: timedelta = DEFAULT_LEASE,
|
|
479
|
+
) -> tuple[Approval, Reservation]:
|
|
480
|
+
approval, reservation = self._authorize_and_reserve(
|
|
481
|
+
approval_id, action_hash, effect_key, action_id, lease
|
|
482
|
+
)
|
|
483
|
+
return _only(approval, "approval"), _only(reservation, "reservation")
|
|
484
|
+
|
|
485
|
+
def _authorize_and_reserve(
|
|
486
|
+
self,
|
|
487
|
+
approval_id: str | None,
|
|
488
|
+
action_hash: str | None,
|
|
489
|
+
effect_key: str | None,
|
|
490
|
+
action_id: str | None,
|
|
491
|
+
lease: timedelta,
|
|
492
|
+
) -> tuple[Approval | None, Reservation | None]:
|
|
493
|
+
"""Consume an approval, reserve an effect, or both together (SPEC-v0.1 §4.2 A4).
|
|
494
|
+
|
|
495
|
+
Nothing is written until both have been decided, and the reservation is written
|
|
496
|
+
before the consumption, so a store failure part-way cannot leave an approval spent
|
|
497
|
+
on an attempt that never reserved.
|
|
498
|
+
"""
|
|
499
|
+
with self._lock:
|
|
500
|
+
now = self._clock()
|
|
501
|
+
approved: ApprovalRecord | None = None
|
|
502
|
+
if approval_id is not None:
|
|
503
|
+
approved = self._consumable(approval_id, _required_hash(action_hash), now)
|
|
504
|
+
plan = ReservationPlan()
|
|
505
|
+
if effect_key is not None:
|
|
506
|
+
plan = self._plan(effect_key, _required_action(action_id), lease, now)
|
|
507
|
+
if plan.reservation is not None:
|
|
508
|
+
self._reserve_locked(plan.reservation, plan.renews, now)
|
|
509
|
+
if approved is not None:
|
|
510
|
+
self._consume_locked(approved.approval_id, now)
|
|
511
|
+
return (approved.as_approval() if approved is not None else None), plan.reservation
|
|
512
|
+
|
|
513
|
+
def _consumable(self, approval_id: str, action_hash: str, now: datetime) -> ApprovalRecord:
|
|
514
|
+
verdict = check_consumable(self._approvals.get(approval_id), approval_id, action_hash, now)
|
|
515
|
+
if verdict.refusal is not None:
|
|
516
|
+
if verdict.expire:
|
|
517
|
+
self._expire_locked(approval_id)
|
|
518
|
+
raise verdict.refusal
|
|
519
|
+
return _only(verdict.record, "approval record")
|
|
520
|
+
|
|
521
|
+
def _plan(
|
|
522
|
+
self, effect_key: str, action_id: str, lease: timedelta, now: datetime
|
|
523
|
+
) -> ReservationPlan:
|
|
524
|
+
plan = plan_reservation(self._effects.get(effect_key), effect_key, action_id, lease, now)
|
|
525
|
+
if plan.refusal is not None:
|
|
526
|
+
if plan.ambiguate:
|
|
527
|
+
self._effects[effect_key] = _transitioned(
|
|
528
|
+
self._effects[effect_key], EffectState.AMBIGUOUS, now, error=LEASE_EXPIRED
|
|
529
|
+
)
|
|
530
|
+
raise plan.refusal
|
|
531
|
+
return plan
|
|
532
|
+
|
|
533
|
+
def _reserve_locked(self, reservation: Reservation, renews: bool, now: datetime) -> None:
|
|
534
|
+
previous = self._effects.get(reservation.effect_key)
|
|
535
|
+
self._effects[reservation.effect_key] = _reserved(reservation, previous, now)
|
|
536
|
+
|
|
537
|
+
def begin_execution(self, effect_key: str, action_id: str) -> None:
|
|
538
|
+
self._transition(effect_key, action_id, EffectState.EXECUTING, _RESERVED)
|
|
539
|
+
|
|
540
|
+
def commit_effect(self, effect_key: str, action_id: str, result: Any) -> None:
|
|
541
|
+
self._transition(
|
|
542
|
+
effect_key,
|
|
543
|
+
action_id,
|
|
544
|
+
EffectState.COMMITTED,
|
|
545
|
+
_EXECUTING,
|
|
546
|
+
result=_result_value(_result_json(result)),
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def fail_effect(self, effect_key: str, action_id: str, error: str) -> None:
|
|
550
|
+
self._transition(effect_key, action_id, EffectState.FAILED, _EXECUTING, error=error)
|
|
551
|
+
|
|
552
|
+
def mark_ambiguous(self, effect_key: str, action_id: str, error: str) -> None:
|
|
553
|
+
self._transition(effect_key, action_id, EffectState.AMBIGUOUS, _UNFINISHED, error=error)
|
|
554
|
+
|
|
555
|
+
def resolve_effect(self, effect_key: str, state: EffectState, resolver: str) -> EffectRecord:
|
|
556
|
+
resolver = _approver(resolver)
|
|
557
|
+
with self._lock:
|
|
558
|
+
record = _resolvable(self._effects.get(effect_key), effect_key, state)
|
|
559
|
+
resolved = _resolved(record, state, resolver, self._clock())
|
|
560
|
+
self._effects[effect_key] = resolved
|
|
561
|
+
return resolved
|
|
562
|
+
|
|
563
|
+
def get_effect(self, effect_key: str) -> EffectRecord | None:
|
|
564
|
+
with self._lock:
|
|
565
|
+
return self._effects.get(effect_key)
|
|
566
|
+
|
|
567
|
+
def list_effects(self, state: EffectState | None = None) -> tuple[EffectRecord, ...]:
|
|
568
|
+
with self._lock:
|
|
569
|
+
records = sorted(self._effects.values(), key=lambda record: record.created_at)
|
|
570
|
+
return tuple(record for record in records if state is None or record.state is state)
|
|
571
|
+
|
|
572
|
+
def _transition(
|
|
573
|
+
self,
|
|
574
|
+
effect_key: str,
|
|
575
|
+
action_id: str,
|
|
576
|
+
state: EffectState,
|
|
577
|
+
expected: frozenset[EffectState],
|
|
578
|
+
*,
|
|
579
|
+
result: Any = None,
|
|
580
|
+
error: str | None = None,
|
|
581
|
+
) -> None:
|
|
582
|
+
with self._lock:
|
|
583
|
+
now = self._clock()
|
|
584
|
+
record = _checked(self._effects.get(effect_key), effect_key, action_id, expected, now)
|
|
585
|
+
self._effects[effect_key] = _transitioned(
|
|
586
|
+
record, state, now, result=result, error=error
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class SQLiteStateStore:
|
|
591
|
+
"""Approvals, effects and evidence in one SQLite file (ARCHITECTURE §5).
|
|
592
|
+
|
|
593
|
+
This is the store that makes reservation atomic across processes (SPEC-v0.1 §5.3 E1):
|
|
594
|
+
every decision is taken inside `BEGIN IMMEDIATE`, which holds the database's write lock,
|
|
595
|
+
and `effects.effect_key` is a primary key — the `UNIQUE(effect_key)` constraint — so an
|
|
596
|
+
insert that races past the lock still fails rather than overwriting a reservation.
|
|
597
|
+
|
|
598
|
+
A connection is opened per thread; `sqlite3` connections are not shareable. Separate
|
|
599
|
+
processes simply open the same file, which is the point.
|
|
600
|
+
"""
|
|
601
|
+
|
|
602
|
+
def __init__(
|
|
603
|
+
self, path: str | os.PathLike[str], *, clock: Callable[[], datetime] = _utc_now
|
|
604
|
+
) -> None:
|
|
605
|
+
text = os.fspath(path)
|
|
606
|
+
if text == ":memory:" or "mode=memory" in text:
|
|
607
|
+
# An in-memory database is private to one connection, so it could not reserve
|
|
608
|
+
# across threads, let alone processes. Refuse rather than silently lose E1.
|
|
609
|
+
raise InvalidArgument(
|
|
610
|
+
f"{text!r} is per-connection and cannot reserve across processes; "
|
|
611
|
+
"use a file path, or InMemoryStateStore if that is what you meant"
|
|
612
|
+
)
|
|
613
|
+
self._path = Path(text)
|
|
614
|
+
self._clock = clock
|
|
615
|
+
self._local = threading.local()
|
|
616
|
+
self._open: set[sqlite3.Connection] = set()
|
|
617
|
+
self._open_lock = threading.Lock()
|
|
618
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
619
|
+
self._journal = EventLog(self._path.parent)
|
|
620
|
+
self._connection().executescript(_SCHEMA)
|
|
621
|
+
|
|
622
|
+
@property
|
|
623
|
+
def path(self) -> Path:
|
|
624
|
+
return self._path
|
|
625
|
+
|
|
626
|
+
@property
|
|
627
|
+
def journal(self) -> EventLog:
|
|
628
|
+
"""The JSONL evidence written beside the database (SPEC-v0.1 §6)."""
|
|
629
|
+
return self._journal
|
|
630
|
+
|
|
631
|
+
def close(self) -> None:
|
|
632
|
+
"""Close every connection this store opened, on whichever thread opened it.
|
|
633
|
+
|
|
634
|
+
A shutdown operation, not a per-thread one: a long-lived host that runs agents on a
|
|
635
|
+
thread pool would otherwise accumulate one open file handle per thread that ever
|
|
636
|
+
touched the store, and closing only the caller's would leave them all. A thread that
|
|
637
|
+
uses the store after this simply gets a fresh connection.
|
|
638
|
+
|
|
639
|
+
It is not safe to call while another thread is mid-transaction — that is the caller's
|
|
640
|
+
to arrange, as it is with any resource being torn down.
|
|
641
|
+
"""
|
|
642
|
+
with self._open_lock:
|
|
643
|
+
connections, self._open = self._open, set()
|
|
644
|
+
for connection in connections:
|
|
645
|
+
connection.close()
|
|
646
|
+
|
|
647
|
+
def _connection(self) -> sqlite3.Connection:
|
|
648
|
+
connection: sqlite3.Connection | None = getattr(self._local, "connection", None)
|
|
649
|
+
if connection is not None:
|
|
650
|
+
with self._open_lock:
|
|
651
|
+
if connection in self._open:
|
|
652
|
+
return connection
|
|
653
|
+
# `close()` tore this one down; open a fresh one rather than hand back a corpse.
|
|
654
|
+
# `check_same_thread=False` because `close()` closes other threads' connections. Each
|
|
655
|
+
# connection is still used by exactly one thread — that is what the thread-local is
|
|
656
|
+
# for — so the guard this drops was never the thing keeping them apart.
|
|
657
|
+
connection = sqlite3.connect(
|
|
658
|
+
self._path,
|
|
659
|
+
isolation_level=None,
|
|
660
|
+
timeout=BUSY_TIMEOUT_MS / 1000,
|
|
661
|
+
check_same_thread=False,
|
|
662
|
+
)
|
|
663
|
+
connection.row_factory = sqlite3.Row
|
|
664
|
+
connection.execute("PRAGMA journal_mode=WAL")
|
|
665
|
+
connection.execute(f"PRAGMA busy_timeout={BUSY_TIMEOUT_MS}")
|
|
666
|
+
connection.execute("PRAGMA synchronous=NORMAL")
|
|
667
|
+
self._local.connection = connection
|
|
668
|
+
with self._open_lock:
|
|
669
|
+
self._open.add(connection)
|
|
670
|
+
return connection
|
|
671
|
+
|
|
672
|
+
# --- evidence ---------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
def append_event(self, event: Event) -> None:
|
|
675
|
+
cursor = self._connection().execute(
|
|
676
|
+
"INSERT INTO events(ts, type, action_id, effect_key, approval_id, data_json) "
|
|
677
|
+
"VALUES(?,?,?,?,?,?)",
|
|
678
|
+
(
|
|
679
|
+
_iso(event.ts),
|
|
680
|
+
str(event.type),
|
|
681
|
+
event.action_id,
|
|
682
|
+
event.effect_key,
|
|
683
|
+
event.approval_id,
|
|
684
|
+
json.dumps(dict(event.data), ensure_ascii=False, separators=(",", ":")),
|
|
685
|
+
),
|
|
686
|
+
)
|
|
687
|
+
self._mirror(self._journal.append_event, replace(event, event_id=cursor.lastrowid))
|
|
688
|
+
|
|
689
|
+
def put_receipt(self, receipt: Receipt) -> None:
|
|
690
|
+
self._connection().execute(
|
|
691
|
+
"INSERT INTO receipts(receipt_id, action_id, effect_key, result, json, ts) "
|
|
692
|
+
"VALUES(?,?,?,?,?,?)",
|
|
693
|
+
(
|
|
694
|
+
receipt.receipt_id,
|
|
695
|
+
receipt.action_id,
|
|
696
|
+
receipt.effect_key,
|
|
697
|
+
str(receipt.result),
|
|
698
|
+
receipt.to_json(),
|
|
699
|
+
_iso(receipt.finished_at),
|
|
700
|
+
),
|
|
701
|
+
)
|
|
702
|
+
self._mirror(self._journal.put_receipt, receipt)
|
|
703
|
+
|
|
704
|
+
@staticmethod
|
|
705
|
+
def _mirror(write: Callable[[_T], None], record: _T) -> None:
|
|
706
|
+
"""Copy a record the database already holds into the JSONL evidence (SPEC §6).
|
|
707
|
+
|
|
708
|
+
SPEC: §6 — the spec requires both, and does not say what happens when only one can
|
|
709
|
+
be written. The record is already durable in the database by the time this runs, so
|
|
710
|
+
a failing file is logged rather than raised: raising would turn an effect that has
|
|
711
|
+
committed at the remote into an exception the caller reads as a failure, which is
|
|
712
|
+
the one mistake this library exists to prevent. `ctrlrun receipts` reads the
|
|
713
|
+
database, so nothing is hidden by the loss.
|
|
714
|
+
"""
|
|
715
|
+
try:
|
|
716
|
+
write(record)
|
|
717
|
+
except OSError as exc:
|
|
718
|
+
_LOG.warning("could not write JSONL evidence: %s", exc)
|
|
719
|
+
|
|
720
|
+
def events(self) -> tuple[Event, ...]:
|
|
721
|
+
rows = self._connection().execute("SELECT * FROM events ORDER BY event_id").fetchall()
|
|
722
|
+
return tuple(
|
|
723
|
+
Event(
|
|
724
|
+
type=EventType(row["type"]),
|
|
725
|
+
action_id=row["action_id"],
|
|
726
|
+
ts=datetime.fromisoformat(row["ts"]),
|
|
727
|
+
data=json.loads(row["data_json"]),
|
|
728
|
+
effect_key=row["effect_key"],
|
|
729
|
+
approval_id=row["approval_id"],
|
|
730
|
+
event_id=row["event_id"],
|
|
731
|
+
)
|
|
732
|
+
for row in rows
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def receipts(self) -> tuple[Receipt, ...]:
|
|
736
|
+
rows = self._connection().execute("SELECT json FROM receipts ORDER BY rowid").fetchall()
|
|
737
|
+
return tuple(Receipt.from_json(row["json"]) for row in rows)
|
|
738
|
+
|
|
739
|
+
# --- approvals (SPEC-v0.1 §4.2) ---------------------------------------------------
|
|
740
|
+
|
|
741
|
+
def put_approval_request(self, request: ApprovalRequest) -> None:
|
|
742
|
+
try:
|
|
743
|
+
self._connection().execute(
|
|
744
|
+
"INSERT INTO approvals(approval_id, action_hash, status, action_json, "
|
|
745
|
+
"created_at, expires_at) VALUES(?,?,?,?,?,?)",
|
|
746
|
+
(
|
|
747
|
+
request.request_id,
|
|
748
|
+
request.action_hash,
|
|
749
|
+
str(ApprovalStatus.PENDING),
|
|
750
|
+
_action_json(request.action),
|
|
751
|
+
_iso(request.created_at),
|
|
752
|
+
_iso(request.expires_at),
|
|
753
|
+
),
|
|
754
|
+
)
|
|
755
|
+
except sqlite3.IntegrityError as exc:
|
|
756
|
+
raise InvalidArgument(f"approval {request.request_id} already exists") from exc
|
|
757
|
+
|
|
758
|
+
def get_approval(self, approval_id: str) -> ApprovalRecord | None:
|
|
759
|
+
return self._read_approval(self._connection(), approval_id)
|
|
760
|
+
|
|
761
|
+
def grant_approval(self, approval_id: str, approver: str) -> Approval:
|
|
762
|
+
approver = _approver(approver)
|
|
763
|
+
connection = self._connection()
|
|
764
|
+
now = self._clock()
|
|
765
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
766
|
+
try:
|
|
767
|
+
record = self._answerable(connection, approval_id, now)
|
|
768
|
+
granted = replace(
|
|
769
|
+
record, status=ApprovalStatus.GRANTED, approver=approver, granted_at=now
|
|
770
|
+
)
|
|
771
|
+
connection.execute(
|
|
772
|
+
"UPDATE approvals SET status=?, approver=?, granted_at=? WHERE approval_id=?",
|
|
773
|
+
(str(ApprovalStatus.GRANTED), approver, _iso(now), approval_id),
|
|
774
|
+
)
|
|
775
|
+
except BaseException:
|
|
776
|
+
self._unwind(connection)
|
|
777
|
+
raise
|
|
778
|
+
connection.commit()
|
|
779
|
+
return granted.as_approval()
|
|
780
|
+
|
|
781
|
+
def deny_approval(self, approval_id: str, approver: str) -> None:
|
|
782
|
+
approver = _approver(approver)
|
|
783
|
+
connection = self._connection()
|
|
784
|
+
now = self._clock()
|
|
785
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
786
|
+
try:
|
|
787
|
+
self._answerable(connection, approval_id, now)
|
|
788
|
+
connection.execute(
|
|
789
|
+
"UPDATE approvals SET status=?, approver=? WHERE approval_id=?",
|
|
790
|
+
(str(ApprovalStatus.DENIED), approver, approval_id),
|
|
791
|
+
)
|
|
792
|
+
except BaseException:
|
|
793
|
+
self._unwind(connection)
|
|
794
|
+
raise
|
|
795
|
+
connection.commit()
|
|
796
|
+
|
|
797
|
+
def consume_approval(self, approval_id: str, action_hash: str) -> Approval:
|
|
798
|
+
approval, _ = self._authorize_and_reserve(
|
|
799
|
+
approval_id, action_hash, None, None, DEFAULT_LEASE
|
|
800
|
+
)
|
|
801
|
+
return _only(approval, "approval")
|
|
802
|
+
|
|
803
|
+
def _answerable(
|
|
804
|
+
self, connection: sqlite3.Connection, approval_id: str, now: datetime
|
|
805
|
+
) -> ApprovalRecord:
|
|
806
|
+
verdict = check_answerable(self._read_approval(connection, approval_id), approval_id, now)
|
|
807
|
+
if verdict.refusal is not None:
|
|
808
|
+
if verdict.expire:
|
|
809
|
+
self._expire_locked(connection, approval_id)
|
|
810
|
+
connection.commit() # a lapsed approval is evidence; keep it, then refuse
|
|
811
|
+
raise verdict.refusal
|
|
812
|
+
return _only(verdict.record, "approval record")
|
|
813
|
+
|
|
814
|
+
def _read_approval(
|
|
815
|
+
self, connection: sqlite3.Connection, approval_id: str
|
|
816
|
+
) -> ApprovalRecord | None:
|
|
817
|
+
row = connection.execute(
|
|
818
|
+
"SELECT * FROM approvals WHERE approval_id=?", (approval_id,)
|
|
819
|
+
).fetchone()
|
|
820
|
+
if row is None:
|
|
821
|
+
return None
|
|
822
|
+
return ApprovalRecord(
|
|
823
|
+
request=ApprovalRequest(
|
|
824
|
+
request_id=row["approval_id"],
|
|
825
|
+
action_hash=row["action_hash"],
|
|
826
|
+
action=_action_from_json(row["action_json"]),
|
|
827
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
828
|
+
expires_at=datetime.fromisoformat(row["expires_at"]),
|
|
829
|
+
),
|
|
830
|
+
status=ApprovalStatus(row["status"]),
|
|
831
|
+
approver=row["approver"],
|
|
832
|
+
granted_at=_at(row["granted_at"]),
|
|
833
|
+
consumed_at=_at(row["consumed_at"]),
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
def _expire_locked(self, connection: sqlite3.Connection, approval_id: str) -> None:
|
|
837
|
+
connection.execute(
|
|
838
|
+
"UPDATE approvals SET status=? WHERE approval_id=?",
|
|
839
|
+
(str(ApprovalStatus.EXPIRED), approval_id),
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
def _consume_locked(
|
|
843
|
+
self, connection: sqlite3.Connection, approval_id: str, now: datetime
|
|
844
|
+
) -> None:
|
|
845
|
+
connection.execute(
|
|
846
|
+
"UPDATE approvals SET status=?, consumed_at=? WHERE approval_id=?",
|
|
847
|
+
(str(ApprovalStatus.CONSUMED), _iso(now), approval_id),
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
# --- effects (SPEC-v0.1 §5.3) -----------------------------------------------------
|
|
851
|
+
|
|
852
|
+
def reserve_effect(
|
|
853
|
+
self, effect_key: str, action_id: str, lease: timedelta = DEFAULT_LEASE
|
|
854
|
+
) -> Reservation:
|
|
855
|
+
_, reservation = self._authorize_and_reserve(None, None, effect_key, action_id, lease)
|
|
856
|
+
return _only(reservation, "reservation")
|
|
857
|
+
|
|
858
|
+
def consume_approval_and_reserve(
|
|
859
|
+
self,
|
|
860
|
+
approval_id: str,
|
|
861
|
+
action_hash: str,
|
|
862
|
+
effect_key: str,
|
|
863
|
+
action_id: str,
|
|
864
|
+
lease: timedelta = DEFAULT_LEASE,
|
|
865
|
+
) -> tuple[Approval, Reservation]:
|
|
866
|
+
approval, reservation = self._authorize_and_reserve(
|
|
867
|
+
approval_id, action_hash, effect_key, action_id, lease
|
|
868
|
+
)
|
|
869
|
+
return _only(approval, "approval"), _only(reservation, "reservation")
|
|
870
|
+
|
|
871
|
+
def _authorize_and_reserve(
|
|
872
|
+
self,
|
|
873
|
+
approval_id: str | None,
|
|
874
|
+
action_hash: str | None,
|
|
875
|
+
effect_key: str | None,
|
|
876
|
+
action_id: str | None,
|
|
877
|
+
lease: timedelta,
|
|
878
|
+
) -> tuple[Approval | None, Reservation | None]:
|
|
879
|
+
"""Consume an approval, reserve an effect, or both, in one transaction (§4.2 A4).
|
|
880
|
+
|
|
881
|
+
`BEGIN IMMEDIATE` takes the write lock before the first read, so the whole
|
|
882
|
+
check-and-write is serialized against every other process on this file (§5.3 E1).
|
|
883
|
+
Nothing is written until both halves have been decided, so a refused reservation
|
|
884
|
+
rolls back to an approval that is still granted (acceptance test T12).
|
|
885
|
+
"""
|
|
886
|
+
connection = self._connection()
|
|
887
|
+
now = self._clock()
|
|
888
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
889
|
+
try:
|
|
890
|
+
approved: ApprovalRecord | None = None
|
|
891
|
+
if approval_id is not None:
|
|
892
|
+
approved = self._consumable(
|
|
893
|
+
connection, approval_id, _required_hash(action_hash), now
|
|
894
|
+
)
|
|
895
|
+
plan = ReservationPlan()
|
|
896
|
+
if effect_key is not None:
|
|
897
|
+
plan = self._plan(connection, effect_key, _required_action(action_id), lease, now)
|
|
898
|
+
if plan.reservation is not None:
|
|
899
|
+
self._reserve_locked(connection, plan.reservation, plan.renews, now)
|
|
900
|
+
if approved is not None:
|
|
901
|
+
self._consume_locked(connection, approved.approval_id, now)
|
|
902
|
+
except BaseException:
|
|
903
|
+
self._unwind(connection)
|
|
904
|
+
raise
|
|
905
|
+
connection.commit()
|
|
906
|
+
return (approved.as_approval() if approved is not None else None), plan.reservation
|
|
907
|
+
|
|
908
|
+
def _consumable(
|
|
909
|
+
self, connection: sqlite3.Connection, approval_id: str, action_hash: str, now: datetime
|
|
910
|
+
) -> ApprovalRecord:
|
|
911
|
+
verdict = check_consumable(
|
|
912
|
+
self._read_approval(connection, approval_id), approval_id, action_hash, now
|
|
913
|
+
)
|
|
914
|
+
if verdict.refusal is not None:
|
|
915
|
+
if verdict.expire:
|
|
916
|
+
self._expire_locked(connection, approval_id)
|
|
917
|
+
connection.commit() # a lapsed approval is evidence; keep it, then refuse
|
|
918
|
+
raise verdict.refusal
|
|
919
|
+
return _only(verdict.record, "approval record")
|
|
920
|
+
|
|
921
|
+
def _plan(
|
|
922
|
+
self,
|
|
923
|
+
connection: sqlite3.Connection,
|
|
924
|
+
effect_key: str,
|
|
925
|
+
action_id: str,
|
|
926
|
+
lease: timedelta,
|
|
927
|
+
now: datetime,
|
|
928
|
+
) -> ReservationPlan:
|
|
929
|
+
record = self._read_effect(connection, effect_key)
|
|
930
|
+
plan = plan_reservation(record, effect_key, action_id, lease, now)
|
|
931
|
+
if plan.refusal is not None:
|
|
932
|
+
if plan.ambiguate and record is not None:
|
|
933
|
+
# SPEC §5.3 E3 — the expired lease becomes AMBIGUOUS and that write is kept,
|
|
934
|
+
# even though this attempt is refused. The approval, written after, is not.
|
|
935
|
+
self._write_effect(
|
|
936
|
+
connection,
|
|
937
|
+
_transitioned(record, EffectState.AMBIGUOUS, now, error=LEASE_EXPIRED),
|
|
938
|
+
)
|
|
939
|
+
connection.commit()
|
|
940
|
+
raise plan.refusal
|
|
941
|
+
return plan
|
|
942
|
+
|
|
943
|
+
def _reserve_locked(
|
|
944
|
+
self,
|
|
945
|
+
connection: sqlite3.Connection,
|
|
946
|
+
reservation: Reservation,
|
|
947
|
+
renews: bool,
|
|
948
|
+
now: datetime,
|
|
949
|
+
) -> None:
|
|
950
|
+
previous = self._read_effect(connection, reservation.effect_key)
|
|
951
|
+
record = _reserved(reservation, previous, now)
|
|
952
|
+
if renews:
|
|
953
|
+
# Only a FAILED record is renewable (§5.4); the WHERE clause says so again, so a
|
|
954
|
+
# record that changed under us refuses instead of overwriting an attempt.
|
|
955
|
+
updated = connection.execute(
|
|
956
|
+
"UPDATE effects SET state=?, action_id=?, attempt=?, lease_expires_at=?, "
|
|
957
|
+
"result_json=NULL, error=NULL, updated_at=? WHERE effect_key=? AND state=?",
|
|
958
|
+
(
|
|
959
|
+
str(record.state),
|
|
960
|
+
record.action_id,
|
|
961
|
+
record.attempt,
|
|
962
|
+
_iso(record.lease_expires_at) if record.lease_expires_at else None,
|
|
963
|
+
_iso(now),
|
|
964
|
+
record.effect_key,
|
|
965
|
+
str(EffectState.FAILED),
|
|
966
|
+
),
|
|
967
|
+
).rowcount
|
|
968
|
+
if updated != 1:
|
|
969
|
+
raise DuplicateEffect(
|
|
970
|
+
f"effect {record.effect_key!r} was taken by another attempt",
|
|
971
|
+
state=IN_PROGRESS_EFFECT,
|
|
972
|
+
effect_key=record.effect_key,
|
|
973
|
+
)
|
|
974
|
+
return
|
|
975
|
+
try:
|
|
976
|
+
connection.execute(
|
|
977
|
+
"INSERT INTO effects(effect_key, state, action_id, attempt, lease_expires_at, "
|
|
978
|
+
"result_json, error, created_at, updated_at) VALUES(?,?,?,?,?,NULL,NULL,?,?)",
|
|
979
|
+
(
|
|
980
|
+
record.effect_key,
|
|
981
|
+
str(record.state),
|
|
982
|
+
record.action_id,
|
|
983
|
+
record.attempt,
|
|
984
|
+
_iso(record.lease_expires_at) if record.lease_expires_at else None,
|
|
985
|
+
_iso(record.created_at),
|
|
986
|
+
_iso(record.updated_at),
|
|
987
|
+
),
|
|
988
|
+
)
|
|
989
|
+
except sqlite3.IntegrityError as exc:
|
|
990
|
+
# UNIQUE(effect_key). Unreachable while BEGIN IMMEDIATE holds the write lock,
|
|
991
|
+
# which is exactly why it is worth keeping: the constraint is the last word.
|
|
992
|
+
raise DuplicateEffect(
|
|
993
|
+
f"effect {record.effect_key!r} is already reserved",
|
|
994
|
+
state=IN_PROGRESS_EFFECT,
|
|
995
|
+
effect_key=record.effect_key,
|
|
996
|
+
) from exc
|
|
997
|
+
|
|
998
|
+
def begin_execution(self, effect_key: str, action_id: str) -> None:
|
|
999
|
+
self._transition(effect_key, action_id, EffectState.EXECUTING, _RESERVED)
|
|
1000
|
+
|
|
1001
|
+
def commit_effect(self, effect_key: str, action_id: str, result: Any) -> None:
|
|
1002
|
+
self._transition(effect_key, action_id, EffectState.COMMITTED, _EXECUTING, result=result)
|
|
1003
|
+
|
|
1004
|
+
def fail_effect(self, effect_key: str, action_id: str, error: str) -> None:
|
|
1005
|
+
self._transition(effect_key, action_id, EffectState.FAILED, _EXECUTING, error=error)
|
|
1006
|
+
|
|
1007
|
+
def mark_ambiguous(self, effect_key: str, action_id: str, error: str) -> None:
|
|
1008
|
+
self._transition(effect_key, action_id, EffectState.AMBIGUOUS, _UNFINISHED, error=error)
|
|
1009
|
+
|
|
1010
|
+
def resolve_effect(self, effect_key: str, state: EffectState, resolver: str) -> EffectRecord:
|
|
1011
|
+
resolver = _approver(resolver)
|
|
1012
|
+
connection = self._connection()
|
|
1013
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
1014
|
+
try:
|
|
1015
|
+
record = _resolvable(self._read_effect(connection, effect_key), effect_key, state)
|
|
1016
|
+
resolved = _resolved(record, state, resolver, self._clock())
|
|
1017
|
+
self._write_effect(connection, resolved)
|
|
1018
|
+
except BaseException:
|
|
1019
|
+
self._unwind(connection)
|
|
1020
|
+
raise
|
|
1021
|
+
connection.commit()
|
|
1022
|
+
return resolved
|
|
1023
|
+
|
|
1024
|
+
def get_effect(self, effect_key: str) -> EffectRecord | None:
|
|
1025
|
+
return self._read_effect(self._connection(), effect_key)
|
|
1026
|
+
|
|
1027
|
+
def list_effects(self, state: EffectState | None = None) -> tuple[EffectRecord, ...]:
|
|
1028
|
+
rows = (
|
|
1029
|
+
self._connection()
|
|
1030
|
+
.execute(
|
|
1031
|
+
"SELECT effect_key FROM effects"
|
|
1032
|
+
+ ("" if state is None else " WHERE state=?")
|
|
1033
|
+
+ " ORDER BY created_at, rowid",
|
|
1034
|
+
() if state is None else (str(state),),
|
|
1035
|
+
)
|
|
1036
|
+
.fetchall()
|
|
1037
|
+
)
|
|
1038
|
+
records = (self.get_effect(row["effect_key"]) for row in rows)
|
|
1039
|
+
return tuple(record for record in records if record is not None)
|
|
1040
|
+
|
|
1041
|
+
def _transition(
|
|
1042
|
+
self,
|
|
1043
|
+
effect_key: str,
|
|
1044
|
+
action_id: str,
|
|
1045
|
+
state: EffectState,
|
|
1046
|
+
expected: frozenset[EffectState],
|
|
1047
|
+
*,
|
|
1048
|
+
result: Any = None,
|
|
1049
|
+
error: str | None = None,
|
|
1050
|
+
) -> None:
|
|
1051
|
+
connection = self._connection()
|
|
1052
|
+
now = self._clock()
|
|
1053
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
1054
|
+
try:
|
|
1055
|
+
record = _checked(
|
|
1056
|
+
self._read_effect(connection, effect_key), effect_key, action_id, expected, now
|
|
1057
|
+
)
|
|
1058
|
+
self._write_effect(
|
|
1059
|
+
connection, _transitioned(record, state, now, result=result, error=error)
|
|
1060
|
+
)
|
|
1061
|
+
except BaseException:
|
|
1062
|
+
self._unwind(connection)
|
|
1063
|
+
raise
|
|
1064
|
+
connection.commit()
|
|
1065
|
+
|
|
1066
|
+
def _read_effect(self, connection: sqlite3.Connection, effect_key: str) -> EffectRecord | None:
|
|
1067
|
+
row = connection.execute(
|
|
1068
|
+
"SELECT * FROM effects WHERE effect_key=?", (effect_key,)
|
|
1069
|
+
).fetchone()
|
|
1070
|
+
if row is None:
|
|
1071
|
+
return None
|
|
1072
|
+
return EffectRecord(
|
|
1073
|
+
effect_key=row["effect_key"],
|
|
1074
|
+
state=EffectState(row["state"]),
|
|
1075
|
+
action_id=row["action_id"],
|
|
1076
|
+
attempt=row["attempt"],
|
|
1077
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
1078
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
1079
|
+
lease_expires_at=_at(row["lease_expires_at"]),
|
|
1080
|
+
result=_result_value(row["result_json"]),
|
|
1081
|
+
error=row["error"],
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
def _write_effect(self, connection: sqlite3.Connection, record: EffectRecord) -> None:
|
|
1085
|
+
connection.execute(
|
|
1086
|
+
"UPDATE effects SET state=?, action_id=?, attempt=?, lease_expires_at=?, "
|
|
1087
|
+
"result_json=?, error=?, updated_at=? WHERE effect_key=?",
|
|
1088
|
+
(
|
|
1089
|
+
str(record.state),
|
|
1090
|
+
record.action_id,
|
|
1091
|
+
record.attempt,
|
|
1092
|
+
_iso(record.lease_expires_at) if record.lease_expires_at else None,
|
|
1093
|
+
_result_json(record.result),
|
|
1094
|
+
record.error,
|
|
1095
|
+
_iso(record.updated_at),
|
|
1096
|
+
record.effect_key,
|
|
1097
|
+
),
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
@staticmethod
|
|
1101
|
+
def _unwind(connection: sqlite3.Connection) -> None:
|
|
1102
|
+
"""Undo the open transaction, if this failure did not already close one."""
|
|
1103
|
+
connection.rollback()
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
_T = TypeVar("_T")
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _only(value: _T | None, what: str) -> _T:
|
|
1110
|
+
"""Narrow a result the caller did ask for. A store returning nothing here is broken."""
|
|
1111
|
+
if value is None:
|
|
1112
|
+
raise InvalidArgument(f"the store produced no {what}")
|
|
1113
|
+
return value
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _approver(approver: str) -> str:
|
|
1117
|
+
if not approver:
|
|
1118
|
+
raise InvalidArgument("approver must be a non-empty string")
|
|
1119
|
+
return approver
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _required_hash(action_hash: str | None) -> str:
|
|
1123
|
+
if not action_hash:
|
|
1124
|
+
raise InvalidArgument("consuming an approval needs the action hash it must authorize")
|
|
1125
|
+
return action_hash
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _required_action(action_id: str | None) -> str:
|
|
1129
|
+
if not action_id:
|
|
1130
|
+
raise InvalidArgument("reserving an effect needs the action_id reserving it")
|
|
1131
|
+
return action_id
|