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