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/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