"""Verify/repair saved cards and finish ingestion without regenerating them. The outbox records retryable publication, not proof about a future backend. Provider output is deliberately excluded from errors and queue receipts. """ from __future__ import annotations from contextlib import contextmanager import hashlib import json import os from pathlib import Path import re import sqlite3 import subprocess import time import xbrain_recall as backend import xkb_index import xkb_paths class PublicationError(RuntimeError): def __init__(self, stage: str, *, unavailable: bool = False): self.stage = stage self.unavailable = unavailable scope = "backend unavailable" if unavailable else "card pending" super().__init__(f"{scope}: {stage}; retry scripts/gbrain_publish.py") @contextmanager def _db(): path = Path(os.getenv("XKB_PUBLISH_OUTBOX", str(xkb_paths.XKB_DATA_DIR / "publish-outbox.sqlite"))) path.parent.mkdir(parents=True, exist_ok=True) db = sqlite3.connect(path, timeout=30) try: with db: db.execute("CREATE TABLE IF NOT EXISTS outbox (slug TEXT PRIMARY KEY, path TEXT NOT NULL, sha256 TEXT NOT NULL, status TEXT NOT NULL, stage TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, updated REAL NOT NULL)") yield db finally: db.close() def transport_content(text: str) -> str: # JavaScript YAML must not round identity integers larger than 2**53. parts = text.split("---", 2) if len(parts) == 3 and not parts[0].strip(): parts[1] = re.sub(r"(?m)^(id|tweet_id|post_id):[ \t]*([0-9]+)[ \t]*$", lambda m: m[1] + ": " + json.dumps(m[2]), parts[1]) return "---".join(parts) return text def storage_slug(slug: str) -> str: # GBrain lowercases slugs; preserve case-sensitive source identities. return "xkb-case-" + slug.encode("utf-8").hex() if slug != slug.lower() else slug def _runtime(): settings = backend._safe_runtime_env() root = backend._resolve_gbrain_dir(settings) env = backend._make_subprocess_env(True, settings) if root is None: raise PublicationError("runtime", unavailable=True) return root, {**env, "GBRAIN_DIR": str(root)} def _run(command, root, env, content=None): try: return subprocess.run(command, input=content, capture_output=True, text=True, encoding="utf-8", env=env, cwd=str(root), timeout=120) except (OSError, subprocess.TimeoutExpired): raise PublicationError("transport", unavailable=True) from None def _verify(root, env, slug=None, content=None) -> bool: args = ["--probe"] if slug is None else ["--stdin", slug] result = _run([backend.BUN, "run", str(Path(__file__).with_name("gbrain_verify.ts")), *args], root, env, content) try: state = json.loads(result.stdout)["status"] except (ValueError, TypeError, KeyError): raise PublicationError("verify protocol", unavailable=True) from None if result.returncode == 0 and state == ("ready" if slug is None else "verified"): return True if slug is not None and result.returncode == 1 and state == "mismatch": return False raise PublicationError("verify", unavailable=state != "invalid_card") def check_backend() -> None: """Read-only preflight before spending on new card generation.""" root, env = _runtime() if not env.get("GEMINI_API_KEY"): raise PublicationError("embedding credential", unavailable=True) _verify(root, env) def publish(card_path: Path, slug: str) -> str: """Return verified (no writes) or published (repaired), or raise safely.""" card_path = Path(card_path).resolve() try: content = card_path.read_text(encoding="utf-8") except (OSError, UnicodeError): # Rotate unreadable receipts so one missing file cannot starve recovery. try: with _db() as db: db.execute("INSERT INTO outbox VALUES (?,?,?,'pending','read saved card',1,?) ON CONFLICT(slug) DO UPDATE SET path=excluded.path,status='pending',stage='read saved card',attempts=attempts+1,updated=excluded.updated", (slug, str(card_path), "", time.time())) except (OSError, sqlite3.Error): raise PublicationError("outbox", unavailable=True) from None raise PublicationError("read saved card") from None digest = hashlib.sha256(content.encode("utf-8")).hexdigest() stage = "outbox" try: with _db() as db: db.execute("INSERT INTO outbox VALUES (?,?,?,?,?,0,?) ON CONFLICT(slug) DO UPDATE SET path=excluded.path,sha256=excluded.sha256,status=excluded.status,stage=excluded.stage,updated=excluded.updated", (slug, str(card_path), digest, "pending", stage, time.time())) stage = "verify" root, env = _runtime() key = storage_slug(slug) transported = transport_content(content) outcome = "verified" if not _verify(root, env, key, transported): if not env.get("GEMINI_API_KEY"): raise PublicationError("embedding credential", unavailable=True) for stage in ("put", "embed"): result = _run([backend.BUN, "run", str(root / "src/cli.ts"), stage, key, "--source", "default"], root, env, transported if stage == "put" else None) if result.returncode: # Only explicit input errors are item-scoped. Unknown outages # must stop paid generation, and raw output stays private. output = (result.stderr or "") + (result.stdout or "") item_error = bool(re.search(r"(?i)(invalid slug|invalid yaml|input too long|request body too large|status(?: code)?[:= ]+(?:400|413|422)\b)", output)) raise PublicationError(stage, unavailable=not item_error) stage = "verify" if not _verify(root, env, key, transported): raise PublicationError(stage) outcome = "published" stage = "card changed" if card_path.read_text(encoding="utf-8") != content: raise PublicationError(stage) with _db() as db: changed = db.execute("UPDATE outbox SET status='verified',stage='verified',attempts=attempts+1,updated=? WHERE slug=? AND sha256=?", (time.time(), slug, digest)).rowcount if not changed: raise PublicationError(stage) return outcome except (PublicationError, OSError, UnicodeError, sqlite3.Error) as exc: error = exc if isinstance(exc, PublicationError) else PublicationError(stage, unavailable=stage == "outbox" or isinstance(exc, sqlite3.Error)) try: with _db() as db: db.execute("UPDATE outbox SET status='pending',stage=?,attempts=attempts+1,updated=? WHERE slug=? AND sha256=?", (error.stage, time.time(), slug, digest)) except (OSError, sqlite3.Error): error = PublicationError("outbox", unavailable=True) raise error from None class PublicationBatch: """One ingestion lifecycle: budget, saved-card recovery and derived index.""" def __init__(self, publisher=None, *, recovery_limit=20): self.publisher = publisher or publish self.recovery_limit = recovery_limit self.generated = self.recovered = self.verified = self.failed = 0 self.revisited = self.deferred = self.saved = 0 self.blocked = False self._seen = set() def ready(self) -> bool: try: check_backend() except PublicationError as exc: self.blocked = True print(str(exc)) return not self.blocked def publish(self, path, slug, *, generated=False) -> bool: if self.blocked: return False self._seen.add(slug) self.saved += int(Path(path).is_file()) self.generated += int(generated) try: outcome = self.publisher(path, slug) if outcome is False: raise PublicationError("publisher", unavailable=True) if not generated: if outcome == "verified": self.verified += 1 else: self.recovered += 1 return True except PublicationError as exc: self.failed += 1 self.blocked = exc.unavailable print(str(exc)) return False def existing(self, path, slug) -> bool: """Existing cards never consume the new-generation budget.""" if not Path(path).is_file(): return False if slug not in self._seen and not self.blocked: self.revisited += 1 self.publish(path, slug) return True def recover_saved(self, cards) -> None: """Rotate background verification; also discover files without receipts.""" with _db() as db: receipts = db.execute("SELECT path,slug,updated,status FROM outbox").fetchall() updated = {slug: attempted for _, slug, attempted, _ in receipts} # Pending work and newly discovered files share one fair retry budget. paths = {slug: Path(path) for path, slug, _, status in receipts if status == "pending"} paths.update({slug: Path(path) for path, slug in cards if Path(path).is_file()}) candidates = sorted(((path, slug) for slug, path in paths.items() if slug not in self._seen), key=lambda item: (updated.get(item[1], 0), item[1])) available = max(0, self.recovery_limit - self.revisited) self.deferred += max(0, len(candidates) - available) for path, slug in candidates[:available]: if self.blocked: break self.revisited += 1 self.publish(path, slug) def finish(self, indexer=None) -> bool: # Saved evidence remains searchable locally even if publication failed. indexed = (indexer or xkb_index.finish_ingest)(self.saved) if self.saved else True if not indexed: # Index completion is part of recovery, not an ephemeral exit code. with _db() as db: db.executemany("UPDATE outbox SET status='pending',stage='index',updated=? WHERE slug=?", [(time.time(), slug) for slug in self._seen]) print(json.dumps({key: getattr(self, key) for key in ("generated", "recovered", "verified", "failed", "deferred", "blocked")})) return bool(indexed and not self.failed and not self.blocked) def retry_pending(limit=20, *, batch=None): batch = batch or PublicationBatch() with _db() as db: rows = db.execute("SELECT path,slug FROM outbox WHERE status='pending' ORDER BY updated LIMIT ?", (max(0, limit),)).fetchall() attempted, before = 0, batch.failed for path, slug in rows: if batch.blocked: break if slug in batch._seen: continue attempted += 1 batch.revisited += 1 batch.publish(Path(path), slug) return {"attempted": attempted, "failed": batch.failed - before} if __name__ == "__main__": import xkb_usage xkb_usage.record(__file__) import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--limit", type=int, default=20) args = parser.parse_args() batch = PublicationBatch() print(json.dumps(retry_pending(args.limit, batch=batch))) raise SystemExit(0 if batch.finish() else 1)