loopiter 0.2.0a1__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.
- loopiter/__init__.py +16 -0
- loopiter/_validation.py +335 -0
- loopiter/analysis.py +261 -0
- loopiter/client.py +637 -0
- loopiter/migrations/001-python-store.sql +25 -0
- loopiter/postgres.py +161 -0
- loopiter/py.typed +0 -0
- loopiter/store.py +143 -0
- loopiter/testing.py +95 -0
- loopiter-0.2.0a1.dist-info/METADATA +295 -0
- loopiter-0.2.0a1.dist-info/RECORD +13 -0
- loopiter-0.2.0a1.dist-info/WHEEL +4 -0
- loopiter-0.2.0a1.dist-info/licenses/LICENSE +21 -0
loopiter/client.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""Review-first lifecycle coordination; external callbacks never run in a DB transaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from . import _validation as v
|
|
12
|
+
from .store import FeedbackStore, StoreTransaction, page_options
|
|
13
|
+
|
|
14
|
+
Record = v.Record
|
|
15
|
+
_UNSET = object()
|
|
16
|
+
_callbacks: set[asyncio.Task] = set()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DeploymentAdapter(Protocol):
|
|
20
|
+
"""All operations must be durable and idempotent by request['idempotency_key'].
|
|
21
|
+
|
|
22
|
+
inspect returns applied + receipt, unknown, or *fenced* not_applied. Fenced means
|
|
23
|
+
a previous/late apply can NEVER take effect afterward. Rollback is a real change.
|
|
24
|
+
Compare expected_artifact_version atomically in your infrastructure.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
async def apply(self, request: Record) -> Record: ...
|
|
28
|
+
async def rollback(self, request: Record) -> Record: ...
|
|
29
|
+
async def inspect(self, request: Record) -> Record: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _callback(operation: Callable[[asyncio.Event], Awaitable[Any]], timeout: float) -> Any: # noqa: ASYNC109
|
|
33
|
+
# wait(), not wait_for(): a callback can suppress cancellation. Never persist its late result.
|
|
34
|
+
cancellation = asyncio.Event()
|
|
35
|
+
|
|
36
|
+
async def invoke():
|
|
37
|
+
return await operation(cancellation)
|
|
38
|
+
|
|
39
|
+
task = asyncio.create_task(invoke())
|
|
40
|
+
_callbacks.add(task) # Keep even a cancellation-suppressing callback alive until it exits.
|
|
41
|
+
|
|
42
|
+
def finished(t):
|
|
43
|
+
_callbacks.discard(t)
|
|
44
|
+
if not t.cancelled():
|
|
45
|
+
t.exception() # Consume failures from ignored late results.
|
|
46
|
+
|
|
47
|
+
task.add_done_callback(finished)
|
|
48
|
+
try:
|
|
49
|
+
done, _ = await asyncio.wait({task}, timeout=timeout)
|
|
50
|
+
if not done:
|
|
51
|
+
raise v.LoopiterError("timeout", "Callback deadline exceeded.")
|
|
52
|
+
return task.result()
|
|
53
|
+
finally:
|
|
54
|
+
if not task.done():
|
|
55
|
+
cancellation.set()
|
|
56
|
+
task.cancel()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FeedbackLoop:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
store: FeedbackStore,
|
|
64
|
+
namespace: str,
|
|
65
|
+
maximum_payload_bytes: int = 262144,
|
|
66
|
+
callback_timeout: float = 120,
|
|
67
|
+
sanitize: Callable[[Any], Awaitable[Any]] | None = None,
|
|
68
|
+
deployments_enabled: Callable[[], bool] | None = None,
|
|
69
|
+
):
|
|
70
|
+
v.nonempty(namespace, "namespace")
|
|
71
|
+
if type(getattr(store, "version", None)) is not int or store.version != 1:
|
|
72
|
+
v.fail("invalid_input", "Python FeedbackStore contract version 1 required.")
|
|
73
|
+
for name in ("transaction", "delete_namespace", "close"):
|
|
74
|
+
if not callable(getattr(store, name, None)):
|
|
75
|
+
v.fail("invalid_input", f"Store missing {name}.")
|
|
76
|
+
v.integer(maximum_payload_bytes, "maximum_payload_bytes")
|
|
77
|
+
v.finite(callback_timeout, "callback_timeout", 0)
|
|
78
|
+
if callback_timeout <= 0:
|
|
79
|
+
v.fail("invalid_input", "callback_timeout must be positive seconds.")
|
|
80
|
+
for hook in (sanitize, deployments_enabled):
|
|
81
|
+
if hook is not None and not callable(hook):
|
|
82
|
+
v.fail("invalid_input", "Hooks must be callable.")
|
|
83
|
+
self._store, self._namespace = store, namespace
|
|
84
|
+
self.maximum_payload_bytes, self.callback_timeout = maximum_payload_bytes, callback_timeout
|
|
85
|
+
self.sanitize, self.deployments_enabled = sanitize, deployments_enabled
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def namespace(self) -> str:
|
|
89
|
+
return self._namespace
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def store(self) -> FeedbackStore:
|
|
93
|
+
return self._store
|
|
94
|
+
|
|
95
|
+
def _base(self, prefix: str, key: str | None = None) -> Record:
|
|
96
|
+
now = v.now()
|
|
97
|
+
return {
|
|
98
|
+
"id": key or f"{prefix}_{uuid4()}",
|
|
99
|
+
"namespace": self.namespace,
|
|
100
|
+
"revision": 1,
|
|
101
|
+
"created_at": now,
|
|
102
|
+
"updated_at": now,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _next(row: Record, **updates) -> Record:
|
|
107
|
+
result = deepcopy(row)
|
|
108
|
+
result.update(updates)
|
|
109
|
+
result.update(revision=row["revision"] + 1, updated_at=max(v.now(), row["updated_at"]))
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
async def _prepare(self, raw: Any) -> Any:
|
|
113
|
+
v.json_value(raw, self.maximum_payload_bytes)
|
|
114
|
+
value = deepcopy(raw)
|
|
115
|
+
if self.sanitize is not None:
|
|
116
|
+
value = await _callback(lambda _: self.sanitize(value), self.callback_timeout)
|
|
117
|
+
v.json_value(value, self.maximum_payload_bytes)
|
|
118
|
+
return deepcopy(value)
|
|
119
|
+
|
|
120
|
+
async def _read(
|
|
121
|
+
self, tx: StoreTransaction, kind: str, key: str, *, required=False
|
|
122
|
+
) -> Record | None:
|
|
123
|
+
v.nonempty(key, "id")
|
|
124
|
+
row = await tx.get(kind, key)
|
|
125
|
+
if row is not None:
|
|
126
|
+
v.stored(kind, row, self.namespace)
|
|
127
|
+
if row["id"] != key:
|
|
128
|
+
v.fail("integrity_error", "Adapter returned another ID.")
|
|
129
|
+
return deepcopy(row)
|
|
130
|
+
if required:
|
|
131
|
+
v.fail("not_found", f"{kind} record not found.")
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
async def _event(self, tx: StoreTransaction, type_: str, key: str, **details) -> None:
|
|
135
|
+
await tx.insert(
|
|
136
|
+
"events", {**self._base("event"), "type": type_, "subject_id": key, "details": details}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def list(
|
|
140
|
+
self,
|
|
141
|
+
collection: str,
|
|
142
|
+
*,
|
|
143
|
+
limit: int = 100,
|
|
144
|
+
cursor: str | None = None,
|
|
145
|
+
window: Record | None = None,
|
|
146
|
+
) -> Record:
|
|
147
|
+
v.enum(collection, v.COLLECTIONS, "collection")
|
|
148
|
+
page_options(limit, cursor, window)
|
|
149
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
150
|
+
return self._page(
|
|
151
|
+
collection,
|
|
152
|
+
await tx.list(collection, limit=limit, cursor=cursor, window=window),
|
|
153
|
+
limit,
|
|
154
|
+
cursor,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _page(self, kind: str, page: Any, limit: int, cursor: str | None) -> Record:
|
|
158
|
+
v.fields(page, ["items", "next_cursor"], ("items",))
|
|
159
|
+
if type(page["items"]) is not list or len(page["items"]) > limit:
|
|
160
|
+
v.fail("integrity_error", "Invalid adapter page size.")
|
|
161
|
+
previous = cursor
|
|
162
|
+
for row in page["items"]:
|
|
163
|
+
v.stored(kind, row, self.namespace)
|
|
164
|
+
if previous is not None and row["id"] <= previous:
|
|
165
|
+
v.fail("integrity_error", "Duplicate or non-progressing adapter page.")
|
|
166
|
+
previous = row["id"]
|
|
167
|
+
if "next_cursor" in page:
|
|
168
|
+
v.nonempty(page["next_cursor"], "next_cursor")
|
|
169
|
+
if not page["items"] or page["next_cursor"] != previous:
|
|
170
|
+
v.fail("integrity_error", "Cursor must identify last returned record.")
|
|
171
|
+
return deepcopy(page)
|
|
172
|
+
|
|
173
|
+
async def get_execution(self, key: str) -> Record | None:
|
|
174
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
175
|
+
return await self._read(tx, "executions", key)
|
|
176
|
+
|
|
177
|
+
async def get_candidate(self, key: str) -> Record | None:
|
|
178
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
179
|
+
return await self._read(tx, "candidates", key)
|
|
180
|
+
|
|
181
|
+
async def get_active_candidate(self, target: Record) -> Record | None:
|
|
182
|
+
v.target(target)
|
|
183
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
184
|
+
state = await self._read(tx, "targets", v.fingerprint(target))
|
|
185
|
+
if state and state.get("active_candidate_id"):
|
|
186
|
+
return await self._read(
|
|
187
|
+
tx, "candidates", state["active_candidate_id"], required=True
|
|
188
|
+
)
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
async def _insert_input(self, tx: StoreTransaction, kind: str, row: Record) -> Record:
|
|
192
|
+
previous = await self._read(tx, kind, row["id"])
|
|
193
|
+
if previous is not None:
|
|
194
|
+
if previous["input_hash"] != row["input_hash"]:
|
|
195
|
+
v.fail("conflict", "ID already exists with different input.")
|
|
196
|
+
return previous
|
|
197
|
+
await tx.insert(kind, row)
|
|
198
|
+
return deepcopy(row)
|
|
199
|
+
|
|
200
|
+
async def record_execution(self, **input_) -> Record:
|
|
201
|
+
v.execution_input(input_)
|
|
202
|
+
data = await self._prepare(input_)
|
|
203
|
+
v.execution_input(data)
|
|
204
|
+
row = {
|
|
205
|
+
**self._base("execution", data.get("id")),
|
|
206
|
+
"artifacts": {},
|
|
207
|
+
"metadata": {},
|
|
208
|
+
"started_at": v.now(),
|
|
209
|
+
**data,
|
|
210
|
+
"input_hash": v.fingerprint(data),
|
|
211
|
+
}
|
|
212
|
+
v.stored("executions", row, self.namespace)
|
|
213
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
214
|
+
if data.get("parent_execution_id"):
|
|
215
|
+
parent = await self._read(
|
|
216
|
+
tx, "executions", data["parent_execution_id"], required=True
|
|
217
|
+
)
|
|
218
|
+
if parent.get("episode_id") != data.get("episode_id"):
|
|
219
|
+
v.fail("invalid_input", "Parent and child must share the episode.")
|
|
220
|
+
return await self._insert_input(tx, "executions", row)
|
|
221
|
+
|
|
222
|
+
async def complete_execution(self, key: str, *, expected_revision: int, **input_) -> Record:
|
|
223
|
+
v.integer(expected_revision, "expected_revision")
|
|
224
|
+
v.fields(input_, ["output", "metadata", "completed_at"])
|
|
225
|
+
data = await self._prepare(input_)
|
|
226
|
+
v.fields(data, ["output", "metadata", "completed_at"])
|
|
227
|
+
if "metadata" in data:
|
|
228
|
+
v.obj(data["metadata"], "metadata")
|
|
229
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
230
|
+
row = await self._read(tx, "executions", key, required=True)
|
|
231
|
+
updated = self._next(row, **data)
|
|
232
|
+
updated["metadata"] = {**row["metadata"], **data.get("metadata", {})}
|
|
233
|
+
updated["completed_at"] = data.get("completed_at", v.now())
|
|
234
|
+
v.stored("executions", updated, self.namespace)
|
|
235
|
+
await tx.replace("executions", updated, expected_revision)
|
|
236
|
+
return updated
|
|
237
|
+
|
|
238
|
+
async def record_signal(self, **input_) -> Record:
|
|
239
|
+
v.signal_input(input_)
|
|
240
|
+
data = await self._prepare(input_)
|
|
241
|
+
v.signal_input(data)
|
|
242
|
+
row = {
|
|
243
|
+
**self._base("signal", data.get("id")),
|
|
244
|
+
"confidence": 1,
|
|
245
|
+
"metadata": {},
|
|
246
|
+
"observed_at": v.now(),
|
|
247
|
+
**data,
|
|
248
|
+
"input_hash": v.fingerprint(data),
|
|
249
|
+
}
|
|
250
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
251
|
+
if data.get("execution_id"):
|
|
252
|
+
execution = await self._read(tx, "executions", data["execution_id"], required=True)
|
|
253
|
+
if "episode_id" in data and data["episode_id"] != execution.get("episode_id"):
|
|
254
|
+
v.fail("invalid_input", "Signal and execution episodes differ.")
|
|
255
|
+
return await self._insert_input(tx, "signals", row)
|
|
256
|
+
|
|
257
|
+
async def create_candidate(self, **input_) -> Record:
|
|
258
|
+
v.candidate_input(input_)
|
|
259
|
+
data = await self._prepare(input_)
|
|
260
|
+
v.candidate_input(data)
|
|
261
|
+
row = {
|
|
262
|
+
**self._base("candidate", data.get("id")),
|
|
263
|
+
"risk": "medium",
|
|
264
|
+
"metadata": {},
|
|
265
|
+
**data,
|
|
266
|
+
"input_hash": v.fingerprint(data),
|
|
267
|
+
"evidence_hash": v.fingerprint(data["evidence"]),
|
|
268
|
+
"status": "proposed",
|
|
269
|
+
"evaluations": [],
|
|
270
|
+
}
|
|
271
|
+
row["content_hash"] = v.content_hash(row)
|
|
272
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
273
|
+
exists = await self._read(tx, "candidates", row["id"])
|
|
274
|
+
result = await self._insert_input(tx, "candidates", row)
|
|
275
|
+
if exists is None:
|
|
276
|
+
await self._event(tx, "candidate.created", row["id"])
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
async def _unlocked(self, tx: StoreTransaction, candidate: Record) -> None:
|
|
280
|
+
state = await self._read(tx, "targets", v.fingerprint(candidate["target"]))
|
|
281
|
+
if state and state.get("pending_attempt_id"):
|
|
282
|
+
raise v.LoopiterError(
|
|
283
|
+
"deployment_pending",
|
|
284
|
+
"Target requires reconciliation.",
|
|
285
|
+
attempt_id=state["pending_attempt_id"],
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
async def evaluate_candidate(
|
|
289
|
+
self,
|
|
290
|
+
key: str,
|
|
291
|
+
evaluator: Callable[[Record, asyncio.Event], Awaitable[Record]],
|
|
292
|
+
*,
|
|
293
|
+
evaluator_name: str,
|
|
294
|
+
version: str,
|
|
295
|
+
dataset_hash: str,
|
|
296
|
+
) -> Record:
|
|
297
|
+
for name, value in (
|
|
298
|
+
("evaluator_name", evaluator_name),
|
|
299
|
+
("version", version),
|
|
300
|
+
("dataset_hash", dataset_hash),
|
|
301
|
+
):
|
|
302
|
+
v.nonempty(value, name)
|
|
303
|
+
if not callable(evaluator):
|
|
304
|
+
v.fail("invalid_input", "evaluator must be an async callable.")
|
|
305
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
306
|
+
snapshot = await self._read(tx, "candidates", key, required=True)
|
|
307
|
+
await self._unlocked(tx, snapshot)
|
|
308
|
+
if snapshot["status"] not in ("proposed", "evaluated"):
|
|
309
|
+
v.fail("conflict", "Candidate is no longer evaluable.")
|
|
310
|
+
result = await _callback(
|
|
311
|
+
lambda cancel: evaluator(deepcopy(snapshot), cancel), self.callback_timeout
|
|
312
|
+
)
|
|
313
|
+
v.evaluation(result)
|
|
314
|
+
result = await self._prepare(result)
|
|
315
|
+
v.evaluation(result)
|
|
316
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
317
|
+
current = await self._read(tx, "candidates", key, required=True)
|
|
318
|
+
await self._unlocked(tx, current)
|
|
319
|
+
if current["revision"] != snapshot["revision"]:
|
|
320
|
+
v.fail("conflict", "Late evaluation ignored after a state change.")
|
|
321
|
+
evaluation = {
|
|
322
|
+
**result,
|
|
323
|
+
"id": f"evaluation_{uuid4()}",
|
|
324
|
+
"candidate_hash": current["content_hash"],
|
|
325
|
+
"evidence_hash": current["evidence_hash"],
|
|
326
|
+
"evaluator": evaluator_name,
|
|
327
|
+
"version": version,
|
|
328
|
+
"dataset_hash": dataset_hash,
|
|
329
|
+
"created_at": v.now(),
|
|
330
|
+
}
|
|
331
|
+
updated = self._next(
|
|
332
|
+
current, status="evaluated", evaluations=[*current["evaluations"], evaluation]
|
|
333
|
+
)
|
|
334
|
+
await tx.replace("candidates", updated, current["revision"])
|
|
335
|
+
await self._event(
|
|
336
|
+
tx,
|
|
337
|
+
"candidate.evaluated",
|
|
338
|
+
key,
|
|
339
|
+
evaluation_id=evaluation["id"],
|
|
340
|
+
passed=result["passed"],
|
|
341
|
+
)
|
|
342
|
+
return updated
|
|
343
|
+
|
|
344
|
+
async def approve_candidate(self, key: str, *, actor: str, evaluation_id: str) -> Record:
|
|
345
|
+
v.nonempty(actor, "actor")
|
|
346
|
+
v.nonempty(evaluation_id, "evaluation_id")
|
|
347
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
348
|
+
current = await self._read(tx, "candidates", key, required=True)
|
|
349
|
+
await self._unlocked(tx, current)
|
|
350
|
+
latest = current["evaluations"][-1] if current["evaluations"] else {}
|
|
351
|
+
if (
|
|
352
|
+
current["status"] != "evaluated"
|
|
353
|
+
or not latest.get("passed")
|
|
354
|
+
or latest["id"] != evaluation_id
|
|
355
|
+
):
|
|
356
|
+
v.fail("conflict", "Approval requires the latest passing evaluation.")
|
|
357
|
+
updated = self._next(
|
|
358
|
+
current,
|
|
359
|
+
status="approved",
|
|
360
|
+
approval={
|
|
361
|
+
"actor": actor,
|
|
362
|
+
"evaluation_id": evaluation_id,
|
|
363
|
+
"candidate_hash": current["content_hash"],
|
|
364
|
+
"approved_at": v.now(),
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
await tx.replace("candidates", updated, current["revision"])
|
|
368
|
+
await self._event(
|
|
369
|
+
tx, "candidate.approved", key, actor=actor, evaluation_id=evaluation_id
|
|
370
|
+
)
|
|
371
|
+
return updated
|
|
372
|
+
|
|
373
|
+
async def reject_candidate(self, key: str, *, reason: str) -> Record:
|
|
374
|
+
v.nonempty(reason, "reason")
|
|
375
|
+
details = await self._prepare({"reason": reason})
|
|
376
|
+
v.fields(details, ["reason"], ("reason",))
|
|
377
|
+
v.nonempty(details["reason"], "reason")
|
|
378
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
379
|
+
current = await self._read(tx, "candidates", key, required=True)
|
|
380
|
+
await self._unlocked(tx, current)
|
|
381
|
+
if current["status"] not in ("proposed", "evaluated", "approved"):
|
|
382
|
+
v.fail("conflict", "Candidate cannot be rejected in this state.")
|
|
383
|
+
updated = self._next(current, status="rejected")
|
|
384
|
+
updated.pop("approval", None)
|
|
385
|
+
await tx.replace("candidates", updated, current["revision"])
|
|
386
|
+
await self._event(tx, "candidate.rejected", key, **details)
|
|
387
|
+
return updated
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _adapter(adapter: DeploymentAdapter) -> None:
|
|
391
|
+
if any(
|
|
392
|
+
not callable(getattr(adapter, name, None)) for name in ("apply", "inspect", "rollback")
|
|
393
|
+
):
|
|
394
|
+
v.fail("invalid_input", "DeploymentAdapter requires apply, inspect and rollback.")
|
|
395
|
+
|
|
396
|
+
def _enabled(self) -> None:
|
|
397
|
+
if self.deployments_enabled is not None and self.deployments_enabled() is not True:
|
|
398
|
+
v.fail(
|
|
399
|
+
"deployment_disabled", "New deployments are disabled. Recovery remains available."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
async def deploy_candidate(
|
|
403
|
+
self, key: str, adapter: DeploymentAdapter, *, expected_artifact_version: Any = _UNSET
|
|
404
|
+
) -> Record:
|
|
405
|
+
return await self._start(key, adapter, "apply", expected_artifact_version)
|
|
406
|
+
|
|
407
|
+
async def rollback_candidate(self, key: str, adapter: DeploymentAdapter) -> Record:
|
|
408
|
+
return await self._start(key, adapter, "rollback", _UNSET)
|
|
409
|
+
|
|
410
|
+
async def _start(
|
|
411
|
+
self, key: str, adapter: DeploymentAdapter, operation: str, expected: Any
|
|
412
|
+
) -> Record:
|
|
413
|
+
self._adapter(adapter)
|
|
414
|
+
if expected is not _UNSET and expected is not None:
|
|
415
|
+
v.nonempty(expected, "expected_artifact_version")
|
|
416
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
417
|
+
if operation == "apply":
|
|
418
|
+
self._enabled()
|
|
419
|
+
candidate = await self._read(tx, "candidates", key, required=True)
|
|
420
|
+
target_id = v.fingerprint(candidate["target"])
|
|
421
|
+
previous = await self._read(tx, "targets", target_id)
|
|
422
|
+
await self._unlocked(tx, candidate)
|
|
423
|
+
if operation == "apply" and candidate["status"] != "approved":
|
|
424
|
+
v.fail("conflict", "Deployment requires an approved candidate.")
|
|
425
|
+
if operation == "rollback" and (
|
|
426
|
+
candidate["status"] != "deployed"
|
|
427
|
+
or not previous
|
|
428
|
+
or previous.get("active_candidate_id") != key
|
|
429
|
+
or "deployment_receipt" not in candidate
|
|
430
|
+
):
|
|
431
|
+
v.fail("conflict", "Rollback requires the currently deployed candidate.")
|
|
432
|
+
version = (
|
|
433
|
+
previous.get("artifact_version")
|
|
434
|
+
if previous and "artifact_version" in previous
|
|
435
|
+
else (None if expected is _UNSET else expected)
|
|
436
|
+
)
|
|
437
|
+
if expected is not _UNSET and version != expected:
|
|
438
|
+
v.fail("conflict", "Active artifact version differs from expected version.")
|
|
439
|
+
attempt = {
|
|
440
|
+
**self._base("attempt"),
|
|
441
|
+
"target": candidate["target"],
|
|
442
|
+
"candidate_id": key,
|
|
443
|
+
"operation": operation,
|
|
444
|
+
"status": "pending",
|
|
445
|
+
"expected_artifact_version": version,
|
|
446
|
+
}
|
|
447
|
+
if previous and previous.get("active_candidate_id"):
|
|
448
|
+
attempt["previous_candidate_id"] = previous["active_candidate_id"]
|
|
449
|
+
if operation == "rollback":
|
|
450
|
+
attempt["restore_artifact_version"] = candidate["deployment_receipt"][
|
|
451
|
+
"previous_artifact_version"
|
|
452
|
+
]
|
|
453
|
+
if candidate.get("predecessor_id"):
|
|
454
|
+
restore = await self._read(
|
|
455
|
+
tx, "candidates", candidate["predecessor_id"], required=True
|
|
456
|
+
)
|
|
457
|
+
if restore["status"] != "superseded":
|
|
458
|
+
v.fail("conflict", "Predecessor is not restorable.")
|
|
459
|
+
attempt["restore_candidate_id"] = restore["id"]
|
|
460
|
+
state = (
|
|
461
|
+
self._next(previous)
|
|
462
|
+
if previous
|
|
463
|
+
else {**self._base("target", target_id), "target": candidate["target"]}
|
|
464
|
+
)
|
|
465
|
+
state["pending_attempt_id"] = attempt["id"]
|
|
466
|
+
await tx.insert("attempts", attempt)
|
|
467
|
+
if previous:
|
|
468
|
+
await tx.replace("targets", state, previous["revision"])
|
|
469
|
+
else:
|
|
470
|
+
await tx.insert("targets", state)
|
|
471
|
+
await self._event(
|
|
472
|
+
tx, "deployment.pending", key, attempt_id=attempt["id"], operation=operation
|
|
473
|
+
)
|
|
474
|
+
return await self._execute(attempt, adapter, inspect_only=False)
|
|
475
|
+
|
|
476
|
+
async def reconcile_deployment(self, attempt_id: str, adapter: DeploymentAdapter) -> Record:
|
|
477
|
+
self._adapter(adapter)
|
|
478
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
479
|
+
attempt = await self._read(tx, "attempts", attempt_id, required=True)
|
|
480
|
+
if attempt["status"] != "pending":
|
|
481
|
+
return attempt
|
|
482
|
+
return await self._execute(attempt, adapter, inspect_only=True)
|
|
483
|
+
|
|
484
|
+
async def _execute(
|
|
485
|
+
self, attempt: Record, adapter: DeploymentAdapter, *, inspect_only: bool
|
|
486
|
+
) -> Record:
|
|
487
|
+
try:
|
|
488
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
489
|
+
candidate = await self._read(
|
|
490
|
+
tx, "candidates", attempt["candidate_id"], required=True
|
|
491
|
+
)
|
|
492
|
+
restore = (
|
|
493
|
+
await self._read(
|
|
494
|
+
tx, "candidates", attempt["restore_candidate_id"], required=True
|
|
495
|
+
)
|
|
496
|
+
if attempt.get("restore_candidate_id")
|
|
497
|
+
else None
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
async def invoke(cancel):
|
|
501
|
+
request = {
|
|
502
|
+
"attempt": deepcopy(attempt),
|
|
503
|
+
"candidate": deepcopy(candidate),
|
|
504
|
+
"restore_candidate": deepcopy(restore),
|
|
505
|
+
"idempotency_key": attempt["id"],
|
|
506
|
+
"cancellation": cancel,
|
|
507
|
+
}
|
|
508
|
+
if inspect_only:
|
|
509
|
+
return await adapter.inspect(request)
|
|
510
|
+
if attempt["operation"] == "apply":
|
|
511
|
+
self._enabled()
|
|
512
|
+
result = await getattr(adapter, attempt["operation"])(request)
|
|
513
|
+
return {"status": "applied", "receipt": result}
|
|
514
|
+
|
|
515
|
+
result = await _callback(invoke, self.callback_timeout)
|
|
516
|
+
v.fields(result, ["status", "receipt"], ("status",))
|
|
517
|
+
v.enum(result["status"], ["applied", "unknown", "not_applied"], "inspection status")
|
|
518
|
+
if result["status"] == "unknown":
|
|
519
|
+
return attempt
|
|
520
|
+
if result["status"] == "applied" and "receipt" not in result:
|
|
521
|
+
v.fail("integrity_error", "Applied inspection requires a receipt.")
|
|
522
|
+
if result["status"] == "not_applied" and "receipt" in result:
|
|
523
|
+
v.fail("integrity_error", "not_applied cannot include a receipt.")
|
|
524
|
+
return await self._finalize(attempt["id"], result.get("receipt"))
|
|
525
|
+
except asyncio.CancelledError:
|
|
526
|
+
# Attempt already persisted. Caller cancellation never clears target reservation.
|
|
527
|
+
raise
|
|
528
|
+
except Exception as exc:
|
|
529
|
+
raise v.LoopiterError(
|
|
530
|
+
"deployment_pending",
|
|
531
|
+
"Outcome uncertain; reconcile this attempt before changing the target.",
|
|
532
|
+
attempt_id=attempt["id"],
|
|
533
|
+
) from exc
|
|
534
|
+
|
|
535
|
+
async def _finalize(self, key: str, receipt: Record | None) -> Record:
|
|
536
|
+
if receipt is not None:
|
|
537
|
+
v.receipt(receipt)
|
|
538
|
+
original = {
|
|
539
|
+
k: receipt[k]
|
|
540
|
+
for k in ("attempt_id", "artifact_version", "previous_artifact_version")
|
|
541
|
+
}
|
|
542
|
+
receipt = await self._prepare(receipt)
|
|
543
|
+
v.receipt(receipt)
|
|
544
|
+
if any(receipt[k] != value for k, value in original.items()):
|
|
545
|
+
v.fail("integrity_error", "Sanitizer must not change deployment identities.")
|
|
546
|
+
async with self.store.transaction(self.namespace) as tx:
|
|
547
|
+
attempt = await self._read(tx, "attempts", key, required=True)
|
|
548
|
+
if attempt["status"] != "pending":
|
|
549
|
+
if attempt.get("receipt") != receipt:
|
|
550
|
+
v.fail("conflict", "Conflicting reconciliation result.")
|
|
551
|
+
return attempt
|
|
552
|
+
state = await self._read(tx, "targets", v.fingerprint(attempt["target"]), required=True)
|
|
553
|
+
if state.get("pending_attempt_id") != key:
|
|
554
|
+
v.fail("conflict", "Target reservation changed.")
|
|
555
|
+
updated_state = self._next(state)
|
|
556
|
+
updated_state.pop("pending_attempt_id")
|
|
557
|
+
updated_attempt = self._next(attempt, status="succeeded" if receipt else "not_applied")
|
|
558
|
+
if receipt is not None:
|
|
559
|
+
if (
|
|
560
|
+
receipt["attempt_id"] != key
|
|
561
|
+
or receipt["previous_artifact_version"] != attempt["expected_artifact_version"]
|
|
562
|
+
):
|
|
563
|
+
v.fail("integrity_error", "Receipt does not match reserved attempt.")
|
|
564
|
+
if attempt["operation"] == "apply" and receipt["artifact_version"] is None:
|
|
565
|
+
v.fail("integrity_error", "Apply requires a concrete artifact version.")
|
|
566
|
+
if (
|
|
567
|
+
attempt["operation"] == "rollback"
|
|
568
|
+
and receipt["artifact_version"] != attempt["restore_artifact_version"]
|
|
569
|
+
):
|
|
570
|
+
v.fail("integrity_error", "Rollback restored the wrong version.")
|
|
571
|
+
candidate = await self._read(
|
|
572
|
+
tx, "candidates", attempt["candidate_id"], required=True
|
|
573
|
+
)
|
|
574
|
+
updated = self._next(candidate)
|
|
575
|
+
if attempt["operation"] == "apply":
|
|
576
|
+
if candidate["status"] != "approved":
|
|
577
|
+
v.fail("conflict", "Candidate approval changed during deployment.")
|
|
578
|
+
updated.update(status="deployed", deployment_receipt=receipt)
|
|
579
|
+
if attempt.get("previous_candidate_id"):
|
|
580
|
+
previous = await self._read(
|
|
581
|
+
tx, "candidates", attempt["previous_candidate_id"], required=True
|
|
582
|
+
)
|
|
583
|
+
if previous["status"] != "deployed":
|
|
584
|
+
v.fail("conflict", "Active predecessor changed.")
|
|
585
|
+
await tx.replace(
|
|
586
|
+
"candidates",
|
|
587
|
+
self._next(previous, status="superseded"),
|
|
588
|
+
previous["revision"],
|
|
589
|
+
)
|
|
590
|
+
updated["predecessor_id"] = previous["id"]
|
|
591
|
+
updated_state["active_candidate_id"] = candidate["id"]
|
|
592
|
+
else:
|
|
593
|
+
if (
|
|
594
|
+
candidate["status"] != "deployed"
|
|
595
|
+
or state.get("active_candidate_id") != candidate["id"]
|
|
596
|
+
):
|
|
597
|
+
v.fail("conflict", "Active candidate changed during rollback.")
|
|
598
|
+
updated["status"] = "rolled_back"
|
|
599
|
+
updated_state.pop("active_candidate_id", None)
|
|
600
|
+
if attempt.get("restore_candidate_id"):
|
|
601
|
+
restore = await self._read(
|
|
602
|
+
tx, "candidates", attempt["restore_candidate_id"], required=True
|
|
603
|
+
)
|
|
604
|
+
if restore["status"] != "superseded":
|
|
605
|
+
v.fail("conflict", "Cannot restore an already rolled-back predecessor.")
|
|
606
|
+
await tx.replace(
|
|
607
|
+
"candidates",
|
|
608
|
+
self._next(restore, status="deployed"),
|
|
609
|
+
restore["revision"],
|
|
610
|
+
)
|
|
611
|
+
updated_state["active_candidate_id"] = restore["id"]
|
|
612
|
+
await tx.replace("candidates", updated, candidate["revision"])
|
|
613
|
+
updated_state["artifact_version"] = receipt["artifact_version"]
|
|
614
|
+
updated_attempt["receipt"] = receipt
|
|
615
|
+
await tx.replace("targets", updated_state, state["revision"])
|
|
616
|
+
await tx.replace("attempts", updated_attempt, attempt["revision"])
|
|
617
|
+
await self._event(
|
|
618
|
+
tx,
|
|
619
|
+
f"deployment.{updated_attempt['status']}",
|
|
620
|
+
attempt["candidate_id"],
|
|
621
|
+
attempt_id=key,
|
|
622
|
+
operation=attempt["operation"],
|
|
623
|
+
)
|
|
624
|
+
return updated_attempt
|
|
625
|
+
|
|
626
|
+
async def analyze(self, **options) -> list[Record]:
|
|
627
|
+
from .analysis import analyze
|
|
628
|
+
|
|
629
|
+
return await analyze(self, **options)
|
|
630
|
+
|
|
631
|
+
async def delete_namespace(self, *, confirmation: str) -> None:
|
|
632
|
+
if confirmation != self.namespace:
|
|
633
|
+
v.fail("invalid_input", "Confirm namespace exactly before deletion.")
|
|
634
|
+
await self.store.delete_namespace(self.namespace)
|
|
635
|
+
|
|
636
|
+
async def close(self) -> None:
|
|
637
|
+
await self.store.close()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Python contract v1 only. Review and apply explicitly; never run on construction.
|
|
2
|
+
-- Separate from Node's feloop_records: not a cross-language storage protocol.
|
|
3
|
+
BEGIN;
|
|
4
|
+
SELECT pg_advisory_xact_lock(hashtextextended('loopiter/python/schema/v1', 0));
|
|
5
|
+
CREATE TABLE IF NOT EXISTS loopiter_python_schema_migrations (
|
|
6
|
+
version integer PRIMARY KEY,
|
|
7
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
8
|
+
);
|
|
9
|
+
CREATE TABLE IF NOT EXISTS loopiter_python_records (
|
|
10
|
+
namespace text COLLATE "C" NOT NULL,
|
|
11
|
+
collection text NOT NULL CHECK (collection IN ('executions', 'signals', 'candidates', 'targets', 'attempts', 'events')),
|
|
12
|
+
id text COLLATE "C" NOT NULL,
|
|
13
|
+
revision bigint NOT NULL CHECK (revision > 0),
|
|
14
|
+
event_time timestamptz NOT NULL,
|
|
15
|
+
body jsonb NOT NULL,
|
|
16
|
+
PRIMARY KEY (namespace, collection, id),
|
|
17
|
+
CHECK (jsonb_typeof(body) = 'object'),
|
|
18
|
+
CHECK (body->>'namespace' = namespace),
|
|
19
|
+
CHECK (body->>'id' = id),
|
|
20
|
+
CHECK ((body->>'revision')::bigint = revision)
|
|
21
|
+
);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS loopiter_python_records_time
|
|
23
|
+
ON loopiter_python_records (namespace, collection, event_time);
|
|
24
|
+
INSERT INTO loopiter_python_schema_migrations(version) VALUES (1) ON CONFLICT DO NOTHING;
|
|
25
|
+
COMMIT;
|