apsimo-hostworker 0.2.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.
- apsimo_hostworker/__init__.py +134 -0
- apsimo_hostworker/_private_io.py +267 -0
- apsimo_hostworker/admission.py +297 -0
- apsimo_hostworker/catalog.py +754 -0
- apsimo_hostworker/client.py +292 -0
- apsimo_hostworker/compat.py +53 -0
- apsimo_hostworker/conformance/__init__.py +52 -0
- apsimo_hostworker/conformance/__main__.py +29 -0
- apsimo_hostworker/conformance/harness.py +278 -0
- apsimo_hostworker/conformance/suite.py +1109 -0
- apsimo_hostworker/contract.py +402 -0
- apsimo_hostworker/gate.py +446 -0
- apsimo_hostworker/intent.py +221 -0
- apsimo_hostworker/sqlite_store.py +1815 -0
- apsimo_hostworker/store.py +446 -0
- apsimo_hostworker/worker.py +643 -0
- apsimo_hostworker-0.2.0.dist-info/METADATA +24 -0
- apsimo_hostworker-0.2.0.dist-info/RECORD +22 -0
- apsimo_hostworker-0.2.0.dist-info/WHEEL +5 -0
- apsimo_hostworker-0.2.0.dist-info/licenses/LICENSE +21 -0
- apsimo_hostworker-0.2.0.dist-info/top_level.txt +2 -0
- colony_hostworker/__init__.py +4 -0
|
@@ -0,0 +1,1815 @@
|
|
|
1
|
+
"""Reference :class:`~apsimo_hostworker.store.ActionStore` on local SQLite.
|
|
2
|
+
|
|
3
|
+
This is the governed-action SUBSET only: actions, immutable receipts and
|
|
4
|
+
events, dead letters, leases, the owner-authorized dispatch transaction, and
|
|
5
|
+
the bounded GET-only observation machinery. It deliberately omits the
|
|
6
|
+
general-purpose queue features of the host system it was extracted from
|
|
7
|
+
(callback outboxes, message-delivery lifecycles, policy-grant dispatch);
|
|
8
|
+
adding them back here would widen the surface the conformance suite must
|
|
9
|
+
defend.
|
|
10
|
+
|
|
11
|
+
Durability posture: WAL journal, ``synchronous=FULL``, ``BEGIN IMMEDIATE``
|
|
12
|
+
transactions, owner-only 0600 database files, and SQL triggers that make
|
|
13
|
+
receipts, events, and action identity immutable even against buggy code in
|
|
14
|
+
this very module.
|
|
15
|
+
|
|
16
|
+
Every transactional invariant this store upholds is specified in
|
|
17
|
+
:mod:`apsimo_hostworker.store` (I1-I11) and exercised by
|
|
18
|
+
:mod:`apsimo_hostworker.conformance`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import hmac
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import sqlite3
|
|
29
|
+
import stat
|
|
30
|
+
import threading
|
|
31
|
+
import uuid
|
|
32
|
+
from contextlib import contextmanager
|
|
33
|
+
from typing import Any, Mapping, Sequence
|
|
34
|
+
|
|
35
|
+
from .catalog import GRANT_AUTHORIZABLE_TOOL_NAMES
|
|
36
|
+
from .contract import canonical_json_utf8, sha256_json_utf8
|
|
37
|
+
from .gate import GRANT_BINDING_METHOD, GateAuthorization
|
|
38
|
+
from .store import (
|
|
39
|
+
ALLOWED_TRANSITIONS,
|
|
40
|
+
ActionIdempotencyConflict,
|
|
41
|
+
ActionLeaseConflict,
|
|
42
|
+
ActionNotFound,
|
|
43
|
+
ActionStoreError,
|
|
44
|
+
ActionTransitionError,
|
|
45
|
+
GATE_RECEIPT_KIND,
|
|
46
|
+
OBSERVATION_RECEIPT_KIND,
|
|
47
|
+
RECOVERY_RECEIPT_KIND,
|
|
48
|
+
STATE_ACCEPTED,
|
|
49
|
+
STATE_COMPLETED,
|
|
50
|
+
STATE_DISPATCHED,
|
|
51
|
+
STATE_FAILED,
|
|
52
|
+
STATE_GATED,
|
|
53
|
+
STATE_PROPOSED,
|
|
54
|
+
STATE_VERIFIED,
|
|
55
|
+
TERMINAL_STATES,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
RECOVERY_SCHEMA = "ColonyDispatchObservationRecoveryV1"
|
|
59
|
+
OBSERVATION_SCHEMA = "ColonyDispatchObservationAttemptV1"
|
|
60
|
+
|
|
61
|
+
_RESERVED_RECEIPT_KINDS = frozenset(
|
|
62
|
+
{GATE_RECEIPT_KIND, RECOVERY_RECEIPT_KIND, OBSERVATION_RECEIPT_KIND}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_ALL_STATES = (
|
|
66
|
+
STATE_PROPOSED,
|
|
67
|
+
STATE_GATED,
|
|
68
|
+
STATE_DISPATCHED,
|
|
69
|
+
STATE_ACCEPTED,
|
|
70
|
+
STATE_VERIFIED,
|
|
71
|
+
STATE_COMPLETED,
|
|
72
|
+
STATE_FAILED,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_LEASEABLE_STATES = frozenset({STATE_GATED, STATE_ACCEPTED, STATE_VERIFIED})
|
|
76
|
+
|
|
77
|
+
_GATE_CLOCK_SKEW_SECONDS = 30.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SqliteActionStore:
|
|
81
|
+
"""SQLite-backed governed-action lifecycle store."""
|
|
82
|
+
|
|
83
|
+
SCHEMA_VERSION = 1
|
|
84
|
+
|
|
85
|
+
def __init__(self, path: str, *, clock=None, busy_timeout_ms: int = 5000):
|
|
86
|
+
import time as _time
|
|
87
|
+
|
|
88
|
+
self.path = os.path.abspath(os.path.expanduser(path))
|
|
89
|
+
self._clock = clock or _time.time
|
|
90
|
+
self._lock = threading.RLock()
|
|
91
|
+
|
|
92
|
+
parent = os.path.dirname(self.path)
|
|
93
|
+
if parent:
|
|
94
|
+
os.makedirs(parent, mode=0o700, exist_ok=True)
|
|
95
|
+
# SQLite creates the database, WAL, and shared-memory files lazily.
|
|
96
|
+
# A restrictive creation mask prevents payloads and receipts from
|
|
97
|
+
# being briefly world-readable before the explicit chmod below.
|
|
98
|
+
previous_umask = os.umask(0o077)
|
|
99
|
+
try:
|
|
100
|
+
self._conn = sqlite3.connect(
|
|
101
|
+
self.path,
|
|
102
|
+
timeout=max(float(busy_timeout_ms) / 1000.0, 0.001),
|
|
103
|
+
isolation_level=None,
|
|
104
|
+
check_same_thread=False,
|
|
105
|
+
)
|
|
106
|
+
finally:
|
|
107
|
+
os.umask(previous_umask)
|
|
108
|
+
self._conn.row_factory = sqlite3.Row
|
|
109
|
+
existing_version = self._conn.execute("PRAGMA user_version").fetchone()[0]
|
|
110
|
+
if existing_version not in (0, self.SCHEMA_VERSION):
|
|
111
|
+
self.close()
|
|
112
|
+
raise ActionStoreError(
|
|
113
|
+
"unsupported action-store schema version %s" % existing_version
|
|
114
|
+
)
|
|
115
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
116
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
117
|
+
self._conn.execute("PRAGMA synchronous=FULL")
|
|
118
|
+
self._conn.execute("PRAGMA busy_timeout=%d" % int(busy_timeout_ms))
|
|
119
|
+
try:
|
|
120
|
+
self._create_schema()
|
|
121
|
+
self._secure_database_files()
|
|
122
|
+
except Exception:
|
|
123
|
+
self.close()
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------- plumbing
|
|
127
|
+
|
|
128
|
+
def _secure_database_files(self):
|
|
129
|
+
private_mode = stat.S_IRUSR | stat.S_IWUSR
|
|
130
|
+
for candidate in (self.path, self.path + "-wal", self.path + "-shm"):
|
|
131
|
+
try:
|
|
132
|
+
os.chmod(candidate, private_mode)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
def close(self):
|
|
137
|
+
with self._lock:
|
|
138
|
+
if self._conn is not None:
|
|
139
|
+
self._conn.close()
|
|
140
|
+
self._conn = None
|
|
141
|
+
|
|
142
|
+
def __enter__(self):
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def __exit__(self, exc_type, exc, traceback):
|
|
146
|
+
self.close()
|
|
147
|
+
|
|
148
|
+
@contextmanager
|
|
149
|
+
def _transaction(self):
|
|
150
|
+
with self._lock:
|
|
151
|
+
if self._conn is None:
|
|
152
|
+
raise ActionStoreError("action store is closed")
|
|
153
|
+
cursor = self._conn.cursor()
|
|
154
|
+
cursor.execute("BEGIN IMMEDIATE")
|
|
155
|
+
try:
|
|
156
|
+
yield cursor
|
|
157
|
+
except Exception:
|
|
158
|
+
self._conn.rollback()
|
|
159
|
+
raise
|
|
160
|
+
else:
|
|
161
|
+
self._conn.commit()
|
|
162
|
+
self._secure_database_files()
|
|
163
|
+
finally:
|
|
164
|
+
cursor.close()
|
|
165
|
+
|
|
166
|
+
def _create_schema(self):
|
|
167
|
+
states = ",".join("'%s'" % state for state in _ALL_STATES)
|
|
168
|
+
ddl = """
|
|
169
|
+
CREATE TABLE IF NOT EXISTS actions (
|
|
170
|
+
action_id TEXT PRIMARY KEY,
|
|
171
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
172
|
+
source TEXT NOT NULL,
|
|
173
|
+
source_ref TEXT,
|
|
174
|
+
action_type TEXT NOT NULL,
|
|
175
|
+
payload_json TEXT NOT NULL,
|
|
176
|
+
payload_sha256 TEXT NOT NULL,
|
|
177
|
+
state TEXT NOT NULL CHECK (state IN (%s)),
|
|
178
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
179
|
+
max_attempts INTEGER NOT NULL CHECK (max_attempts = 1),
|
|
180
|
+
next_attempt_at REAL NOT NULL,
|
|
181
|
+
lease_owner TEXT,
|
|
182
|
+
lease_expires_at REAL,
|
|
183
|
+
last_error TEXT,
|
|
184
|
+
result_json TEXT,
|
|
185
|
+
created_at REAL NOT NULL,
|
|
186
|
+
updated_at REAL NOT NULL,
|
|
187
|
+
terminal_at REAL
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
CREATE INDEX IF NOT EXISTS idx_actions_ready
|
|
191
|
+
ON actions(state, next_attempt_at, lease_expires_at, created_at);
|
|
192
|
+
CREATE INDEX IF NOT EXISTS idx_actions_source_ref
|
|
193
|
+
ON actions(source, source_ref);
|
|
194
|
+
|
|
195
|
+
CREATE TABLE IF NOT EXISTS action_events (
|
|
196
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
197
|
+
action_id TEXT NOT NULL REFERENCES actions(action_id),
|
|
198
|
+
event_type TEXT NOT NULL,
|
|
199
|
+
from_state TEXT,
|
|
200
|
+
to_state TEXT,
|
|
201
|
+
actor TEXT NOT NULL,
|
|
202
|
+
details_json TEXT NOT NULL,
|
|
203
|
+
created_at REAL NOT NULL
|
|
204
|
+
);
|
|
205
|
+
CREATE INDEX IF NOT EXISTS idx_action_events_action
|
|
206
|
+
ON action_events(action_id, event_id);
|
|
207
|
+
|
|
208
|
+
CREATE TABLE IF NOT EXISTS receipts (
|
|
209
|
+
receipt_id TEXT PRIMARY KEY,
|
|
210
|
+
action_id TEXT NOT NULL REFERENCES actions(action_id),
|
|
211
|
+
receipt_key TEXT NOT NULL,
|
|
212
|
+
kind TEXT NOT NULL,
|
|
213
|
+
status TEXT NOT NULL,
|
|
214
|
+
external_id TEXT,
|
|
215
|
+
evidence_json TEXT NOT NULL,
|
|
216
|
+
evidence_sha256 TEXT NOT NULL,
|
|
217
|
+
observed_at REAL NOT NULL,
|
|
218
|
+
created_at REAL NOT NULL,
|
|
219
|
+
UNIQUE(action_id, receipt_key)
|
|
220
|
+
);
|
|
221
|
+
CREATE INDEX IF NOT EXISTS idx_receipts_action
|
|
222
|
+
ON receipts(action_id, created_at);
|
|
223
|
+
|
|
224
|
+
CREATE TABLE IF NOT EXISTS dead_letters (
|
|
225
|
+
dead_letter_id TEXT PRIMARY KEY,
|
|
226
|
+
action_id TEXT NOT NULL UNIQUE REFERENCES actions(action_id),
|
|
227
|
+
reason TEXT NOT NULL,
|
|
228
|
+
action_snapshot_json TEXT NOT NULL,
|
|
229
|
+
created_at REAL NOT NULL
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
CREATE TRIGGER IF NOT EXISTS action_events_no_update
|
|
233
|
+
BEFORE UPDATE ON action_events BEGIN
|
|
234
|
+
SELECT RAISE(ABORT, 'action events are immutable');
|
|
235
|
+
END;
|
|
236
|
+
CREATE TRIGGER IF NOT EXISTS action_events_no_delete
|
|
237
|
+
BEFORE DELETE ON action_events BEGIN
|
|
238
|
+
SELECT RAISE(ABORT, 'action events are immutable');
|
|
239
|
+
END;
|
|
240
|
+
CREATE TRIGGER IF NOT EXISTS receipts_no_update
|
|
241
|
+
BEFORE UPDATE ON receipts BEGIN
|
|
242
|
+
SELECT RAISE(ABORT, 'receipts are immutable');
|
|
243
|
+
END;
|
|
244
|
+
CREATE TRIGGER IF NOT EXISTS receipts_no_delete
|
|
245
|
+
BEFORE DELETE ON receipts BEGIN
|
|
246
|
+
SELECT RAISE(ABORT, 'receipts are immutable');
|
|
247
|
+
END;
|
|
248
|
+
CREATE TRIGGER IF NOT EXISTS actions_identity_no_update
|
|
249
|
+
BEFORE UPDATE OF action_id,idempotency_key,source,source_ref,action_type,
|
|
250
|
+
payload_json,payload_sha256,max_attempts,created_at
|
|
251
|
+
ON actions BEGIN
|
|
252
|
+
SELECT RAISE(ABORT, 'action identity and payload are immutable');
|
|
253
|
+
END;
|
|
254
|
+
""" % states
|
|
255
|
+
with self._lock:
|
|
256
|
+
try:
|
|
257
|
+
self._conn.executescript(
|
|
258
|
+
"BEGIN IMMEDIATE;\n"
|
|
259
|
+
+ ddl
|
|
260
|
+
+ "\nPRAGMA user_version=%d;\nCOMMIT;" % self.SCHEMA_VERSION
|
|
261
|
+
)
|
|
262
|
+
except Exception:
|
|
263
|
+
self._conn.rollback()
|
|
264
|
+
raise
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def _validate_text(name, value):
|
|
268
|
+
if not isinstance(value, str) or not value.strip():
|
|
269
|
+
raise ActionStoreError("%s must be a non-empty string" % name)
|
|
270
|
+
return value.strip()
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _json_load(value):
|
|
274
|
+
return json.loads(value) if value is not None else None
|
|
275
|
+
|
|
276
|
+
def _action_dict(self, row):
|
|
277
|
+
if row is None:
|
|
278
|
+
return None
|
|
279
|
+
result = dict(row)
|
|
280
|
+
raw_payload = result.pop("payload_json")
|
|
281
|
+
try:
|
|
282
|
+
payload = self._json_load(raw_payload)
|
|
283
|
+
canonical = canonical_json_utf8(payload)
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
raise ActionStoreError("action payload is not canonical JSON") from exc
|
|
286
|
+
observed = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
287
|
+
if not hmac.compare_digest(observed, str(result.get("payload_sha256") or "")):
|
|
288
|
+
raise ActionStoreError(
|
|
289
|
+
"action payload digest does not match its immutable record"
|
|
290
|
+
)
|
|
291
|
+
result["payload"] = payload
|
|
292
|
+
result["result"] = self._json_load(result.pop("result_json"))
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
def _receipt_dict(self, row):
|
|
296
|
+
result = dict(row)
|
|
297
|
+
result["evidence"] = self._json_load(result.pop("evidence_json"))
|
|
298
|
+
return result
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _get_action_row(cursor, action_id):
|
|
302
|
+
row = cursor.execute(
|
|
303
|
+
"SELECT * FROM actions WHERE action_id=?", (action_id,)
|
|
304
|
+
).fetchone()
|
|
305
|
+
if row is None:
|
|
306
|
+
raise ActionNotFound("action %s not found" % action_id)
|
|
307
|
+
return row
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _require_lease(row, owner, now):
|
|
311
|
+
if not owner or row["lease_owner"] != owner:
|
|
312
|
+
raise ActionLeaseConflict(
|
|
313
|
+
"record is not leased by %s" % (owner or "<empty>")
|
|
314
|
+
)
|
|
315
|
+
expires = row["lease_expires_at"]
|
|
316
|
+
if expires is None or float(expires) <= now:
|
|
317
|
+
raise ActionLeaseConflict("lease for %s has expired" % owner)
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _check_transition(from_state, to_state):
|
|
321
|
+
if to_state not in ALLOWED_TRANSITIONS.get(from_state, frozenset()):
|
|
322
|
+
raise ActionTransitionError(
|
|
323
|
+
"cannot transition %s -> %s" % (from_state, to_state)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _add_event(cursor, action_id, event_type, from_state, to_state, actor, details, now):
|
|
328
|
+
cursor.execute(
|
|
329
|
+
"""INSERT INTO action_events
|
|
330
|
+
(action_id,event_type,from_state,to_state,actor,details_json,created_at)
|
|
331
|
+
VALUES (?,?,?,?,?,?,?)""",
|
|
332
|
+
(
|
|
333
|
+
action_id,
|
|
334
|
+
event_type,
|
|
335
|
+
from_state,
|
|
336
|
+
to_state,
|
|
337
|
+
actor,
|
|
338
|
+
canonical_json_utf8(details or {}),
|
|
339
|
+
now,
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def _transition(
|
|
344
|
+
self,
|
|
345
|
+
cursor,
|
|
346
|
+
row,
|
|
347
|
+
to_state,
|
|
348
|
+
actor,
|
|
349
|
+
event_type,
|
|
350
|
+
details,
|
|
351
|
+
now,
|
|
352
|
+
result=None,
|
|
353
|
+
error=None,
|
|
354
|
+
clear_lease=False,
|
|
355
|
+
next_attempt_at=None,
|
|
356
|
+
):
|
|
357
|
+
from_state = row["state"]
|
|
358
|
+
self._check_transition(from_state, to_state)
|
|
359
|
+
terminal_at = now if to_state in TERMINAL_STATES else None
|
|
360
|
+
result_json = (
|
|
361
|
+
canonical_json_utf8(result) if result is not None else row["result_json"]
|
|
362
|
+
)
|
|
363
|
+
lease_owner = None if clear_lease else row["lease_owner"]
|
|
364
|
+
lease_expires = None if clear_lease else row["lease_expires_at"]
|
|
365
|
+
next_at = row["next_attempt_at"] if next_attempt_at is None else next_attempt_at
|
|
366
|
+
cursor.execute(
|
|
367
|
+
"""UPDATE actions
|
|
368
|
+
SET state=?, result_json=?, last_error=?, next_attempt_at=?,
|
|
369
|
+
lease_owner=?, lease_expires_at=?, updated_at=?, terminal_at=?
|
|
370
|
+
WHERE action_id=?""",
|
|
371
|
+
(
|
|
372
|
+
to_state,
|
|
373
|
+
result_json,
|
|
374
|
+
error,
|
|
375
|
+
next_at,
|
|
376
|
+
lease_owner,
|
|
377
|
+
lease_expires,
|
|
378
|
+
now,
|
|
379
|
+
terminal_at,
|
|
380
|
+
row["action_id"],
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
self._add_event(
|
|
384
|
+
cursor,
|
|
385
|
+
row["action_id"],
|
|
386
|
+
event_type,
|
|
387
|
+
from_state,
|
|
388
|
+
to_state,
|
|
389
|
+
actor,
|
|
390
|
+
details,
|
|
391
|
+
now,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _dead_letter_action(self, cursor, action_id, reason, now):
|
|
395
|
+
row = self._get_action_row(cursor, action_id)
|
|
396
|
+
snapshot = self._action_dict(row)
|
|
397
|
+
cursor.execute(
|
|
398
|
+
"""INSERT OR IGNORE INTO dead_letters
|
|
399
|
+
(dead_letter_id,action_id,reason,action_snapshot_json,created_at)
|
|
400
|
+
VALUES (?,?,?,?,?)""",
|
|
401
|
+
(str(uuid.uuid4()), action_id, reason, canonical_json_utf8(snapshot), now),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------------------- ingress
|
|
405
|
+
|
|
406
|
+
def propose(
|
|
407
|
+
self,
|
|
408
|
+
idempotency_key,
|
|
409
|
+
source,
|
|
410
|
+
action_type,
|
|
411
|
+
payload,
|
|
412
|
+
source_ref=None,
|
|
413
|
+
action_id=None,
|
|
414
|
+
actor="ingress",
|
|
415
|
+
):
|
|
416
|
+
"""Insert one immutable governed action in ``proposed`` state.
|
|
417
|
+
|
|
418
|
+
``max_attempts`` is pinned to 1 for every governed action — a schema
|
|
419
|
+
CHECK re-enforces it — so the one-mutation guarantee cannot be
|
|
420
|
+
configured away at ingress.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
idempotency_key = self._validate_text("idempotency_key", idempotency_key)
|
|
424
|
+
source = self._validate_text("source", source)
|
|
425
|
+
action_type = self._validate_text("action_type", action_type)
|
|
426
|
+
if source_ref is not None and not isinstance(source_ref, str):
|
|
427
|
+
raise ActionStoreError("source_ref must be a string or None")
|
|
428
|
+
payload_json = canonical_json_utf8(payload)
|
|
429
|
+
payload_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
|
|
430
|
+
now = float(self._clock())
|
|
431
|
+
action_id = action_id or str(uuid.uuid4())
|
|
432
|
+
|
|
433
|
+
with self._transaction() as cursor:
|
|
434
|
+
existing = cursor.execute(
|
|
435
|
+
"SELECT * FROM actions WHERE idempotency_key=?", (idempotency_key,)
|
|
436
|
+
).fetchone()
|
|
437
|
+
if existing is not None:
|
|
438
|
+
same = (
|
|
439
|
+
existing["source"] == source
|
|
440
|
+
and existing["source_ref"] == source_ref
|
|
441
|
+
and existing["action_type"] == action_type
|
|
442
|
+
and existing["payload_sha256"] == payload_hash
|
|
443
|
+
)
|
|
444
|
+
if not same:
|
|
445
|
+
raise ActionIdempotencyConflict(
|
|
446
|
+
"action idempotency key %s was reused with different content"
|
|
447
|
+
% idempotency_key
|
|
448
|
+
)
|
|
449
|
+
return self._action_dict(existing)
|
|
450
|
+
|
|
451
|
+
cursor.execute(
|
|
452
|
+
"""INSERT INTO actions
|
|
453
|
+
(action_id,idempotency_key,source,source_ref,action_type,payload_json,
|
|
454
|
+
payload_sha256,state,attempt_count,max_attempts,next_attempt_at,
|
|
455
|
+
created_at,updated_at)
|
|
456
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
457
|
+
(
|
|
458
|
+
action_id,
|
|
459
|
+
idempotency_key,
|
|
460
|
+
source,
|
|
461
|
+
source_ref,
|
|
462
|
+
action_type,
|
|
463
|
+
payload_json,
|
|
464
|
+
payload_hash,
|
|
465
|
+
STATE_PROPOSED,
|
|
466
|
+
0,
|
|
467
|
+
1,
|
|
468
|
+
now,
|
|
469
|
+
now,
|
|
470
|
+
now,
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
self._add_event(
|
|
474
|
+
cursor,
|
|
475
|
+
action_id,
|
|
476
|
+
"proposed",
|
|
477
|
+
None,
|
|
478
|
+
STATE_PROPOSED,
|
|
479
|
+
actor,
|
|
480
|
+
{
|
|
481
|
+
"source": source,
|
|
482
|
+
"source_ref": source_ref,
|
|
483
|
+
"action_type": action_type,
|
|
484
|
+
},
|
|
485
|
+
now,
|
|
486
|
+
)
|
|
487
|
+
return self._action_dict(self._get_action_row(cursor, action_id))
|
|
488
|
+
|
|
489
|
+
def _insert_receipt(
|
|
490
|
+
self,
|
|
491
|
+
cursor,
|
|
492
|
+
action_id,
|
|
493
|
+
receipt_key,
|
|
494
|
+
kind,
|
|
495
|
+
status_value,
|
|
496
|
+
evidence,
|
|
497
|
+
external_id,
|
|
498
|
+
observed_at,
|
|
499
|
+
now,
|
|
500
|
+
):
|
|
501
|
+
receipt_key = self._validate_text("receipt_key", receipt_key)
|
|
502
|
+
kind = self._validate_text("kind", kind)
|
|
503
|
+
status_value = self._validate_text("status", status_value)
|
|
504
|
+
evidence_json = canonical_json_utf8(evidence)
|
|
505
|
+
evidence_hash = hashlib.sha256(evidence_json.encode("utf-8")).hexdigest()
|
|
506
|
+
existing = cursor.execute(
|
|
507
|
+
"SELECT * FROM receipts WHERE action_id=? AND receipt_key=?",
|
|
508
|
+
(action_id, receipt_key),
|
|
509
|
+
).fetchone()
|
|
510
|
+
if existing is not None:
|
|
511
|
+
same = (
|
|
512
|
+
existing["kind"] == kind
|
|
513
|
+
and existing["status"] == status_value
|
|
514
|
+
and existing["external_id"] == external_id
|
|
515
|
+
and existing["evidence_sha256"] == evidence_hash
|
|
516
|
+
)
|
|
517
|
+
if not same:
|
|
518
|
+
raise ActionIdempotencyConflict(
|
|
519
|
+
"receipt key %s was reused with different evidence" % receipt_key
|
|
520
|
+
)
|
|
521
|
+
return self._receipt_dict(existing)
|
|
522
|
+
receipt_id = str(uuid.uuid4())
|
|
523
|
+
cursor.execute(
|
|
524
|
+
"""INSERT INTO receipts
|
|
525
|
+
(receipt_id,action_id,receipt_key,kind,status,external_id,evidence_json,
|
|
526
|
+
evidence_sha256,observed_at,created_at)
|
|
527
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
|
528
|
+
(
|
|
529
|
+
receipt_id,
|
|
530
|
+
action_id,
|
|
531
|
+
receipt_key,
|
|
532
|
+
kind,
|
|
533
|
+
status_value,
|
|
534
|
+
external_id,
|
|
535
|
+
evidence_json,
|
|
536
|
+
evidence_hash,
|
|
537
|
+
float(observed_at if observed_at is not None else now),
|
|
538
|
+
now,
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
return self._receipt_dict(
|
|
542
|
+
cursor.execute(
|
|
543
|
+
"SELECT * FROM receipts WHERE receipt_id=?", (receipt_id,)
|
|
544
|
+
).fetchone()
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
def add_receipt(
|
|
548
|
+
self,
|
|
549
|
+
action_id,
|
|
550
|
+
receipt_key,
|
|
551
|
+
kind,
|
|
552
|
+
status_value,
|
|
553
|
+
evidence,
|
|
554
|
+
external_id=None,
|
|
555
|
+
observed_at=None,
|
|
556
|
+
):
|
|
557
|
+
"""Append one generic receipt. Reserved kinds are refused here: the
|
|
558
|
+
gate receipt is written by :meth:`gate` and the recovery/observation
|
|
559
|
+
receipts only by their transactional owners."""
|
|
560
|
+
|
|
561
|
+
kind = self._validate_text("kind", kind)
|
|
562
|
+
if kind in _RESERVED_RECEIPT_KINDS:
|
|
563
|
+
raise ActionStoreError(
|
|
564
|
+
"receipt kind %s is reserved for its transactional owner" % kind
|
|
565
|
+
)
|
|
566
|
+
now = float(self._clock())
|
|
567
|
+
with self._transaction() as cursor:
|
|
568
|
+
self._get_action_row(cursor, action_id)
|
|
569
|
+
return self._insert_receipt(
|
|
570
|
+
cursor,
|
|
571
|
+
action_id,
|
|
572
|
+
receipt_key,
|
|
573
|
+
kind,
|
|
574
|
+
status_value,
|
|
575
|
+
evidence,
|
|
576
|
+
external_id,
|
|
577
|
+
observed_at,
|
|
578
|
+
now,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def gate(
|
|
582
|
+
self,
|
|
583
|
+
action_id,
|
|
584
|
+
evidence,
|
|
585
|
+
receipt_key="owner-gate",
|
|
586
|
+
actor="gate",
|
|
587
|
+
external_id=None,
|
|
588
|
+
):
|
|
589
|
+
"""Attach the owner-approval gate receipt and move to ``gated``.
|
|
590
|
+
|
|
591
|
+
The store records the evidence verbatim; it does NOT judge it.
|
|
592
|
+
Judgment happens at point of use, inside
|
|
593
|
+
:meth:`begin_owner_authorized_dispatch` (invariant I4).
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
now = float(self._clock())
|
|
597
|
+
with self._transaction() as cursor:
|
|
598
|
+
row = self._get_action_row(cursor, action_id)
|
|
599
|
+
if row["state"] == STATE_GATED:
|
|
600
|
+
receipt = self._insert_receipt(
|
|
601
|
+
cursor,
|
|
602
|
+
action_id,
|
|
603
|
+
receipt_key,
|
|
604
|
+
GATE_RECEIPT_KIND,
|
|
605
|
+
"passed",
|
|
606
|
+
evidence,
|
|
607
|
+
external_id,
|
|
608
|
+
None,
|
|
609
|
+
now,
|
|
610
|
+
)
|
|
611
|
+
return self._action_dict(row), receipt
|
|
612
|
+
if row["state"] != STATE_PROPOSED:
|
|
613
|
+
raise ActionTransitionError("only proposed actions can be gated")
|
|
614
|
+
receipt = self._insert_receipt(
|
|
615
|
+
cursor,
|
|
616
|
+
action_id,
|
|
617
|
+
receipt_key,
|
|
618
|
+
GATE_RECEIPT_KIND,
|
|
619
|
+
"passed",
|
|
620
|
+
evidence,
|
|
621
|
+
external_id,
|
|
622
|
+
None,
|
|
623
|
+
now,
|
|
624
|
+
)
|
|
625
|
+
self._transition(
|
|
626
|
+
cursor,
|
|
627
|
+
row,
|
|
628
|
+
STATE_GATED,
|
|
629
|
+
actor,
|
|
630
|
+
"gate_passed",
|
|
631
|
+
{"receipt_key": receipt_key},
|
|
632
|
+
now,
|
|
633
|
+
)
|
|
634
|
+
return self._action_dict(self._get_action_row(cursor, action_id)), receipt
|
|
635
|
+
|
|
636
|
+
# --------------------------------------------------- leases & recovery
|
|
637
|
+
|
|
638
|
+
@classmethod
|
|
639
|
+
def _lease_scope(cls, *, source=None, source_prefix=None, action_type=None, action_ids=None):
|
|
640
|
+
if source is not None and source_prefix is not None:
|
|
641
|
+
raise ActionStoreError("source and source_prefix are mutually exclusive")
|
|
642
|
+
clauses = []
|
|
643
|
+
values = []
|
|
644
|
+
if source is not None:
|
|
645
|
+
clauses.append("source=?")
|
|
646
|
+
values.append(cls._validate_text("source", source))
|
|
647
|
+
if source_prefix is not None:
|
|
648
|
+
prefix = cls._validate_text("source_prefix", source_prefix)
|
|
649
|
+
clauses.append("substr(source,1,?)=?")
|
|
650
|
+
values.extend((len(prefix), prefix))
|
|
651
|
+
if action_type is not None:
|
|
652
|
+
clauses.append("action_type=?")
|
|
653
|
+
values.append(cls._validate_text("action_type", action_type))
|
|
654
|
+
if action_ids is not None:
|
|
655
|
+
ids = tuple(str(action_id) for action_id in action_ids)
|
|
656
|
+
if not ids:
|
|
657
|
+
clauses.append("0")
|
|
658
|
+
else:
|
|
659
|
+
clauses.append("action_id IN (%s)" % ",".join("?" for _ in ids))
|
|
660
|
+
values.extend(ids)
|
|
661
|
+
return (" AND " + " AND ".join(clauses) if clauses else ""), values
|
|
662
|
+
|
|
663
|
+
def _dispatch_observation_recovery(self, cursor, row):
|
|
664
|
+
"""Return one valid immutable GET-only recovery contract, if present.
|
|
665
|
+
|
|
666
|
+
Ordinary dispatched actions keep the conservative lease-reaper
|
|
667
|
+
behavior (terminal ambiguity). Only a contract inserted atomically
|
|
668
|
+
by :meth:`begin_owner_authorized_dispatch` changes an expired
|
|
669
|
+
dispatch into read-only reconciliation work.
|
|
670
|
+
"""
|
|
671
|
+
|
|
672
|
+
receipts = cursor.execute(
|
|
673
|
+
"SELECT * FROM receipts WHERE action_id=? AND kind=?",
|
|
674
|
+
(row["action_id"], RECOVERY_RECEIPT_KIND),
|
|
675
|
+
).fetchall()
|
|
676
|
+
if len(receipts) != 1:
|
|
677
|
+
return None
|
|
678
|
+
receipt = receipts[0]
|
|
679
|
+
try:
|
|
680
|
+
evidence = json.loads(receipt["evidence_json"])
|
|
681
|
+
issued_at = float(evidence.get("issued_at"))
|
|
682
|
+
deadline = float(evidence.get("observation_deadline"))
|
|
683
|
+
except Exception:
|
|
684
|
+
return None
|
|
685
|
+
execution_digest = evidence.get("execution_digest")
|
|
686
|
+
fields = {
|
|
687
|
+
"schema",
|
|
688
|
+
"version",
|
|
689
|
+
"action_id",
|
|
690
|
+
"action_digest",
|
|
691
|
+
"execution_digest",
|
|
692
|
+
"issued_at",
|
|
693
|
+
"observation_deadline",
|
|
694
|
+
"max_observations",
|
|
695
|
+
}
|
|
696
|
+
if (
|
|
697
|
+
not isinstance(evidence, dict)
|
|
698
|
+
or set(evidence) != fields
|
|
699
|
+
or evidence.get("schema") != RECOVERY_SCHEMA
|
|
700
|
+
or isinstance(evidence.get("version"), bool)
|
|
701
|
+
or evidence.get("version") != 1
|
|
702
|
+
or evidence.get("action_id") != row["action_id"]
|
|
703
|
+
or not hmac.compare_digest(
|
|
704
|
+
str(evidence.get("action_digest") or ""),
|
|
705
|
+
str(row["payload_sha256"] or ""),
|
|
706
|
+
)
|
|
707
|
+
or not isinstance(execution_digest, str)
|
|
708
|
+
or len(execution_digest) != 64
|
|
709
|
+
or any(
|
|
710
|
+
character not in "0123456789abcdef"
|
|
711
|
+
for character in execution_digest
|
|
712
|
+
)
|
|
713
|
+
or receipt["receipt_key"]
|
|
714
|
+
!= "dispatch-observation-recovery:" + execution_digest
|
|
715
|
+
or receipt["status"] != "observation_only"
|
|
716
|
+
or receipt["external_id"] != execution_digest
|
|
717
|
+
or not hmac.compare_digest(
|
|
718
|
+
str(receipt["evidence_sha256"] or ""), sha256_json_utf8(evidence)
|
|
719
|
+
)
|
|
720
|
+
or not math.isfinite(issued_at)
|
|
721
|
+
or not math.isfinite(deadline)
|
|
722
|
+
or issued_at <= 0
|
|
723
|
+
or deadline <= issued_at
|
|
724
|
+
or deadline - issued_at > 60 * 60
|
|
725
|
+
or float(receipt["created_at"]) != issued_at
|
|
726
|
+
or isinstance(evidence.get("max_observations"), bool)
|
|
727
|
+
or not isinstance(evidence.get("max_observations"), int)
|
|
728
|
+
or not 1 <= evidence["max_observations"] <= 100
|
|
729
|
+
):
|
|
730
|
+
return None
|
|
731
|
+
return evidence
|
|
732
|
+
|
|
733
|
+
def _dispatch_observation_attempts(self, cursor, row, recovery):
|
|
734
|
+
rows = cursor.execute(
|
|
735
|
+
"SELECT * FROM receipts WHERE action_id=? AND kind=?",
|
|
736
|
+
(row["action_id"], OBSERVATION_RECEIPT_KIND),
|
|
737
|
+
).fetchall()
|
|
738
|
+
by_attempt = {}
|
|
739
|
+
fields = {
|
|
740
|
+
"schema",
|
|
741
|
+
"version",
|
|
742
|
+
"action_id",
|
|
743
|
+
"action_digest",
|
|
744
|
+
"execution_digest",
|
|
745
|
+
"attempt",
|
|
746
|
+
"started_at",
|
|
747
|
+
}
|
|
748
|
+
for receipt in rows:
|
|
749
|
+
try:
|
|
750
|
+
evidence = json.loads(receipt["evidence_json"])
|
|
751
|
+
started_at = float(evidence.get("started_at"))
|
|
752
|
+
except Exception as error:
|
|
753
|
+
raise ActionTransitionError(
|
|
754
|
+
"dispatch observation journal is invalid"
|
|
755
|
+
) from error
|
|
756
|
+
attempt = evidence.get("attempt")
|
|
757
|
+
execution_digest = recovery["execution_digest"]
|
|
758
|
+
if (
|
|
759
|
+
not isinstance(evidence, dict)
|
|
760
|
+
or set(evidence) != fields
|
|
761
|
+
or evidence.get("schema") != OBSERVATION_SCHEMA
|
|
762
|
+
or isinstance(evidence.get("version"), bool)
|
|
763
|
+
or evidence.get("version") != 1
|
|
764
|
+
or evidence.get("action_id") != row["action_id"]
|
|
765
|
+
or not hmac.compare_digest(
|
|
766
|
+
str(evidence.get("action_digest") or ""),
|
|
767
|
+
str(row["payload_sha256"] or ""),
|
|
768
|
+
)
|
|
769
|
+
or evidence.get("execution_digest") != execution_digest
|
|
770
|
+
or isinstance(attempt, bool)
|
|
771
|
+
or not isinstance(attempt, int)
|
|
772
|
+
or not 1 <= attempt <= recovery["max_observations"]
|
|
773
|
+
or receipt["receipt_key"]
|
|
774
|
+
!= "dispatch-observation:%s:%03d" % (execution_digest, attempt)
|
|
775
|
+
or receipt["status"] != "started"
|
|
776
|
+
or receipt["external_id"] != execution_digest
|
|
777
|
+
or not hmac.compare_digest(
|
|
778
|
+
str(receipt["evidence_sha256"] or ""), sha256_json_utf8(evidence)
|
|
779
|
+
)
|
|
780
|
+
or not math.isfinite(started_at)
|
|
781
|
+
or started_at < float(recovery["issued_at"])
|
|
782
|
+
or started_at > float(recovery["observation_deadline"])
|
|
783
|
+
or float(receipt["created_at"]) != started_at
|
|
784
|
+
or attempt in by_attempt
|
|
785
|
+
):
|
|
786
|
+
raise ActionTransitionError(
|
|
787
|
+
"dispatch observation journal is invalid"
|
|
788
|
+
)
|
|
789
|
+
by_attempt[attempt] = receipt
|
|
790
|
+
if set(by_attempt) != set(range(1, len(by_attempt) + 1)):
|
|
791
|
+
raise ActionTransitionError("dispatch observation journal is invalid")
|
|
792
|
+
return [by_attempt[index] for index in range(1, len(by_attempt) + 1)]
|
|
793
|
+
|
|
794
|
+
def _terminalize_dispatch_observation(
|
|
795
|
+
self, cursor, row, recovery, now, *, actor, reason, observations
|
|
796
|
+
):
|
|
797
|
+
message = (
|
|
798
|
+
"governed action outcome remains explicitly ambiguous after "
|
|
799
|
+
"bounded GET-only reconciliation"
|
|
800
|
+
)
|
|
801
|
+
result = {
|
|
802
|
+
"status": "ambiguous",
|
|
803
|
+
"effect_state": "unknown",
|
|
804
|
+
"execution_digest": recovery["execution_digest"],
|
|
805
|
+
"observation_attempts": int(observations),
|
|
806
|
+
"reason": reason,
|
|
807
|
+
}
|
|
808
|
+
self._transition(
|
|
809
|
+
cursor,
|
|
810
|
+
row,
|
|
811
|
+
STATE_FAILED,
|
|
812
|
+
actor,
|
|
813
|
+
"dispatch_observation_ambiguous",
|
|
814
|
+
{
|
|
815
|
+
"reason": reason,
|
|
816
|
+
"observations": int(observations),
|
|
817
|
+
"execution_digest": recovery["execution_digest"],
|
|
818
|
+
},
|
|
819
|
+
now,
|
|
820
|
+
result=result,
|
|
821
|
+
error=message,
|
|
822
|
+
clear_lease=True,
|
|
823
|
+
)
|
|
824
|
+
self._dead_letter_action(cursor, row["action_id"], message, now)
|
|
825
|
+
return self._action_dict(self._get_action_row(cursor, row["action_id"]))
|
|
826
|
+
|
|
827
|
+
def _recover_expired_action_leases(
|
|
828
|
+
self,
|
|
829
|
+
cursor,
|
|
830
|
+
now,
|
|
831
|
+
*,
|
|
832
|
+
source=None,
|
|
833
|
+
source_prefix=None,
|
|
834
|
+
action_type=None,
|
|
835
|
+
action_ids=None,
|
|
836
|
+
):
|
|
837
|
+
scope, scope_values = self._lease_scope(
|
|
838
|
+
source=source,
|
|
839
|
+
source_prefix=source_prefix,
|
|
840
|
+
action_type=action_type,
|
|
841
|
+
action_ids=action_ids,
|
|
842
|
+
)
|
|
843
|
+
dispatched = cursor.execute(
|
|
844
|
+
"""SELECT * FROM actions
|
|
845
|
+
WHERE state=? AND lease_expires_at IS NOT NULL AND lease_expires_at<=?%s"""
|
|
846
|
+
% scope,
|
|
847
|
+
(STATE_DISPATCHED, now, *scope_values),
|
|
848
|
+
).fetchall()
|
|
849
|
+
for row in dispatched:
|
|
850
|
+
recovery = self._dispatch_observation_recovery(cursor, row)
|
|
851
|
+
if recovery is not None:
|
|
852
|
+
try:
|
|
853
|
+
observations = len(
|
|
854
|
+
self._dispatch_observation_attempts(cursor, row, recovery)
|
|
855
|
+
)
|
|
856
|
+
except ActionTransitionError:
|
|
857
|
+
self._terminalize_dispatch_observation(
|
|
858
|
+
cursor,
|
|
859
|
+
row,
|
|
860
|
+
recovery,
|
|
861
|
+
now,
|
|
862
|
+
actor="lease-reaper",
|
|
863
|
+
reason="invalid_observation_journal",
|
|
864
|
+
observations=0,
|
|
865
|
+
)
|
|
866
|
+
continue
|
|
867
|
+
if (
|
|
868
|
+
now >= float(recovery["observation_deadline"])
|
|
869
|
+
or observations >= int(recovery["max_observations"])
|
|
870
|
+
):
|
|
871
|
+
self._terminalize_dispatch_observation(
|
|
872
|
+
cursor,
|
|
873
|
+
row,
|
|
874
|
+
recovery,
|
|
875
|
+
now,
|
|
876
|
+
actor="lease-reaper",
|
|
877
|
+
reason="observation_bound_exhausted",
|
|
878
|
+
observations=observations,
|
|
879
|
+
)
|
|
880
|
+
continue
|
|
881
|
+
cursor.execute(
|
|
882
|
+
"""UPDATE actions
|
|
883
|
+
SET lease_owner=NULL,lease_expires_at=NULL,updated_at=?
|
|
884
|
+
WHERE action_id=?""",
|
|
885
|
+
(now, row["action_id"]),
|
|
886
|
+
)
|
|
887
|
+
self._add_event(
|
|
888
|
+
cursor,
|
|
889
|
+
row["action_id"],
|
|
890
|
+
"dispatch_observation_lease_expired",
|
|
891
|
+
row["state"],
|
|
892
|
+
row["state"],
|
|
893
|
+
"lease-reaper",
|
|
894
|
+
{
|
|
895
|
+
"previous_owner": row["lease_owner"],
|
|
896
|
+
"mode": "get_only",
|
|
897
|
+
"observations": observations,
|
|
898
|
+
},
|
|
899
|
+
now,
|
|
900
|
+
)
|
|
901
|
+
continue
|
|
902
|
+
reason = "dispatch lease expired; external outcome is ambiguous"
|
|
903
|
+
self._transition(
|
|
904
|
+
cursor,
|
|
905
|
+
row,
|
|
906
|
+
STATE_FAILED,
|
|
907
|
+
"lease-reaper",
|
|
908
|
+
"ambiguous_dispatch_expired",
|
|
909
|
+
{"reason": reason, "previous_owner": row["lease_owner"]},
|
|
910
|
+
now,
|
|
911
|
+
error=reason,
|
|
912
|
+
clear_lease=True,
|
|
913
|
+
)
|
|
914
|
+
self._dead_letter_action(cursor, row["action_id"], reason, now)
|
|
915
|
+
|
|
916
|
+
recoverable = cursor.execute(
|
|
917
|
+
"""SELECT * FROM actions
|
|
918
|
+
WHERE state IN (?,?,?) AND lease_expires_at IS NOT NULL
|
|
919
|
+
AND lease_expires_at<=?%s""" % scope,
|
|
920
|
+
(
|
|
921
|
+
STATE_GATED,
|
|
922
|
+
STATE_ACCEPTED,
|
|
923
|
+
STATE_VERIFIED,
|
|
924
|
+
now,
|
|
925
|
+
*scope_values,
|
|
926
|
+
),
|
|
927
|
+
).fetchall()
|
|
928
|
+
for row in recoverable:
|
|
929
|
+
cursor.execute(
|
|
930
|
+
"""UPDATE actions SET lease_owner=NULL,lease_expires_at=NULL,updated_at=?
|
|
931
|
+
WHERE action_id=?""",
|
|
932
|
+
(now, row["action_id"]),
|
|
933
|
+
)
|
|
934
|
+
self._add_event(
|
|
935
|
+
cursor,
|
|
936
|
+
row["action_id"],
|
|
937
|
+
"lease_expired",
|
|
938
|
+
row["state"],
|
|
939
|
+
row["state"],
|
|
940
|
+
"lease-reaper",
|
|
941
|
+
{"previous_owner": row["lease_owner"]},
|
|
942
|
+
now,
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
def recover_expired_leases(
|
|
946
|
+
self, *, source=None, source_prefix=None, action_type=None, action_ids=None
|
|
947
|
+
):
|
|
948
|
+
now = float(self._clock())
|
|
949
|
+
with self._transaction() as cursor:
|
|
950
|
+
self._recover_expired_action_leases(
|
|
951
|
+
cursor,
|
|
952
|
+
now,
|
|
953
|
+
source=source,
|
|
954
|
+
source_prefix=source_prefix,
|
|
955
|
+
action_type=action_type,
|
|
956
|
+
action_ids=action_ids,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
def lease_next(
|
|
960
|
+
self,
|
|
961
|
+
owner,
|
|
962
|
+
*,
|
|
963
|
+
lease_seconds=60.0,
|
|
964
|
+
states=(STATE_GATED, STATE_ACCEPTED, STATE_VERIFIED),
|
|
965
|
+
source=None,
|
|
966
|
+
source_prefix=None,
|
|
967
|
+
action_type=None,
|
|
968
|
+
action_ids=None,
|
|
969
|
+
):
|
|
970
|
+
owner = self._validate_text("owner", owner)
|
|
971
|
+
lease_seconds = float(lease_seconds)
|
|
972
|
+
if not math.isfinite(lease_seconds) or lease_seconds <= 0:
|
|
973
|
+
raise ActionStoreError("lease_seconds must be positive")
|
|
974
|
+
states = tuple(str(state) for state in states)
|
|
975
|
+
if not states or any(state not in _LEASEABLE_STATES for state in states):
|
|
976
|
+
raise ActionStoreError(
|
|
977
|
+
"only gated, accepted, or verified actions are leaseable"
|
|
978
|
+
)
|
|
979
|
+
if action_ids is not None:
|
|
980
|
+
action_ids = tuple(str(action_id) for action_id in action_ids)
|
|
981
|
+
if not action_ids:
|
|
982
|
+
return None
|
|
983
|
+
now = float(self._clock())
|
|
984
|
+
state_placeholders = ",".join("?" for _ in states)
|
|
985
|
+
action_filter, scoped_values = self._lease_scope(
|
|
986
|
+
source=source,
|
|
987
|
+
source_prefix=source_prefix,
|
|
988
|
+
action_type=action_type,
|
|
989
|
+
action_ids=action_ids,
|
|
990
|
+
)
|
|
991
|
+
values = list(states) + [now, now]
|
|
992
|
+
values.extend(scoped_values)
|
|
993
|
+
with self._transaction() as cursor:
|
|
994
|
+
self._recover_expired_action_leases(
|
|
995
|
+
cursor,
|
|
996
|
+
now,
|
|
997
|
+
source=source,
|
|
998
|
+
source_prefix=source_prefix,
|
|
999
|
+
action_type=action_type,
|
|
1000
|
+
action_ids=action_ids,
|
|
1001
|
+
)
|
|
1002
|
+
row = cursor.execute(
|
|
1003
|
+
"""SELECT * FROM actions
|
|
1004
|
+
WHERE state IN (%s) AND next_attempt_at<=?
|
|
1005
|
+
AND (lease_owner IS NULL OR lease_expires_at<=?)%s
|
|
1006
|
+
ORDER BY next_attempt_at, created_at, action_id LIMIT 1"""
|
|
1007
|
+
% (state_placeholders, action_filter),
|
|
1008
|
+
tuple(values),
|
|
1009
|
+
).fetchone()
|
|
1010
|
+
if row is None:
|
|
1011
|
+
return None
|
|
1012
|
+
expires = now + lease_seconds
|
|
1013
|
+
cursor.execute(
|
|
1014
|
+
"""UPDATE actions SET lease_owner=?,lease_expires_at=?,updated_at=?
|
|
1015
|
+
WHERE action_id=?""",
|
|
1016
|
+
(owner, expires, now, row["action_id"]),
|
|
1017
|
+
)
|
|
1018
|
+
self._add_event(
|
|
1019
|
+
cursor,
|
|
1020
|
+
row["action_id"],
|
|
1021
|
+
"leased",
|
|
1022
|
+
row["state"],
|
|
1023
|
+
row["state"],
|
|
1024
|
+
owner,
|
|
1025
|
+
{"lease_expires_at": expires},
|
|
1026
|
+
now,
|
|
1027
|
+
)
|
|
1028
|
+
return self._action_dict(self._get_action_row(cursor, row["action_id"]))
|
|
1029
|
+
|
|
1030
|
+
def lease_dispatched_observation(
|
|
1031
|
+
self,
|
|
1032
|
+
owner,
|
|
1033
|
+
*,
|
|
1034
|
+
lease_seconds=60.0,
|
|
1035
|
+
source=None,
|
|
1036
|
+
source_prefix=None,
|
|
1037
|
+
action_type=None,
|
|
1038
|
+
action_ids=None,
|
|
1039
|
+
):
|
|
1040
|
+
"""Lease only dispatched rows carrying a durable GET-only contract.
|
|
1041
|
+
|
|
1042
|
+
This method can never make an ordinary dispatched row retryable and
|
|
1043
|
+
can never transition a row back to ``gated``. Exhausted contracts
|
|
1044
|
+
are terminalized as explicitly ambiguous without another effect.
|
|
1045
|
+
"""
|
|
1046
|
+
|
|
1047
|
+
owner = self._validate_text("owner", owner)
|
|
1048
|
+
lease_seconds = float(lease_seconds)
|
|
1049
|
+
if not math.isfinite(lease_seconds) or lease_seconds <= 0:
|
|
1050
|
+
raise ActionStoreError("lease_seconds must be positive")
|
|
1051
|
+
if action_ids is not None:
|
|
1052
|
+
action_ids = tuple(str(action_id) for action_id in action_ids)
|
|
1053
|
+
if not action_ids:
|
|
1054
|
+
return None
|
|
1055
|
+
now = float(self._clock())
|
|
1056
|
+
scope, scope_values = self._lease_scope(
|
|
1057
|
+
source=source,
|
|
1058
|
+
source_prefix=source_prefix,
|
|
1059
|
+
action_type=action_type,
|
|
1060
|
+
action_ids=action_ids,
|
|
1061
|
+
)
|
|
1062
|
+
with self._transaction() as cursor:
|
|
1063
|
+
self._recover_expired_action_leases(
|
|
1064
|
+
cursor,
|
|
1065
|
+
now,
|
|
1066
|
+
source=source,
|
|
1067
|
+
source_prefix=source_prefix,
|
|
1068
|
+
action_type=action_type,
|
|
1069
|
+
action_ids=action_ids,
|
|
1070
|
+
)
|
|
1071
|
+
rows = cursor.execute(
|
|
1072
|
+
"""SELECT * FROM actions
|
|
1073
|
+
WHERE state=? AND next_attempt_at<=?
|
|
1074
|
+
AND (lease_owner IS NULL OR lease_expires_at<=?)%s
|
|
1075
|
+
ORDER BY next_attempt_at,created_at,action_id""" % scope,
|
|
1076
|
+
(STATE_DISPATCHED, now, now, *scope_values),
|
|
1077
|
+
).fetchall()
|
|
1078
|
+
for row in rows:
|
|
1079
|
+
recovery = self._dispatch_observation_recovery(cursor, row)
|
|
1080
|
+
if recovery is None:
|
|
1081
|
+
continue
|
|
1082
|
+
try:
|
|
1083
|
+
observations = len(
|
|
1084
|
+
self._dispatch_observation_attempts(cursor, row, recovery)
|
|
1085
|
+
)
|
|
1086
|
+
except ActionTransitionError:
|
|
1087
|
+
self._terminalize_dispatch_observation(
|
|
1088
|
+
cursor,
|
|
1089
|
+
row,
|
|
1090
|
+
recovery,
|
|
1091
|
+
now,
|
|
1092
|
+
actor=owner,
|
|
1093
|
+
reason="invalid_observation_journal",
|
|
1094
|
+
observations=0,
|
|
1095
|
+
)
|
|
1096
|
+
continue
|
|
1097
|
+
if (
|
|
1098
|
+
now >= float(recovery["observation_deadline"])
|
|
1099
|
+
or observations >= int(recovery["max_observations"])
|
|
1100
|
+
):
|
|
1101
|
+
self._terminalize_dispatch_observation(
|
|
1102
|
+
cursor,
|
|
1103
|
+
row,
|
|
1104
|
+
recovery,
|
|
1105
|
+
now,
|
|
1106
|
+
actor=owner,
|
|
1107
|
+
reason="observation_bound_exhausted",
|
|
1108
|
+
observations=observations,
|
|
1109
|
+
)
|
|
1110
|
+
continue
|
|
1111
|
+
expires = now + lease_seconds
|
|
1112
|
+
cursor.execute(
|
|
1113
|
+
"""UPDATE actions SET lease_owner=?,lease_expires_at=?,updated_at=?
|
|
1114
|
+
WHERE action_id=?""",
|
|
1115
|
+
(owner, expires, now, row["action_id"]),
|
|
1116
|
+
)
|
|
1117
|
+
self._add_event(
|
|
1118
|
+
cursor,
|
|
1119
|
+
row["action_id"],
|
|
1120
|
+
"dispatch_observation_leased",
|
|
1121
|
+
row["state"],
|
|
1122
|
+
row["state"],
|
|
1123
|
+
owner,
|
|
1124
|
+
{"lease_expires_at": expires, "mode": "get_only"},
|
|
1125
|
+
now,
|
|
1126
|
+
)
|
|
1127
|
+
return self._action_dict(
|
|
1128
|
+
self._get_action_row(cursor, row["action_id"])
|
|
1129
|
+
)
|
|
1130
|
+
return None
|
|
1131
|
+
|
|
1132
|
+
# -------------------------------------------- owner-authorized dispatch
|
|
1133
|
+
|
|
1134
|
+
def begin_owner_authorized_dispatch(
|
|
1135
|
+
self,
|
|
1136
|
+
action_id,
|
|
1137
|
+
owner,
|
|
1138
|
+
*,
|
|
1139
|
+
gate_receipt_key,
|
|
1140
|
+
expected_source,
|
|
1141
|
+
expected_source_ref,
|
|
1142
|
+
expected_action_type,
|
|
1143
|
+
expected_payload,
|
|
1144
|
+
expected_approval_id,
|
|
1145
|
+
expected_decision_id,
|
|
1146
|
+
expected_execution_digest,
|
|
1147
|
+
observation_window_seconds,
|
|
1148
|
+
max_observations,
|
|
1149
|
+
gate_validator,
|
|
1150
|
+
):
|
|
1151
|
+
"""Atomically consume one exact, still-live owner approval gate.
|
|
1152
|
+
|
|
1153
|
+
Implements invariants I4 and I5: the caller-supplied
|
|
1154
|
+
``gate_validator`` runs INSIDE this transaction against the durable
|
|
1155
|
+
receipts re-read here, the store re-checks the structural gate
|
|
1156
|
+
bindings itself, and the GET-only recovery contract is inserted in
|
|
1157
|
+
the same transaction as the ``gated -> dispatched`` transition.
|
|
1158
|
+
"""
|
|
1159
|
+
|
|
1160
|
+
gate_receipt_key = self._validate_text("gate_receipt_key", gate_receipt_key)
|
|
1161
|
+
expected_source = self._validate_text("expected_source", expected_source)
|
|
1162
|
+
expected_source_ref = self._validate_text(
|
|
1163
|
+
"expected_source_ref", expected_source_ref
|
|
1164
|
+
)
|
|
1165
|
+
expected_action_type = self._validate_text(
|
|
1166
|
+
"expected_action_type", expected_action_type
|
|
1167
|
+
)
|
|
1168
|
+
expected_approval_id = self._validate_text(
|
|
1169
|
+
"expected_approval_id", expected_approval_id
|
|
1170
|
+
)
|
|
1171
|
+
expected_decision_id = self._validate_text(
|
|
1172
|
+
"expected_decision_id", expected_decision_id
|
|
1173
|
+
)
|
|
1174
|
+
expected_execution_digest = self._validate_text(
|
|
1175
|
+
"expected_execution_digest", expected_execution_digest
|
|
1176
|
+
)
|
|
1177
|
+
if not callable(gate_validator):
|
|
1178
|
+
raise ActionStoreError("gate validator must be callable")
|
|
1179
|
+
try:
|
|
1180
|
+
observation_window = float(observation_window_seconds)
|
|
1181
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
1182
|
+
raise ActionStoreError("observation window is invalid") from error
|
|
1183
|
+
if (
|
|
1184
|
+
len(expected_execution_digest) != 64
|
|
1185
|
+
or any(
|
|
1186
|
+
character not in "0123456789abcdef"
|
|
1187
|
+
for character in expected_execution_digest
|
|
1188
|
+
)
|
|
1189
|
+
or not math.isfinite(observation_window)
|
|
1190
|
+
or not 1 <= observation_window <= 60 * 60
|
|
1191
|
+
or isinstance(max_observations, bool)
|
|
1192
|
+
or not isinstance(max_observations, int)
|
|
1193
|
+
or not 1 <= max_observations <= 100
|
|
1194
|
+
):
|
|
1195
|
+
raise ActionStoreError("dispatch observation contract is invalid")
|
|
1196
|
+
expected_payload_json = canonical_json_utf8(expected_payload)
|
|
1197
|
+
expected_payload_digest = hashlib.sha256(
|
|
1198
|
+
expected_payload_json.encode("utf-8")
|
|
1199
|
+
).hexdigest()
|
|
1200
|
+
now = float(self._clock())
|
|
1201
|
+
with self._transaction() as cursor:
|
|
1202
|
+
row = self._get_action_row(cursor, action_id)
|
|
1203
|
+
self._require_lease(row, owner, now)
|
|
1204
|
+
if row["state"] != STATE_GATED:
|
|
1205
|
+
raise ActionTransitionError("only gated actions can be dispatched")
|
|
1206
|
+
if float(row["next_attempt_at"]) > now:
|
|
1207
|
+
raise ActionTransitionError("action retry backoff has not elapsed")
|
|
1208
|
+
if (
|
|
1209
|
+
row["source"] != expected_source
|
|
1210
|
+
or row["source_ref"] != expected_source_ref
|
|
1211
|
+
or row["action_type"] != expected_action_type
|
|
1212
|
+
or row["payload_json"] != expected_payload_json
|
|
1213
|
+
or not hmac.compare_digest(
|
|
1214
|
+
str(row["payload_sha256"] or ""), expected_payload_digest
|
|
1215
|
+
)
|
|
1216
|
+
):
|
|
1217
|
+
raise ActionTransitionError(
|
|
1218
|
+
"owner authorization does not bind this action payload"
|
|
1219
|
+
)
|
|
1220
|
+
gates = cursor.execute(
|
|
1221
|
+
"SELECT * FROM receipts WHERE action_id=? AND kind=?",
|
|
1222
|
+
(action_id, GATE_RECEIPT_KIND),
|
|
1223
|
+
).fetchall()
|
|
1224
|
+
if len(gates) != 1 or gates[0]["receipt_key"] != gate_receipt_key:
|
|
1225
|
+
raise ActionTransitionError(
|
|
1226
|
+
"exactly one owner authorization gate is required"
|
|
1227
|
+
)
|
|
1228
|
+
receipt = gates[0]
|
|
1229
|
+
# Structural in-transaction re-check: shape-agnostic bindings the
|
|
1230
|
+
# store enforces itself, in addition to the semantic validator.
|
|
1231
|
+
try:
|
|
1232
|
+
evidence = json.loads(receipt["evidence_json"])
|
|
1233
|
+
decided_at = float(evidence.get("decided_at_epoch"))
|
|
1234
|
+
expires_at = float(evidence.get("expires_at_epoch"))
|
|
1235
|
+
except Exception as error:
|
|
1236
|
+
raise ActionTransitionError(
|
|
1237
|
+
"owner authorization receipt is invalid"
|
|
1238
|
+
) from error
|
|
1239
|
+
if (
|
|
1240
|
+
not isinstance(evidence, dict)
|
|
1241
|
+
or receipt["status"] != "passed"
|
|
1242
|
+
or receipt["external_id"] != expected_approval_id
|
|
1243
|
+
or not hmac.compare_digest(
|
|
1244
|
+
str(receipt["evidence_sha256"] or ""),
|
|
1245
|
+
sha256_json_utf8(evidence),
|
|
1246
|
+
)
|
|
1247
|
+
or evidence.get("decision") != "approved"
|
|
1248
|
+
or evidence.get("authority") != "owner"
|
|
1249
|
+
or evidence.get("decision_id") != expected_decision_id
|
|
1250
|
+
or evidence.get("approval_id") != expected_approval_id
|
|
1251
|
+
or evidence.get("action_id") != action_id
|
|
1252
|
+
or not hmac.compare_digest(
|
|
1253
|
+
str(evidence.get("action_digest") or ""),
|
|
1254
|
+
expected_payload_digest,
|
|
1255
|
+
)
|
|
1256
|
+
or isinstance(evidence.get("revision"), bool)
|
|
1257
|
+
or evidence.get("revision") != 1
|
|
1258
|
+
or not math.isfinite(decided_at)
|
|
1259
|
+
or not math.isfinite(expires_at)
|
|
1260
|
+
or decided_at <= 0
|
|
1261
|
+
or decided_at > now + _GATE_CLOCK_SKEW_SECONDS
|
|
1262
|
+
or expires_at <= now
|
|
1263
|
+
or expires_at <= decided_at
|
|
1264
|
+
):
|
|
1265
|
+
raise ActionTransitionError(
|
|
1266
|
+
"owner authorization receipt does not bind this action"
|
|
1267
|
+
)
|
|
1268
|
+
# Backstop independent of validator and worker configuration: a
|
|
1269
|
+
# grant-bound receipt never dispatches a tool the catalog does
|
|
1270
|
+
# not explicitly allow on standing-grant authority.
|
|
1271
|
+
if evidence.get("binding_method") == GRANT_BINDING_METHOD:
|
|
1272
|
+
intent_value = (
|
|
1273
|
+
expected_payload.get("intent")
|
|
1274
|
+
if isinstance(expected_payload, dict)
|
|
1275
|
+
else None
|
|
1276
|
+
)
|
|
1277
|
+
grant_tool_name = (
|
|
1278
|
+
intent_value.get("tool_name")
|
|
1279
|
+
if isinstance(intent_value, dict)
|
|
1280
|
+
else None
|
|
1281
|
+
)
|
|
1282
|
+
if grant_tool_name not in GRANT_AUTHORIZABLE_TOOL_NAMES:
|
|
1283
|
+
raise ActionTransitionError(
|
|
1284
|
+
"bounded grant authority never covers this tool"
|
|
1285
|
+
)
|
|
1286
|
+
# THE in-transaction semantic re-validation (invariant I4): the
|
|
1287
|
+
# validator sees the durable projections re-read in THIS
|
|
1288
|
+
# transaction and the store's own clock — never the caller's
|
|
1289
|
+
# earlier view.
|
|
1290
|
+
action_projection = self._action_dict(row)
|
|
1291
|
+
receipt_projections = [
|
|
1292
|
+
self._receipt_dict(item)
|
|
1293
|
+
for item in cursor.execute(
|
|
1294
|
+
"SELECT * FROM receipts WHERE action_id=? "
|
|
1295
|
+
"ORDER BY created_at,receipt_id",
|
|
1296
|
+
(action_id,),
|
|
1297
|
+
).fetchall()
|
|
1298
|
+
]
|
|
1299
|
+
try:
|
|
1300
|
+
authorization = gate_validator(
|
|
1301
|
+
action_projection, receipt_projections, now
|
|
1302
|
+
)
|
|
1303
|
+
except ActionStoreError:
|
|
1304
|
+
raise
|
|
1305
|
+
except Exception as error:
|
|
1306
|
+
raise ActionTransitionError(
|
|
1307
|
+
"owner gate validator rejected this action"
|
|
1308
|
+
) from error
|
|
1309
|
+
if (
|
|
1310
|
+
not isinstance(authorization, GateAuthorization)
|
|
1311
|
+
or authorization.expired
|
|
1312
|
+
or authorization.receipt_key != gate_receipt_key
|
|
1313
|
+
or authorization.approval_id != expected_approval_id
|
|
1314
|
+
or authorization.decision_id != expected_decision_id
|
|
1315
|
+
):
|
|
1316
|
+
raise ActionTransitionError(
|
|
1317
|
+
"owner gate validator did not authorize this dispatch"
|
|
1318
|
+
)
|
|
1319
|
+
existing_recovery = cursor.execute(
|
|
1320
|
+
"SELECT 1 FROM receipts WHERE action_id=? AND kind=?",
|
|
1321
|
+
(action_id, RECOVERY_RECEIPT_KIND),
|
|
1322
|
+
).fetchone()
|
|
1323
|
+
if existing_recovery is not None:
|
|
1324
|
+
raise ActionTransitionError(
|
|
1325
|
+
"dispatch observation contract already exists"
|
|
1326
|
+
)
|
|
1327
|
+
recovery = {
|
|
1328
|
+
"schema": RECOVERY_SCHEMA,
|
|
1329
|
+
"version": 1,
|
|
1330
|
+
"action_id": action_id,
|
|
1331
|
+
"action_digest": expected_payload_digest,
|
|
1332
|
+
"execution_digest": expected_execution_digest,
|
|
1333
|
+
"issued_at": now,
|
|
1334
|
+
"observation_deadline": now + observation_window,
|
|
1335
|
+
"max_observations": max_observations,
|
|
1336
|
+
}
|
|
1337
|
+
self._insert_receipt(
|
|
1338
|
+
cursor,
|
|
1339
|
+
action_id,
|
|
1340
|
+
"dispatch-observation-recovery:" + expected_execution_digest,
|
|
1341
|
+
RECOVERY_RECEIPT_KIND,
|
|
1342
|
+
"observation_only",
|
|
1343
|
+
recovery,
|
|
1344
|
+
expected_execution_digest,
|
|
1345
|
+
now,
|
|
1346
|
+
now,
|
|
1347
|
+
)
|
|
1348
|
+
attempt = int(row["attempt_count"]) + 1
|
|
1349
|
+
if attempt > int(row["max_attempts"]):
|
|
1350
|
+
raise ActionTransitionError("action attempt budget is exhausted")
|
|
1351
|
+
self._check_transition(row["state"], STATE_DISPATCHED)
|
|
1352
|
+
cursor.execute(
|
|
1353
|
+
"""UPDATE actions SET state=?,attempt_count=?,last_error=NULL,updated_at=?
|
|
1354
|
+
WHERE action_id=?""",
|
|
1355
|
+
(STATE_DISPATCHED, attempt, now, row["action_id"]),
|
|
1356
|
+
)
|
|
1357
|
+
self._add_event(
|
|
1358
|
+
cursor,
|
|
1359
|
+
row["action_id"],
|
|
1360
|
+
"dispatched",
|
|
1361
|
+
row["state"],
|
|
1362
|
+
STATE_DISPATCHED,
|
|
1363
|
+
owner,
|
|
1364
|
+
{"attempt": attempt},
|
|
1365
|
+
now,
|
|
1366
|
+
)
|
|
1367
|
+
return self._action_dict(self._get_action_row(cursor, action_id))
|
|
1368
|
+
|
|
1369
|
+
# ------------------------------------------------ dispatched observation
|
|
1370
|
+
|
|
1371
|
+
def begin_dispatched_observation(self, action_id, owner, execution_digest):
|
|
1372
|
+
"""Durably consume one bounded GET attempt before external observation."""
|
|
1373
|
+
|
|
1374
|
+
execution_digest = self._validate_text("execution_digest", execution_digest)
|
|
1375
|
+
now = float(self._clock())
|
|
1376
|
+
with self._transaction() as cursor:
|
|
1377
|
+
row = self._get_action_row(cursor, action_id)
|
|
1378
|
+
self._require_lease(row, owner, now)
|
|
1379
|
+
if row["state"] != STATE_DISPATCHED:
|
|
1380
|
+
raise ActionTransitionError(
|
|
1381
|
+
"only dispatched actions can begin effect observation"
|
|
1382
|
+
)
|
|
1383
|
+
recovery = self._dispatch_observation_recovery(cursor, row)
|
|
1384
|
+
if (
|
|
1385
|
+
recovery is None
|
|
1386
|
+
or not hmac.compare_digest(
|
|
1387
|
+
str(recovery["execution_digest"]), execution_digest
|
|
1388
|
+
)
|
|
1389
|
+
):
|
|
1390
|
+
raise ActionTransitionError(
|
|
1391
|
+
"dispatch observation contract does not bind this action"
|
|
1392
|
+
)
|
|
1393
|
+
try:
|
|
1394
|
+
observations = self._dispatch_observation_attempts(
|
|
1395
|
+
cursor, row, recovery
|
|
1396
|
+
)
|
|
1397
|
+
except ActionTransitionError:
|
|
1398
|
+
action = self._terminalize_dispatch_observation(
|
|
1399
|
+
cursor,
|
|
1400
|
+
row,
|
|
1401
|
+
recovery,
|
|
1402
|
+
now,
|
|
1403
|
+
actor=owner,
|
|
1404
|
+
reason="invalid_observation_journal",
|
|
1405
|
+
observations=0,
|
|
1406
|
+
)
|
|
1407
|
+
return action, None
|
|
1408
|
+
if (
|
|
1409
|
+
now >= float(recovery["observation_deadline"])
|
|
1410
|
+
or len(observations) >= int(recovery["max_observations"])
|
|
1411
|
+
):
|
|
1412
|
+
action = self._terminalize_dispatch_observation(
|
|
1413
|
+
cursor,
|
|
1414
|
+
row,
|
|
1415
|
+
recovery,
|
|
1416
|
+
now,
|
|
1417
|
+
actor=owner,
|
|
1418
|
+
reason="observation_bound_exhausted",
|
|
1419
|
+
observations=len(observations),
|
|
1420
|
+
)
|
|
1421
|
+
return action, None
|
|
1422
|
+
attempt = len(observations) + 1
|
|
1423
|
+
evidence = {
|
|
1424
|
+
"schema": OBSERVATION_SCHEMA,
|
|
1425
|
+
"version": 1,
|
|
1426
|
+
"action_id": row["action_id"],
|
|
1427
|
+
"action_digest": row["payload_sha256"],
|
|
1428
|
+
"execution_digest": execution_digest,
|
|
1429
|
+
"attempt": attempt,
|
|
1430
|
+
"started_at": now,
|
|
1431
|
+
}
|
|
1432
|
+
receipt_key = "dispatch-observation:%s:%03d" % (
|
|
1433
|
+
execution_digest,
|
|
1434
|
+
attempt,
|
|
1435
|
+
)
|
|
1436
|
+
receipt = self._insert_receipt(
|
|
1437
|
+
cursor,
|
|
1438
|
+
row["action_id"],
|
|
1439
|
+
receipt_key,
|
|
1440
|
+
OBSERVATION_RECEIPT_KIND,
|
|
1441
|
+
"started",
|
|
1442
|
+
evidence,
|
|
1443
|
+
execution_digest,
|
|
1444
|
+
now,
|
|
1445
|
+
now,
|
|
1446
|
+
)
|
|
1447
|
+
self._add_event(
|
|
1448
|
+
cursor,
|
|
1449
|
+
row["action_id"],
|
|
1450
|
+
"dispatch_observation_started",
|
|
1451
|
+
row["state"],
|
|
1452
|
+
row["state"],
|
|
1453
|
+
owner,
|
|
1454
|
+
{"attempt": attempt, "mode": "get_only"},
|
|
1455
|
+
now,
|
|
1456
|
+
)
|
|
1457
|
+
return self._action_dict(row), receipt
|
|
1458
|
+
|
|
1459
|
+
def defer_dispatched_observation(
|
|
1460
|
+
self,
|
|
1461
|
+
action_id,
|
|
1462
|
+
owner,
|
|
1463
|
+
execution_digest,
|
|
1464
|
+
observation_receipt_key,
|
|
1465
|
+
reason,
|
|
1466
|
+
delay_seconds,
|
|
1467
|
+
):
|
|
1468
|
+
"""Record an unresolved GET and either defer or end as ambiguous."""
|
|
1469
|
+
|
|
1470
|
+
execution_digest = self._validate_text("execution_digest", execution_digest)
|
|
1471
|
+
observation_receipt_key = self._validate_text(
|
|
1472
|
+
"observation_receipt_key", observation_receipt_key
|
|
1473
|
+
)
|
|
1474
|
+
reason = self._validate_text("reason", str(reason))[:2000]
|
|
1475
|
+
delay = float(delay_seconds)
|
|
1476
|
+
if not math.isfinite(delay) or not 0 < delay <= 24 * 60 * 60:
|
|
1477
|
+
raise ActionStoreError("defer delay must be between zero and one day")
|
|
1478
|
+
now = float(self._clock())
|
|
1479
|
+
with self._transaction() as cursor:
|
|
1480
|
+
row = self._get_action_row(cursor, action_id)
|
|
1481
|
+
self._require_lease(row, owner, now)
|
|
1482
|
+
if row["state"] != STATE_DISPATCHED:
|
|
1483
|
+
raise ActionTransitionError(
|
|
1484
|
+
"only dispatched observations can be deferred"
|
|
1485
|
+
)
|
|
1486
|
+
recovery = self._dispatch_observation_recovery(cursor, row)
|
|
1487
|
+
if (
|
|
1488
|
+
recovery is None
|
|
1489
|
+
or not hmac.compare_digest(
|
|
1490
|
+
str(recovery["execution_digest"]), execution_digest
|
|
1491
|
+
)
|
|
1492
|
+
):
|
|
1493
|
+
raise ActionTransitionError(
|
|
1494
|
+
"dispatch observation contract does not bind this action"
|
|
1495
|
+
)
|
|
1496
|
+
observations = self._dispatch_observation_attempts(cursor, row, recovery)
|
|
1497
|
+
if (
|
|
1498
|
+
not observations
|
|
1499
|
+
or observations[-1]["receipt_key"] != observation_receipt_key
|
|
1500
|
+
):
|
|
1501
|
+
raise ActionTransitionError(
|
|
1502
|
+
"dispatch observation attempt is not the current attempt"
|
|
1503
|
+
)
|
|
1504
|
+
if (
|
|
1505
|
+
now >= float(recovery["observation_deadline"])
|
|
1506
|
+
or len(observations) >= int(recovery["max_observations"])
|
|
1507
|
+
):
|
|
1508
|
+
return self._terminalize_dispatch_observation(
|
|
1509
|
+
cursor,
|
|
1510
|
+
row,
|
|
1511
|
+
recovery,
|
|
1512
|
+
now,
|
|
1513
|
+
actor=owner,
|
|
1514
|
+
reason="observation_bound_exhausted",
|
|
1515
|
+
observations=len(observations),
|
|
1516
|
+
)
|
|
1517
|
+
next_at = now + delay
|
|
1518
|
+
message = "governed dispatch observation unresolved (%d/%d)" % (
|
|
1519
|
+
len(observations),
|
|
1520
|
+
recovery["max_observations"],
|
|
1521
|
+
)
|
|
1522
|
+
cursor.execute(
|
|
1523
|
+
"""UPDATE actions SET next_attempt_at=?,lease_owner=NULL,
|
|
1524
|
+
lease_expires_at=NULL,last_error=?,updated_at=? WHERE action_id=?""",
|
|
1525
|
+
(next_at, message, now, action_id),
|
|
1526
|
+
)
|
|
1527
|
+
self._add_event(
|
|
1528
|
+
cursor,
|
|
1529
|
+
action_id,
|
|
1530
|
+
"dispatch_observation_deferred",
|
|
1531
|
+
row["state"],
|
|
1532
|
+
row["state"],
|
|
1533
|
+
owner,
|
|
1534
|
+
{
|
|
1535
|
+
"reason": reason,
|
|
1536
|
+
"retry_at": next_at,
|
|
1537
|
+
"observation": len(observations),
|
|
1538
|
+
"mode": "get_only",
|
|
1539
|
+
},
|
|
1540
|
+
now,
|
|
1541
|
+
)
|
|
1542
|
+
return self._action_dict(self._get_action_row(cursor, action_id))
|
|
1543
|
+
|
|
1544
|
+
# ------------------------------------------------------- forward states
|
|
1545
|
+
|
|
1546
|
+
def defer_leased(self, action_id, owner, reason, delay_seconds, *, event_type="deferred"):
|
|
1547
|
+
"""Release a lease without changing lifecycle state or effect count.
|
|
1548
|
+
|
|
1549
|
+
Only for work safe to repeat in its current state: a gated action
|
|
1550
|
+
whose gate is still unconsumed, or read-only verification after an
|
|
1551
|
+
external mutation produced immutable acceptance evidence.
|
|
1552
|
+
"""
|
|
1553
|
+
|
|
1554
|
+
reason = self._validate_text("reason", str(reason))[:2000]
|
|
1555
|
+
delay = float(delay_seconds)
|
|
1556
|
+
if not math.isfinite(delay) or not 0 < delay <= 24 * 60 * 60:
|
|
1557
|
+
raise ActionStoreError("defer delay must be between zero and one day")
|
|
1558
|
+
event_type = self._validate_text("event_type", event_type)
|
|
1559
|
+
now = float(self._clock())
|
|
1560
|
+
with self._transaction() as cursor:
|
|
1561
|
+
row = self._get_action_row(cursor, action_id)
|
|
1562
|
+
self._require_lease(row, owner, now)
|
|
1563
|
+
if row["state"] not in (STATE_GATED, STATE_ACCEPTED, STATE_VERIFIED):
|
|
1564
|
+
raise ActionTransitionError("action state cannot be deferred safely")
|
|
1565
|
+
next_at = now + delay
|
|
1566
|
+
cursor.execute(
|
|
1567
|
+
"""UPDATE actions SET next_attempt_at=?,lease_owner=NULL,
|
|
1568
|
+
lease_expires_at=NULL,last_error=?,updated_at=? WHERE action_id=?""",
|
|
1569
|
+
(next_at, reason, now, action_id),
|
|
1570
|
+
)
|
|
1571
|
+
self._add_event(
|
|
1572
|
+
cursor,
|
|
1573
|
+
action_id,
|
|
1574
|
+
event_type,
|
|
1575
|
+
row["state"],
|
|
1576
|
+
row["state"],
|
|
1577
|
+
owner,
|
|
1578
|
+
{"reason": reason, "retry_at": next_at},
|
|
1579
|
+
now,
|
|
1580
|
+
)
|
|
1581
|
+
return self._action_dict(self._get_action_row(cursor, action_id))
|
|
1582
|
+
|
|
1583
|
+
def accept(
|
|
1584
|
+
self,
|
|
1585
|
+
action_id,
|
|
1586
|
+
owner,
|
|
1587
|
+
receipt_key,
|
|
1588
|
+
kind,
|
|
1589
|
+
evidence,
|
|
1590
|
+
*,
|
|
1591
|
+
external_id=None,
|
|
1592
|
+
result=None,
|
|
1593
|
+
):
|
|
1594
|
+
kind = self._validate_text("kind", kind)
|
|
1595
|
+
if kind in _RESERVED_RECEIPT_KINDS:
|
|
1596
|
+
raise ActionStoreError(
|
|
1597
|
+
"receipt kind %s is reserved for its transactional owner" % kind
|
|
1598
|
+
)
|
|
1599
|
+
now = float(self._clock())
|
|
1600
|
+
with self._transaction() as cursor:
|
|
1601
|
+
row = self._get_action_row(cursor, action_id)
|
|
1602
|
+
self._require_lease(row, owner, now)
|
|
1603
|
+
if row["state"] != STATE_DISPATCHED:
|
|
1604
|
+
raise ActionTransitionError("only dispatched actions can be accepted")
|
|
1605
|
+
receipt = self._insert_receipt(
|
|
1606
|
+
cursor,
|
|
1607
|
+
action_id,
|
|
1608
|
+
receipt_key,
|
|
1609
|
+
kind,
|
|
1610
|
+
"accepted",
|
|
1611
|
+
evidence,
|
|
1612
|
+
external_id,
|
|
1613
|
+
None,
|
|
1614
|
+
now,
|
|
1615
|
+
)
|
|
1616
|
+
self._transition(
|
|
1617
|
+
cursor,
|
|
1618
|
+
row,
|
|
1619
|
+
STATE_ACCEPTED,
|
|
1620
|
+
owner,
|
|
1621
|
+
"accepted",
|
|
1622
|
+
{"receipt_key": receipt_key, "kind": kind},
|
|
1623
|
+
now,
|
|
1624
|
+
result=result,
|
|
1625
|
+
)
|
|
1626
|
+
return self._action_dict(self._get_action_row(cursor, action_id)), receipt
|
|
1627
|
+
|
|
1628
|
+
def verify(
|
|
1629
|
+
self,
|
|
1630
|
+
action_id,
|
|
1631
|
+
owner,
|
|
1632
|
+
receipt_key,
|
|
1633
|
+
evidence,
|
|
1634
|
+
*,
|
|
1635
|
+
qualifying_receipt_keys,
|
|
1636
|
+
kind="verification",
|
|
1637
|
+
):
|
|
1638
|
+
kind = self._validate_text("kind", kind)
|
|
1639
|
+
if kind in _RESERVED_RECEIPT_KINDS:
|
|
1640
|
+
raise ActionStoreError(
|
|
1641
|
+
"receipt kind %s is reserved for its transactional owner" % kind
|
|
1642
|
+
)
|
|
1643
|
+
qualifying_receipt_keys = tuple(qualifying_receipt_keys or ())
|
|
1644
|
+
if not qualifying_receipt_keys:
|
|
1645
|
+
raise ActionStoreError(
|
|
1646
|
+
"verification requires at least one qualifying receipt"
|
|
1647
|
+
)
|
|
1648
|
+
now = float(self._clock())
|
|
1649
|
+
with self._transaction() as cursor:
|
|
1650
|
+
row = self._get_action_row(cursor, action_id)
|
|
1651
|
+
self._require_lease(row, owner, now)
|
|
1652
|
+
if row["state"] != STATE_ACCEPTED:
|
|
1653
|
+
raise ActionTransitionError("only accepted actions can be verified")
|
|
1654
|
+
placeholders = ",".join("?" for _ in qualifying_receipt_keys)
|
|
1655
|
+
found = cursor.execute(
|
|
1656
|
+
"SELECT receipt_key FROM receipts WHERE action_id=? AND receipt_key IN (%s)"
|
|
1657
|
+
% placeholders,
|
|
1658
|
+
(action_id,) + qualifying_receipt_keys,
|
|
1659
|
+
).fetchall()
|
|
1660
|
+
found_keys = {item["receipt_key"] for item in found}
|
|
1661
|
+
missing = set(qualifying_receipt_keys) - found_keys
|
|
1662
|
+
if missing:
|
|
1663
|
+
raise ActionNotFound(
|
|
1664
|
+
"qualifying receipts not found: %s" % sorted(missing)
|
|
1665
|
+
)
|
|
1666
|
+
receipt = self._insert_receipt(
|
|
1667
|
+
cursor,
|
|
1668
|
+
action_id,
|
|
1669
|
+
receipt_key,
|
|
1670
|
+
kind,
|
|
1671
|
+
"verified",
|
|
1672
|
+
evidence,
|
|
1673
|
+
None,
|
|
1674
|
+
None,
|
|
1675
|
+
now,
|
|
1676
|
+
)
|
|
1677
|
+
self._transition(
|
|
1678
|
+
cursor,
|
|
1679
|
+
row,
|
|
1680
|
+
STATE_VERIFIED,
|
|
1681
|
+
owner,
|
|
1682
|
+
"verified",
|
|
1683
|
+
{
|
|
1684
|
+
"receipt_key": receipt_key,
|
|
1685
|
+
"qualifying_receipt_keys": list(qualifying_receipt_keys),
|
|
1686
|
+
},
|
|
1687
|
+
now,
|
|
1688
|
+
)
|
|
1689
|
+
return self._action_dict(self._get_action_row(cursor, action_id)), receipt
|
|
1690
|
+
|
|
1691
|
+
def complete(self, action_id, owner, result):
|
|
1692
|
+
now = float(self._clock())
|
|
1693
|
+
with self._transaction() as cursor:
|
|
1694
|
+
row = self._get_action_row(cursor, action_id)
|
|
1695
|
+
self._require_lease(row, owner, now)
|
|
1696
|
+
if row["state"] != STATE_VERIFIED:
|
|
1697
|
+
raise ActionTransitionError(
|
|
1698
|
+
"an action must be verified before completion"
|
|
1699
|
+
)
|
|
1700
|
+
self._transition(
|
|
1701
|
+
cursor,
|
|
1702
|
+
row,
|
|
1703
|
+
STATE_COMPLETED,
|
|
1704
|
+
owner,
|
|
1705
|
+
"completed",
|
|
1706
|
+
{"result_sha256": sha256_json_utf8(result)},
|
|
1707
|
+
now,
|
|
1708
|
+
result=result,
|
|
1709
|
+
clear_lease=True,
|
|
1710
|
+
)
|
|
1711
|
+
return self._action_dict(self._get_action_row(cursor, action_id))
|
|
1712
|
+
|
|
1713
|
+
def fail_attempt(self, action_id, owner, error, retryable):
|
|
1714
|
+
"""Terminalize as failed. ``retryable`` is recorded, never honored
|
|
1715
|
+
with a second mutation (invariants I6/I10)."""
|
|
1716
|
+
|
|
1717
|
+
error = self._validate_text("error", str(error))[:2000]
|
|
1718
|
+
now = float(self._clock())
|
|
1719
|
+
with self._transaction() as cursor:
|
|
1720
|
+
row = self._get_action_row(cursor, action_id)
|
|
1721
|
+
self._require_lease(row, owner, now)
|
|
1722
|
+
state = row["state"]
|
|
1723
|
+
if state not in (
|
|
1724
|
+
STATE_GATED,
|
|
1725
|
+
STATE_DISPATCHED,
|
|
1726
|
+
STATE_ACCEPTED,
|
|
1727
|
+
STATE_VERIFIED,
|
|
1728
|
+
):
|
|
1729
|
+
raise ActionTransitionError("action in %s cannot fail" % state)
|
|
1730
|
+
reason = error
|
|
1731
|
+
if retryable and state != STATE_DISPATCHED:
|
|
1732
|
+
reason = "%s (not retried after %s: outcome may be ambiguous)" % (
|
|
1733
|
+
error,
|
|
1734
|
+
state,
|
|
1735
|
+
)
|
|
1736
|
+
elif retryable:
|
|
1737
|
+
reason = "%s (governed actions are never re-dispatched)" % error
|
|
1738
|
+
self._transition(
|
|
1739
|
+
cursor,
|
|
1740
|
+
row,
|
|
1741
|
+
STATE_FAILED,
|
|
1742
|
+
owner,
|
|
1743
|
+
"failed",
|
|
1744
|
+
{
|
|
1745
|
+
"error": reason,
|
|
1746
|
+
"attempt": row["attempt_count"],
|
|
1747
|
+
"retryable_requested": bool(retryable),
|
|
1748
|
+
},
|
|
1749
|
+
now,
|
|
1750
|
+
error=reason,
|
|
1751
|
+
clear_lease=True,
|
|
1752
|
+
)
|
|
1753
|
+
self._dead_letter_action(cursor, action_id, reason, now)
|
|
1754
|
+
return self._action_dict(self._get_action_row(cursor, action_id))
|
|
1755
|
+
|
|
1756
|
+
# ------------------------------------------------------------ read side
|
|
1757
|
+
|
|
1758
|
+
def get_action(self, action_id):
|
|
1759
|
+
with self._lock:
|
|
1760
|
+
if self._conn is None:
|
|
1761
|
+
raise ActionStoreError("action store is closed")
|
|
1762
|
+
row = self._conn.execute(
|
|
1763
|
+
"SELECT * FROM actions WHERE action_id=?", (action_id,)
|
|
1764
|
+
).fetchone()
|
|
1765
|
+
if row is None:
|
|
1766
|
+
raise ActionNotFound("action %s not found" % action_id)
|
|
1767
|
+
return self._action_dict(row)
|
|
1768
|
+
|
|
1769
|
+
def list_receipts(self, action_id):
|
|
1770
|
+
with self._lock:
|
|
1771
|
+
if self._conn is None:
|
|
1772
|
+
raise ActionStoreError("action store is closed")
|
|
1773
|
+
rows = self._conn.execute(
|
|
1774
|
+
"SELECT * FROM receipts WHERE action_id=? ORDER BY created_at,receipt_id",
|
|
1775
|
+
(action_id,),
|
|
1776
|
+
).fetchall()
|
|
1777
|
+
return [self._receipt_dict(row) for row in rows]
|
|
1778
|
+
|
|
1779
|
+
def list_events(self, action_id):
|
|
1780
|
+
with self._lock:
|
|
1781
|
+
if self._conn is None:
|
|
1782
|
+
raise ActionStoreError("action store is closed")
|
|
1783
|
+
rows = self._conn.execute(
|
|
1784
|
+
"SELECT * FROM action_events WHERE action_id=? ORDER BY event_id",
|
|
1785
|
+
(action_id,),
|
|
1786
|
+
).fetchall()
|
|
1787
|
+
result = []
|
|
1788
|
+
for row in rows:
|
|
1789
|
+
item = dict(row)
|
|
1790
|
+
item["details"] = self._json_load(item.pop("details_json"))
|
|
1791
|
+
result.append(item)
|
|
1792
|
+
return result
|
|
1793
|
+
|
|
1794
|
+
def list_dead_letters(self):
|
|
1795
|
+
with self._lock:
|
|
1796
|
+
if self._conn is None:
|
|
1797
|
+
raise ActionStoreError("action store is closed")
|
|
1798
|
+
rows = self._conn.execute(
|
|
1799
|
+
"SELECT * FROM dead_letters ORDER BY created_at,dead_letter_id"
|
|
1800
|
+
).fetchall()
|
|
1801
|
+
result = []
|
|
1802
|
+
for row in rows:
|
|
1803
|
+
item = dict(row)
|
|
1804
|
+
item["action_snapshot"] = self._json_load(
|
|
1805
|
+
item.pop("action_snapshot_json")
|
|
1806
|
+
)
|
|
1807
|
+
result.append(item)
|
|
1808
|
+
return result
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
__all__ = (
|
|
1812
|
+
"OBSERVATION_SCHEMA",
|
|
1813
|
+
"RECOVERY_SCHEMA",
|
|
1814
|
+
"SqliteActionStore",
|
|
1815
|
+
)
|