from __future__ import annotations import json import os import sqlite3 from collections import Counter from collections.abc import Iterator from contextlib import contextmanager from datetime import UTC, datetime, timedelta from hashlib import sha256 from pathlib import Path from uuid import uuid4 from .models import ( ArchitectureGraph, RemediationSignal, Repository, RepositoryDetail, RepositoryPolicy, RepositoryState, RepositorySummary, RepositorySyncResult, ScanComparison, ScanJob, ScanJobStatus, ScanReport, ScanSummary, ScanTrigger, Severity, SourceOrigin, TrendPoint, finding_fingerprint, ) from .rules import enrich_findings MAX_HISTORY_PER_REPOSITORY = 200 SCHEMA_VERSION = 4 JOB_LEASE_SECONDS = 900 ACTIVE_JOB_STATUSES = ( ScanJobStatus.QUEUED.value, ScanJobStatus.ACQUIRING_SOURCE.value, ScanJobStatus.SCANNING.value, ScanJobStatus.ANALYZING.value, ScanJobStatus.PERSISTING.value, ) class QueueFullError(RuntimeError): pass class MaintenanceModeError(RuntimeError): """Raised when a new scan is requested while durable maintenance is active.""" def _now() -> datetime: return datetime.now(UTC) def _iso(value: datetime | None) -> str | None: return value.isoformat() if value else None def _datetime(value: str | None) -> datetime | None: if not value: return None try: parsed = datetime.fromisoformat(value) except ValueError: return None return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed def local_repository_id(path: str) -> str: resolved = str(Path(path).expanduser().resolve()) return f"local:{sha256(resolved.encode('utf-8')).hexdigest()[:24]}" def _bounded_setting(name: str, default: int, *, minimum: int, maximum: int) -> int: try: value = int(os.getenv(name, str(default))) except ValueError: value = default return min(maximum, max(minimum, value)) def history_per_repository() -> int: return _bounded_setting("REPOPULSE_HISTORY_PER_REPOSITORY", MAX_HISTORY_PER_REPOSITORY, minimum=1, maximum=2_000) def full_reports_per_repository() -> int: return _bounded_setting("REPOPULSE_FULL_REPORTS_PER_REPOSITORY", 30, minimum=1, maximum=500) def report_retention_days() -> int: return _bounded_setting("REPOPULSE_REPORT_RETENTION_DAYS", 365, minimum=1, maximum=3_650) def job_lease_seconds() -> int: return _bounded_setting("REPOPULSE_JOB_LEASE_SECONDS", JOB_LEASE_SECONDS, minimum=30, maximum=3_600) class ScanStore: """SQLite-backed catalog, durable scan history and recoverable job queue.""" def __init__(self, db_path: str | None = None) -> None: raw_path = db_path or os.getenv("REPOPULSE_DB", "data/repopulse.db") self.path = Path(raw_path) self.path.parent.mkdir(parents=True, exist_ok=True) self.recovery_metadata: dict[str, str] | None = None try: self._validate_existing_database() self._init_schema() except sqlite3.DatabaseError as exc: if not self._is_corruption_error(exc): raise backup = self._quarantine_corrupt_database() self._init_schema() self.recovery_metadata = { "status": "degraded_recovered", "detected_at": _now().isoformat(), "quarantined_database": backup.name if backup else "unavailable", "operator_action": "Verify the quarantined database, restore it if needed, then acknowledge recovery.", } self.set_state("database_recovery", self.recovery_metadata) @staticmethod def _is_corruption_error(error: sqlite3.DatabaseError) -> bool: message = str(error).casefold() return "not a database" in message or "malformed" in message or "corrupt" in message def _quarantine_corrupt_database(self) -> Path | None: if not self.path.exists(): return None stamp = _now().strftime("%Y%m%dT%H%M%S%fZ") backup = self.path.with_name(f"{self.path.name}.corrupt-{stamp}-{uuid4().hex[:8]}") self.path.replace(backup) for suffix in ("-wal", "-shm"): sidecar = Path(f"{self.path}{suffix}") if sidecar.exists(): sidecar.replace(Path(f"{backup}{suffix}")) return backup def _validate_existing_database(self) -> None: if not self.path.exists() or self.path.stat().st_size == 0: return connection = sqlite3.connect(self.path, timeout=5.0) try: row = connection.execute("PRAGMA quick_check").fetchone() if row is None or str(row[0]).casefold() != "ok": raise sqlite3.DatabaseError("database quick_check reported corruption") finally: connection.close() def _backup_before_migration(self, db: sqlite3.Connection, version: int) -> Path: stamp = _now().strftime("%Y%m%dT%H%M%S%fZ") backup = self.path.with_name(f"{self.path.name}.pre-v{version}-{stamp}.bak") destination = sqlite3.connect(backup) try: db.backup(destination) finally: destination.close() return backup def _connect(self) -> sqlite3.Connection: connection = sqlite3.connect(self.path, timeout=5.0) connection.row_factory = sqlite3.Row connection.execute("PRAGMA busy_timeout = 5000") return connection @contextmanager def _connection(self) -> Iterator[sqlite3.Connection]: connection = self._connect() try: yield connection connection.commit() except BaseException: connection.rollback() raise finally: connection.close() @staticmethod def _columns(db: sqlite3.Connection, table: str) -> set[str]: return {str(row[1]) for row in db.execute(f"PRAGMA table_info({table})").fetchall()} def _init_schema(self) -> None: with self._connection() as db: current_version = int(db.execute("PRAGMA user_version").fetchone()[0]) migration_backup: Path | None = None if current_version > SCHEMA_VERSION: raise RuntimeError( f"Database schema version {current_version} is newer than supported version {SCHEMA_VERSION}" ) if current_version and current_version < SCHEMA_VERSION: migration_backup = self._backup_before_migration(db, current_version) db.execute("PRAGMA journal_mode = WAL") db.execute("PRAGMA synchronous = NORMAL") db.execute( """ CREATE TABLE IF NOT EXISTS scans ( id TEXT PRIMARY KEY, repository_path TEXT NOT NULL, repository_name TEXT NOT NULL, scanned_at TEXT NOT NULL, health_score INTEGER NOT NULL, report_json TEXT NOT NULL ) """ ) scan_columns = self._columns(db, "scans") additions = { "repository_id": "TEXT", "history_key": "TEXT", "source_origin": "TEXT", "provider": "TEXT", "branch": "TEXT", "requested_revision": "TEXT", "actual_revision": "TEXT", "trigger": "TEXT", "finding_count": "INTEGER", "blocker_count": "INTEGER", "warning_count": "INTEGER", "release_readiness": "TEXT", "primary_signal_json": "TEXT", "added_findings": "INTEGER", "resolved_findings": "INTEGER", "triage_priority": "INTEGER", "triage_rationale": "TEXT", "report_compacted": "INTEGER NOT NULL DEFAULT 0", } for name, definition in additions.items(): if name not in scan_columns: db.execute(f"ALTER TABLE scans ADD COLUMN {name} {definition}") db.execute( """ CREATE TABLE IF NOT EXISTS repositories ( id TEXT PRIMARY KEY, provider TEXT NOT NULL, provider_repository_id TEXT NOT NULL, owner TEXT NOT NULL, name TEXT NOT NULL, full_name TEXT NOT NULL, description TEXT, html_url TEXT, clone_url TEXT, default_branch TEXT NOT NULL DEFAULT 'main', private INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, latest_revision TEXT, latest_revision_at TEXT, last_synced_at TEXT, sync_error TEXT, policy_json TEXT NOT NULL DEFAULT '{}', source_path TEXT, UNIQUE(provider, provider_repository_id) ) """ ) repository_columns = self._columns(db, "repositories") repository_additions = { "description": "TEXT", "available": "INTEGER NOT NULL DEFAULT 1", } for name, definition in repository_additions.items(): if name not in repository_columns: db.execute(f"ALTER TABLE repositories ADD COLUMN {name} {definition}") db.execute( """ CREATE TABLE IF NOT EXISTS scan_jobs ( id TEXT PRIMARY KEY, repository_id TEXT NOT NULL, status TEXT NOT NULL, trigger TEXT NOT NULL, requested_revision TEXT, actual_revision TEXT, phase TEXT NOT NULL, progress INTEGER NOT NULL DEFAULT 0, message TEXT, created_at TEXT NOT NULL, started_at TEXT, updated_at TEXT NOT NULL, finished_at TEXT, duration_ms REAL, scan_id TEXT, error_code TEXT, error_detail TEXT, attempt INTEGER NOT NULL DEFAULT 0, lease_until TEXT, deduplicated INTEGER NOT NULL DEFAULT 0 ) """ ) job_columns = self._columns(db, "scan_jobs") if "duration_ms" not in job_columns: db.execute("ALTER TABLE scan_jobs ADD COLUMN duration_ms REAL") db.execute( """ CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ) """ ) if migration_backup: db.execute( """ INSERT INTO app_state (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at """, ( "last_schema_migration_backup", json.dumps( { "from_version": current_version, "to_version": SCHEMA_VERSION, "backup_file": migration_backup.name, "created_at": _now().isoformat(), }, separators=(",", ":"), ), _now().isoformat(), ), ) db.execute( "CREATE INDEX IF NOT EXISTS idx_scans_repository_time ON scans(repository_path, scanned_at DESC)" ) db.execute( "CREATE INDEX IF NOT EXISTS idx_scans_repository_id_time ON scans(repository_id, scanned_at DESC)" ) db.execute( "CREATE INDEX IF NOT EXISTS idx_scans_history_time ON scans(history_key, scanned_at DESC)" ) db.execute( "CREATE INDEX IF NOT EXISTS idx_scans_repository_revision ON scans(repository_id, actual_revision)" ) db.execute( "CREATE INDEX IF NOT EXISTS idx_jobs_queue ON scan_jobs(status, created_at)" ) db.execute( "CREATE INDEX IF NOT EXISTS idx_jobs_repository ON scan_jobs(repository_id, created_at DESC)" ) self._backfill_legacy_catalog(db) self._backfill_scan_summaries(db) db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") def _backfill_legacy_catalog(self, db: sqlite3.Connection) -> None: rows = db.execute( """ SELECT id, repository_path, repository_name, report_json, repository_id, history_key FROM scans WHERE repository_id IS NULL OR history_key IS NULL """ ).fetchall() for row in rows: repository_id = row["repository_id"] or local_repository_id(row["repository_path"]) history_key = row["history_key"] or repository_id self._upsert_local_repository_row( db, repository_id, row["repository_name"], row["repository_path"], ) db.execute( """ UPDATE scans SET repository_id = ?, history_key = ?, source_origin = COALESCE(source_origin, 'local'), provider = COALESCE(provider, 'local'), trigger = COALESCE(trigger, 'local') WHERE id = ? """, (repository_id, history_key, row["id"]), ) @staticmethod def _summary_fields(report: ScanReport, previous: ScanReport | None = None) -> dict[str, object]: report = report.model_copy(update={"findings": enrich_findings(report.findings)}) active = [item for item in report.findings if not item.suppressed] severity_rank = {Severity.BLOCKER: 0, Severity.WARNING: 1, Severity.INFO: 2} primary = min( active, key=lambda item: (severity_rank[item.severity], -item.deduction, item.category, item.code), default=None, ) primary_signal = ( RemediationSignal( code=primary.code, category=primary.category, severity=primary.severity, message=primary.message, count=sum(1 for item in active if item.code == primary.code), guidance=primary.guidance, ) if primary else None ) previous_active = [item for item in (previous.findings if previous else []) if not item.suppressed] active_keys = {finding_fingerprint(item) for item in active} previous_keys = {finding_fingerprint(item) for item in previous_active} guidance = primary_signal.guidance if primary_signal else None return { "finding_count": len(active), "blocker_count": sum(1 for item in active if item.severity == Severity.BLOCKER), "warning_count": sum(1 for item in active if item.severity == Severity.WARNING), "release_readiness": report.release_gate.status if report.release_gate else report.release_readiness, "primary_signal_json": primary_signal.model_dump_json() if primary_signal else None, "added_findings": len(active_keys - previous_keys) if previous else 0, "resolved_findings": len(previous_keys - active_keys) if previous else 0, "triage_priority": guidance.priority if guidance else 0, "triage_rationale": guidance.priority_rationale if guidance else None, } def _backfill_scan_summaries(self, db: sqlite3.Connection) -> None: rows = db.execute( "SELECT rowid AS scan_rowid, * FROM scans " "WHERE finding_count IS NULL OR blocker_count IS NULL OR release_readiness IS NULL" ).fetchall() for row in rows: report = self._report_from_row(row) previous_row = db.execute( """ SELECT * FROM scans WHERE history_key = ? AND ( scanned_at < ? OR (scanned_at = ? AND rowid < ?) ) ORDER BY scanned_at DESC, rowid DESC LIMIT 1 """, (row["history_key"], row["scanned_at"], row["scanned_at"], row["scan_rowid"]), ).fetchone() previous = self._report_from_row(previous_row) if previous_row else None fields = self._summary_fields(report, previous) db.execute( """ UPDATE scans SET finding_count = ?, blocker_count = ?, warning_count = ?, release_readiness = ?, primary_signal_json = ?, added_findings = ?, resolved_findings = ?, triage_priority = ?, triage_rationale = ? WHERE id = ? """, (*fields.values(), row["id"]), ) @staticmethod def _upsert_local_repository_row( db: sqlite3.Connection, repository_id: str, name: str, source_path: str, ) -> None: db.execute( """ INSERT INTO repositories ( id, provider, provider_repository_id, owner, name, full_name, default_branch, source_path ) VALUES (?, 'local', ?, 'local', ?, ?, 'main', ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, full_name = excluded.full_name, source_path = COALESCE(excluded.source_path, repositories.source_path) """, (repository_id, repository_id.removeprefix("local:"), name, f"local/{name}", source_path), ) @staticmethod def _upsert_repository_row( db: sqlite3.Connection, repository: Repository, source_path: str | None = None, ) -> None: db.execute( """ INSERT INTO repositories ( id, provider, provider_repository_id, owner, name, full_name, description, html_url, clone_url, default_branch, private, archived, available, latest_revision, latest_revision_at, last_synced_at, sync_error, policy_json, source_path ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, provider_repository_id = excluded.provider_repository_id, owner = excluded.owner, name = excluded.name, full_name = excluded.full_name, description = excluded.description, html_url = excluded.html_url, clone_url = excluded.clone_url, default_branch = excluded.default_branch, private = excluded.private, archived = excluded.archived, available = excluded.available, latest_revision = COALESCE(excluded.latest_revision, repositories.latest_revision), latest_revision_at = COALESCE(excluded.latest_revision_at, repositories.latest_revision_at), last_synced_at = COALESCE(excluded.last_synced_at, repositories.last_synced_at), sync_error = excluded.sync_error, policy_json = excluded.policy_json, source_path = COALESCE(excluded.source_path, repositories.source_path) """, ( repository.id, repository.provider, repository.provider_repository_id, repository.owner, repository.name, repository.full_name, repository.description, repository.html_url, repository.clone_url, repository.default_branch, int(repository.private), int(repository.archived), int(repository.available), repository.latest_revision, _iso(repository.latest_revision_at), _iso(repository.last_synced_at), repository.sync_error, repository.policy.model_dump_json(), source_path, ), ) def upsert_repository(self, repository: Repository, *, source_path: str | None = None) -> Repository: with self._connection() as db: self._upsert_repository_row(db, repository, source_path) row = db.execute("SELECT * FROM repositories WHERE id = ?", (repository.id,)).fetchone() return self._repository_from_row(row) if row else repository @staticmethod def _repository_from_row(row: sqlite3.Row) -> Repository: try: policy = RepositoryPolicy.model_validate_json(row["policy_json"] or "{}") except (TypeError, ValueError): policy = RepositoryPolicy() return Repository( id=row["id"], provider=row["provider"], provider_repository_id=row["provider_repository_id"], owner=row["owner"], name=row["name"], full_name=row["full_name"], description=row["description"], html_url=row["html_url"], clone_url=row["clone_url"], default_branch=row["default_branch"] or "main", private=bool(row["private"]), archived=bool(row["archived"]), available=bool(row["available"]), latest_revision=row["latest_revision"], latest_revision_at=_datetime(row["latest_revision_at"]), last_synced_at=_datetime(row["last_synced_at"]), sync_error=row["sync_error"], policy=policy, ) def get_repository(self, repository_id: str) -> Repository | None: with self._connection() as db: row = db.execute("SELECT * FROM repositories WHERE id = ?", (repository_id,)).fetchone() return self._repository_from_row(row) if row else None def get_repository_source_path(self, repository_id: str) -> str | None: with self._connection() as db: row = db.execute("SELECT source_path FROM repositories WHERE id = ?", (repository_id,)).fetchone() return row["source_path"] if row else None def _repository_summary(self, db: sqlite3.Connection, row: sqlite3.Row) -> RepositorySummary: repository = self._repository_from_row(row) latest = db.execute( """ SELECT * FROM scans WHERE repository_id = ? ORDER BY scanned_at DESC, rowid DESC LIMIT 1 """, (repository.id,), ).fetchone() active = db.execute( """ SELECT * FROM scan_jobs WHERE repository_id = ? AND status IN (?, ?, ?, ?, ?) ORDER BY created_at DESC LIMIT 1 """, (repository.id, *ACTIVE_JOB_STATUSES), ).fetchone() last_job = db.execute( "SELECT * FROM scan_jobs WHERE repository_id = ? ORDER BY created_at DESC LIMIT 1", (repository.id,), ).fetchone() last_scan = self._report_from_row(latest) if latest else None active_findings = [item for item in (last_scan.findings if last_scan else []) if not item.suppressed] severity_rank = {Severity.BLOCKER: 0, Severity.WARNING: 1, Severity.INFO: 2} primary_finding = min( active_findings, key=lambda item: (severity_rank[item.severity], -item.deduction, item.category, item.code), default=None, ) primary_signal = ( RemediationSignal( code=primary_finding.code, category=primary_finding.category, severity=primary_finding.severity, message=primary_finding.message, count=sum(1 for item in active_findings if item.code == primary_finding.code), ) if primary_finding else None ) previous_row = ( db.execute( """ SELECT * FROM scans WHERE repository_id = ? AND id != ? ORDER BY scanned_at DESC, rowid DESC LIMIT 1 """, (repository.id, last_scan.id), ).fetchone() if last_scan else None ) previous_scan = self._report_from_row(previous_row) if previous_row else None previous_findings = [item for item in (previous_scan.findings if previous_scan else []) if not item.suppressed] active_fingerprints = {self._finding_key(item) for item in active_findings} previous_fingerprints = {self._finding_key(item) for item in previous_findings} last_revision = ( last_scan.source.actual_revision if last_scan and last_scan.source.actual_revision else (latest["actual_revision"] if latest else None) ) stale = bool(repository.latest_revision and last_revision and repository.latest_revision != last_revision) if repository.archived: state = RepositoryState.ARCHIVED elif not repository.available: state = RepositoryState.UNAVAILABLE elif active: state = ( RepositoryState.QUEUED if active["status"] == ScanJobStatus.QUEUED.value else RepositoryState.SCANNING ) elif last_job and last_job["status"] == ScanJobStatus.FAILED.value: last_job_at = _datetime(last_job["created_at"]) if last_scan is None or (last_job_at and last_job_at > last_scan.scanned_at): state = RepositoryState.FAILED elif stale: state = RepositoryState.STALE else: state = RepositoryState.CURRENT elif last_scan is None: state = RepositoryState.NEVER_SCANNED elif stale: state = RepositoryState.STALE else: state = RepositoryState.CURRENT data = repository.model_dump() data.update( { "state": state, "stale": stale, "active_job_id": active["id"] if active else None, "last_scan_id": last_scan.id if last_scan else None, "last_scan_at": last_scan.scanned_at if last_scan else None, "last_scan_health": last_scan.health_score if last_scan else None, "last_scan_readiness": last_scan.release_gate.status if last_scan and last_scan.release_gate else (last_scan.release_readiness if last_scan else None), "last_scan_revision": last_revision, "last_scan_blockers": sum(1 for item in active_findings if item.severity == Severity.BLOCKER), "last_scan_warnings": sum(1 for item in active_findings if item.severity == Severity.WARNING), "last_scan_primary_signal": primary_signal, "last_scan_health_delta": (last_scan.health_score - previous_scan.health_score) if previous_scan and last_scan else None, "last_scan_added_findings": len(active_fingerprints - previous_fingerprints) if previous_scan else 0, "last_scan_resolved_findings": len(previous_fingerprints - active_fingerprints) if previous_scan else 0, } ) return RepositorySummary(**data) def _catalog_rows( self, db: sqlite3.Connection, *, limit: int, offset: int = 0, repository_id: str | None = None ) -> list[sqlite3.Row]: where = "WHERE r.id = ?" if repository_id else "" params: tuple[object, ...] = (repository_id, limit, offset) if repository_id else (limit, offset) return db.execute( f""" WITH ranked_scans AS ( SELECT s.*, ROW_NUMBER() OVER ( PARTITION BY s.repository_id ORDER BY s.scanned_at DESC, s.rowid DESC ) AS scan_rank FROM scans AS s WHERE s.repository_id IS NOT NULL ), active_jobs AS ( SELECT j.id, j.repository_id, j.status, ROW_NUMBER() OVER ( PARTITION BY j.repository_id ORDER BY j.created_at DESC, j.rowid DESC ) AS job_rank FROM scan_jobs AS j WHERE j.status IN ('queued', 'acquiring_source', 'scanning', 'analyzing', 'persisting') ), last_jobs AS ( SELECT j.repository_id, j.status, j.created_at, ROW_NUMBER() OVER ( PARTITION BY j.repository_id ORDER BY j.created_at DESC, j.rowid DESC ) AS job_rank FROM scan_jobs AS j ) SELECT r.*, latest.id AS summary_scan_id, latest.scanned_at AS summary_scanned_at, latest.health_score AS summary_health, latest.release_readiness AS summary_readiness, latest.actual_revision AS summary_revision, latest.finding_count AS summary_findings, latest.blocker_count AS summary_blockers, latest.warning_count AS summary_warnings, latest.primary_signal_json AS summary_primary_signal, latest.added_findings AS summary_added, latest.resolved_findings AS summary_resolved, latest.triage_priority AS summary_priority, latest.triage_rationale AS summary_rationale, previous.health_score AS previous_health, active.id AS active_job_id, active.status AS active_job_status, last_job.status AS last_job_status, last_job.created_at AS last_job_created_at FROM repositories AS r LEFT JOIN ranked_scans AS latest ON latest.repository_id = r.id AND latest.scan_rank = 1 LEFT JOIN ranked_scans AS previous ON previous.repository_id = r.id AND previous.scan_rank = 2 LEFT JOIN active_jobs AS active ON active.repository_id = r.id AND active.job_rank = 1 LEFT JOIN last_jobs AS last_job ON last_job.repository_id = r.id AND last_job.job_rank = 1 {where} ORDER BY r.archived ASC, r.full_name COLLATE NOCASE ASC LIMIT ? OFFSET ? """, params, ).fetchall() def _compact_repository_summary(self, row: sqlite3.Row) -> RepositorySummary: repository = self._repository_from_row(row) primary_signal = None if row["summary_primary_signal"]: try: primary_signal = RemediationSignal.model_validate_json(row["summary_primary_signal"]) except (TypeError, ValueError): primary_signal = None last_revision = row["summary_revision"] stale = bool(repository.latest_revision and last_revision and repository.latest_revision != last_revision) if repository.archived: state = RepositoryState.ARCHIVED elif not repository.available: state = RepositoryState.UNAVAILABLE elif row["active_job_id"]: state = RepositoryState.QUEUED if row["active_job_status"] == ScanJobStatus.QUEUED.value else RepositoryState.SCANNING elif row["last_job_status"] == ScanJobStatus.FAILED.value: last_job_at = _datetime(row["last_job_created_at"]) last_scan_at = _datetime(row["summary_scanned_at"]) if last_scan_at is None or (last_job_at and last_job_at > last_scan_at): state = RepositoryState.FAILED else: state = RepositoryState.STALE if stale else RepositoryState.CURRENT elif row["summary_scan_id"] is None: state = RepositoryState.NEVER_SCANNED else: state = RepositoryState.STALE if stale else RepositoryState.CURRENT data = repository.model_dump() data.update( { "state": state, "stale": stale, "active_job_id": row["active_job_id"], "last_scan_id": row["summary_scan_id"], "last_scan_at": _datetime(row["summary_scanned_at"]), "last_scan_health": row["summary_health"], "last_scan_readiness": row["summary_readiness"], "last_scan_revision": last_revision, "last_scan_blockers": int(row["summary_blockers"] or 0), "last_scan_warnings": int(row["summary_warnings"] or 0), "last_scan_primary_signal": primary_signal, "last_scan_health_delta": ( int(row["summary_health"]) - int(row["previous_health"]) if row["summary_health"] is not None and row["previous_health"] is not None else None ), "last_scan_added_findings": int(row["summary_added"] or 0), "last_scan_resolved_findings": int(row["summary_resolved"] or 0), "last_scan_priority": int(row["summary_priority"] or 0), "last_scan_priority_rationale": row["summary_rationale"], } ) return RepositorySummary(**data) def list_repositories(self, limit: int = 100, offset: int = 0) -> list[RepositorySummary]: with self._connection() as db: return [self._compact_repository_summary(row) for row in self._catalog_rows(db, limit=limit, offset=offset)] def repository_count(self) -> int: with self._connection() as db: return int(db.execute("SELECT COUNT(*) FROM repositories").fetchone()[0]) def repository_state_counts(self) -> dict[str, int]: counts: Counter[str] = Counter() for repository in self.list_repositories(limit=1000): counts[repository.state.value] += 1 return dict(counts) def get_repository_detail(self, repository_id: str) -> RepositoryDetail | None: with self._connection() as db: rows = self._catalog_rows(db, repository_id=repository_id, limit=1) if not rows: return None summary = self._compact_repository_summary(rows[0]) latest = db.execute( "SELECT * FROM scans WHERE repository_id = ? ORDER BY scanned_at DESC, rowid DESC LIMIT 1", (repository_id,), ).fetchone() return RepositoryDetail(**summary.model_dump(), latest_scan=self._report_from_row(latest) if latest else None) def repository_scans(self, repository_id: str, limit: int = 50) -> list[ScanSummary]: with self._connection() as db: rows = db.execute( "SELECT * FROM scans WHERE repository_id = ? ORDER BY scanned_at DESC, rowid DESC LIMIT ?", (repository_id, limit), ).fetchall() result: list[ScanSummary] = [] for row in rows: report = self._report_from_row(row) result.append( ScanSummary( id=report.id, repository_id=repository_id, scanned_at=report.scanned_at, branch=report.source.branch, requested_revision=report.source.requested_revision, actual_revision=report.source.actual_revision, trigger=report.source.trigger, health_score=report.health_score, release_readiness=report.release_readiness, findings=len(report.findings), duration_ms=report.duration_ms, ) ) return result def _report_from_row(self, row: sqlite3.Row | None) -> ScanReport: if row is None: raise ValueError("Scan row is required") report = ScanReport.model_validate_json(row["report_json"]) source_updates: dict[str, object] = {} if row["repository_id"] and not report.source.repository_id: source_updates["repository_id"] = row["repository_id"] if row["source_origin"] and report.source.origin == SourceOrigin.LOCAL: try: source_updates["origin"] = SourceOrigin(str(row["source_origin"])) except ValueError: pass if row["provider"] and not report.source.provider: source_updates["provider"] = row["provider"] for field in ("branch", "requested_revision", "actual_revision", "trigger"): value = row[field] if value is not None and getattr(report.source, field) in (None, ScanTrigger.MANUAL): if field == "trigger": try: source_updates[field] = ScanTrigger(str(value)) except ValueError: continue else: source_updates[field] = value if source_updates: report = report.model_copy(update={"source": report.source.model_copy(update=source_updates)}) return report.model_copy( update={ "findings": enrich_findings(report.findings), "compacted": bool(row["report_compacted"]) if "report_compacted" in row else report.compacted, } ) def _normalise_report(self, report: ScanReport) -> ScanReport: if report.source.repository_id: return report repository_id = local_repository_id(report.repository_path) source = report.source.model_copy( update={ "origin": SourceOrigin.LOCAL, "provider": "local", "repository_id": repository_id, "repository_full_name": f"local/{report.repository_name}", "trigger": ScanTrigger.LOCAL, } ) return report.model_copy(update={"source": source}) def save(self, report: ScanReport) -> ScanReport: report = self._normalise_report(report) report = report.model_copy(update={"findings": enrich_findings(report.findings)}) source = report.source history_key = source.repository_id or report.repository_path with self._connection() as db: if source.origin == SourceOrigin.LOCAL and source.repository_id: self._upsert_local_repository_row(db, source.repository_id, report.repository_name, report.repository_path) previous_row = db.execute( """ SELECT * FROM scans WHERE history_key = ? ORDER BY scanned_at DESC, rowid DESC LIMIT 1 """, (history_key,), ).fetchone() previous = self._report_from_row(previous_row) if previous_row else None summary = self._summary_fields(report, previous) db.execute( """ INSERT INTO scans ( id, repository_path, repository_name, scanned_at, health_score, report_json, repository_id, history_key, source_origin, provider, branch, requested_revision, actual_revision, trigger, finding_count, blocker_count, warning_count, release_readiness, primary_signal_json, added_findings, resolved_findings, triage_priority, triage_rationale, report_compacted ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( report.id, report.repository_path, report.repository_name, report.scanned_at.isoformat(), report.health_score, report.model_dump_json(), source.repository_id, history_key, source.origin.value, source.provider, source.branch, source.requested_revision, source.actual_revision, source.trigger.value, summary["finding_count"], summary["blocker_count"], summary["warning_count"], summary["release_readiness"], summary["primary_signal_json"], summary["added_findings"], summary["resolved_findings"], summary["triage_priority"], summary["triage_rationale"], 0, ), ) self._apply_retention(db, history_key) if source.repository_id: db.execute( """ UPDATE repositories SET latest_revision = COALESCE(?, latest_revision), latest_revision_at = COALESCE(latest_revision_at, ?), sync_error = NULL WHERE id = ? AND provider = 'local' """, (source.actual_revision, _iso(report.scanned_at), source.repository_id), ) return report def _apply_retention(self, db: sqlite3.Connection, history_key: str) -> None: db.execute( """ DELETE FROM scans WHERE history_key = ? AND rowid NOT IN ( SELECT rowid FROM scans WHERE history_key = ? ORDER BY scanned_at DESC, rowid DESC LIMIT ? ) """, (history_key, history_key, history_per_repository()), ) cutoff = (_now() - timedelta(days=report_retention_days())).isoformat() db.execute("DELETE FROM scans WHERE history_key = ? AND scanned_at < ?", (history_key, cutoff)) rows = db.execute( """ SELECT * FROM scans WHERE history_key = ? AND report_compacted = 0 ORDER BY scanned_at DESC, rowid DESC LIMIT -1 OFFSET ? """, (history_key, full_reports_per_repository()), ).fetchall() for row in rows: report = self._report_from_row(row) compact = report.model_copy( update={"findings": [], "architecture": ArchitectureGraph(), "hotspots": [], "compacted": True} ) db.execute( "UPDATE scans SET report_json = ?, report_compacted = 1 WHERE id = ?", (compact.model_dump_json(), row["id"]), ) def get(self, scan_id: str) -> ScanReport | None: with self._connection() as db: row = db.execute("SELECT * FROM scans WHERE id = ?", (scan_id,)).fetchone() return self._report_from_row(row) if row else None def recent(self, limit: int = 20) -> list[ScanReport]: with self._connection() as db: rows = db.execute( "SELECT * FROM scans ORDER BY scanned_at DESC, rowid DESC LIMIT ?", (limit,), ).fetchall() return [self._report_from_row(row) for row in rows] def _history_candidates(self, repository_key: str) -> list[str]: if repository_key.startswith(("local:", "gitea:")): return [repository_key] return [repository_key, local_repository_id(repository_key)] def recent_for_repository(self, repository_key: str, limit: int = 50) -> list[ScanReport]: with self._connection() as db: for candidate in self._history_candidates(repository_key): rows = db.execute( "SELECT * FROM scans WHERE history_key = ? ORDER BY scanned_at DESC, rowid DESC LIMIT ?", (candidate, limit), ).fetchall() if rows: return [self._report_from_row(row) for row in rows] return [] def previous_for_repository(self, repository_key: str, exclude_id: str) -> ScanReport | None: with self._connection() as db: for candidate in self._history_candidates(repository_key): row = db.execute( """ SELECT previous.* FROM scans AS previous JOIN scans AS current ON current.id = ? WHERE previous.history_key = ? AND previous.id != current.id AND ( previous.scanned_at < current.scanned_at OR (previous.scanned_at = current.scanned_at AND previous.rowid < current.rowid) ) ORDER BY previous.scanned_at DESC, previous.rowid DESC LIMIT 1 """, (exclude_id, candidate), ).fetchone() if row: return self._report_from_row(row) return None @staticmethod def _finding_key(report_finding) -> str: return report_finding.fingerprint or finding_fingerprint(report_finding) def compare(self, report: ScanReport) -> ScanComparison: previous = self.previous_for_repository( report.source.repository_id or report.repository_path, report.id, ) if previous is None: return ScanComparison(current_scan_id=report.id) current_metrics = report.metrics.model_dump() previous_metrics = previous.metrics.model_dump() metric_deltas = { key: int(current_metrics[key]) - int(previous_metrics.get(key, 0)) for key in current_metrics } category_deltas = { category: report.category_scores.get(category, 0) - previous.category_scores.get(category, 0) for category in set(report.category_scores) | set(previous.category_scores) } current_findings = {self._finding_key(item): item for item in report.findings} previous_findings = {self._finding_key(item): item for item in previous.findings} added = [current_findings[key] for key in current_findings.keys() - previous_findings.keys()] resolved = [previous_findings[key] for key in previous_findings.keys() - current_findings.keys()] return ScanComparison( current_scan_id=report.id, previous_scan_id=previous.id, health_score_delta=report.health_score - previous.health_score, category_score_deltas=dict(sorted(category_deltas.items())), metric_deltas=metric_deltas, added_findings=sorted(added, key=lambda item: (item.category, item.path or "", item.line or 0)), resolved_findings=sorted(resolved, key=lambda item: (item.category, item.path or "", item.line or 0)), ) def trend(self, repository_key: str, limit: int = 50) -> list[TrendPoint]: reports = list(reversed(self.recent_for_repository(repository_key, limit))) points: list[TrendPoint] = [] for report in reports: blockers = sum(1 for item in report.findings if item.severity == Severity.BLOCKER) warnings = sum(1 for item in report.findings if item.severity == Severity.WARNING) debt = report.metrics.todo + report.metrics.fixme + report.metrics.hack + report.metrics.xxx points.append( TrendPoint( scan_id=report.id, scanned_at=report.scanned_at, health_score=report.health_score, findings=len(report.findings), blockers=blockers, warnings=warnings, debt_markers=debt, ) ) return points @staticmethod def _job_from_row(row: sqlite3.Row) -> ScanJob: return ScanJob( id=row["id"], repository_id=row["repository_id"], status=row["status"], trigger=row["trigger"], requested_revision=row["requested_revision"], actual_revision=row["actual_revision"], phase=row["phase"], progress=int(row["progress"]), message=row["message"], created_at=_datetime(row["created_at"]) or _now(), started_at=_datetime(row["started_at"]), updated_at=_datetime(row["updated_at"]) or _now(), finished_at=_datetime(row["finished_at"]), duration_ms=float(row["duration_ms"]) if row["duration_ms"] is not None else None, scan_id=row["scan_id"], error_code=row["error_code"], error_detail=row["error_detail"], attempt=int(row["attempt"]), deduplicated=bool(row["deduplicated"]), ) def _recover_expired_jobs(self, db: sqlite3.Connection, now: str) -> None: placeholders = ",".join("?" for _ in ACTIVE_JOB_STATUSES[1:]) db.execute( f""" UPDATE scan_jobs SET status = 'queued', phase = 'queued', progress = 0, message = 'Recovered after worker lease expiry', updated_at = ?, lease_until = NULL WHERE status IN ({placeholders}) AND lease_until IS NOT NULL AND lease_until < ? """, (now, *ACTIVE_JOB_STATUSES[1:], now), ) def enqueue_job( self, repository_id: str, requested_revision: str | None, trigger: ScanTrigger, *, force: bool = False, max_queue: int = 100, ) -> ScanJob: now_dt = _now() now = now_dt.isoformat() with self._connection() as db: db.execute("BEGIN IMMEDIATE") if self._maintenance_enabled(db): raise MaintenanceModeError("RepoPulse is in maintenance mode") self._recover_expired_jobs(db, now) repository = db.execute("SELECT id FROM repositories WHERE id = ?", (repository_id,)).fetchone() if repository is None: raise KeyError(repository_id) active_rows = db.execute( """ SELECT * FROM scan_jobs WHERE repository_id = ? AND status IN (?, ?, ?, ?, ?) ORDER BY created_at DESC """, (repository_id, *ACTIVE_JOB_STATUSES), ).fetchall() if not force: for row in active_rows: if row["requested_revision"] == requested_revision: return self._job_from_row(row).model_copy(update={"deduplicated": True}) if requested_revision and not force: completed = db.execute( """ SELECT * FROM scans WHERE repository_id = ? AND actual_revision = ? ORDER BY scanned_at DESC, rowid DESC LIMIT 1 """, (repository_id, requested_revision), ).fetchone() if completed: existing = db.execute( """ SELECT * FROM scan_jobs WHERE repository_id = ? AND scan_id = ? ORDER BY created_at DESC LIMIT 1 """, (repository_id, completed["id"]), ).fetchone() if existing: return self._job_from_row(existing).model_copy(update={"deduplicated": True}) job_id = str(uuid4()) db.execute( """ INSERT INTO scan_jobs ( id, repository_id, status, trigger, requested_revision, actual_revision, phase, progress, message, created_at, updated_at, finished_at, scan_id, deduplicated ) VALUES (?, ?, 'completed', ?, ?, ?, 'completed', 100, 'Existing revision already analyzed', ?, ?, ?, ?, 1) """, ( job_id, repository_id, trigger.value, requested_revision, completed["actual_revision"], now, now, completed["scanned_at"], completed["id"], ), ) row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() return self._job_from_row(row) queued = db.execute( "SELECT COUNT(*) FROM scan_jobs WHERE status = 'queued'" ).fetchone()[0] if int(queued) >= max(1, max_queue): raise QueueFullError("Scan queue is full; retry after queued work completes") if requested_revision: for row in active_rows: if row["status"] == ScanJobStatus.QUEUED.value and row["requested_revision"] != requested_revision: db.execute( """ UPDATE scan_jobs SET status = 'superseded', phase = 'superseded', progress = 100, message = 'Superseded by a newer repository revision', updated_at = ?, finished_at = ? WHERE id = ? """, (now, now, row["id"]), ) job_id = str(uuid4()) db.execute( """ INSERT INTO scan_jobs ( id, repository_id, status, trigger, requested_revision, phase, progress, message, created_at, updated_at ) VALUES (?, ?, 'queued', ?, ?, 'queued', 0, 'Waiting for an available worker', ?, ?) """, (job_id, repository_id, trigger.value, requested_revision, now, now), ) row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() return self._job_from_row(row) def get_job(self, job_id: str) -> ScanJob | None: with self._connection() as db: row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() return self._job_from_row(row) if row else None def claim_next_job(self) -> ScanJob | None: now_dt = _now() now = now_dt.isoformat() lease_until = (now_dt + timedelta(seconds=job_lease_seconds())).isoformat() with self._connection() as db: db.execute("BEGIN IMMEDIATE") if self._maintenance_enabled(db): return None self._recover_expired_jobs(db, now) row = db.execute( """ SELECT queued.* FROM scan_jobs AS queued WHERE queued.status = 'queued' ORDER BY COALESCE(( SELECT MAX(served.started_at) FROM scan_jobs AS served WHERE served.repository_id = queued.repository_id AND served.started_at IS NOT NULL ), ''), queued.created_at, queued.rowid LIMIT 1 """ ).fetchone() if row is None: return None db.execute( """ UPDATE scan_jobs SET status = 'acquiring_source', phase = 'acquiring_source', progress = 5, message = 'Acquiring exact repository revision', started_at = COALESCE(started_at, ?), updated_at = ?, attempt = attempt + 1, lease_until = ? WHERE id = ? AND status = 'queued' """, (now, now, lease_until, row["id"]), ) claimed = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (row["id"],)).fetchone() return self._job_from_row(claimed) def renew_job_lease(self, job_id: str) -> ScanJob | None: """Renew only an actively owned lease; terminal jobs are never resurrected.""" lease_until = (_now() + timedelta(seconds=job_lease_seconds())).isoformat() placeholders = ",".join("?" for _ in ACTIVE_JOB_STATUSES[1:]) with self._connection() as db: db.execute( f""" UPDATE scan_jobs SET lease_until = ?, updated_at = ? WHERE id = ? AND status IN ({placeholders}) """, (lease_until, _now().isoformat(), job_id, *ACTIVE_JOB_STATUSES[1:]), ) row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() return self._job_from_row(row) if row else None def update_job(self, job_id: str, **updates: object) -> ScanJob | None: allowed = { "status", "actual_revision", "phase", "progress", "message", "started_at", "finished_at", "duration_ms", "scan_id", "error_code", "error_detail", "lease_until", } invalid = set(updates) - allowed if invalid: raise ValueError(f"Unsupported job fields: {sorted(invalid)}") if not updates: return self.get_job(job_id) serialised: dict[str, object] = {} for key, value in updates.items(): if isinstance(value, datetime): serialised[key] = _iso(value) elif isinstance(value, (ScanJobStatus, ScanTrigger)): serialised[key] = value.value else: serialised[key] = value serialised["updated_at"] = _now().isoformat() if serialised.get("status") in ACTIVE_JOB_STATUSES[1:] and "lease_until" not in serialised: serialised["lease_until"] = (_now() + timedelta(seconds=job_lease_seconds())).isoformat() if "status" in serialised and serialised["status"] in { ScanJobStatus.COMPLETED.value, ScanJobStatus.FAILED.value, ScanJobStatus.CANCELLED.value, ScanJobStatus.SUPERSEDED.value, }: with self._connection() as db: existing = db.execute( "SELECT started_at FROM scan_jobs WHERE id = ?", (job_id,) ).fetchone() if "duration_ms" not in serialised and existing and existing["started_at"]: started = _datetime(existing["started_at"]) if started: serialised["duration_ms"] = max(0.0, (_now() - started).total_seconds() * 1000) serialised.setdefault("finished_at", _now().isoformat()) serialised.setdefault("lease_until", None) columns = ", ".join(f"{key} = ?" for key in serialised) with self._connection() as db: db.execute( f"UPDATE scan_jobs SET {columns} WHERE id = ?", (*serialised.values(), job_id), ) row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() return self._job_from_row(row) if row else None @staticmethod def _maintenance_enabled(db: sqlite3.Connection) -> bool: row = db.execute("SELECT value FROM app_state WHERE key = 'maintenance_mode'").fetchone() if row is None: return False try: value = json.loads(row["value"]) except (TypeError, json.JSONDecodeError): # Fail closed if persisted control state cannot be interpreted. return True if isinstance(value, dict) and isinstance(value.get("enabled"), bool): return value["enabled"] return value is not False def maintenance_status(self) -> dict[str, object]: with self._connection() as db: row = db.execute( "SELECT value FROM app_state WHERE key = 'maintenance_mode'" ).fetchone() if row is None: return {"enabled": False, "changed_at": None} try: value = json.loads(row["value"]) except (TypeError, json.JSONDecodeError): return {"enabled": True, "changed_at": None, "state_error": "invalid_persisted_state"} if isinstance(value, dict) and isinstance(value.get("enabled"), bool): return { "enabled": value["enabled"], "changed_at": value.get("changed_at"), } if isinstance(value, bool): return {"enabled": value, "changed_at": None} return {"enabled": True, "changed_at": None, "state_error": "invalid_persisted_state"} def set_maintenance_mode(self, enabled: bool) -> dict[str, object]: changed_at = _now().isoformat() value = json.dumps({"enabled": enabled, "changed_at": changed_at}, separators=(",", ":")) with self._connection() as db: db.execute("BEGIN IMMEDIATE") db.execute( """ INSERT INTO app_state (key, value, updated_at) VALUES ('maintenance_mode', ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at """, (value, changed_at), ) return {"enabled": enabled, "changed_at": changed_at} def touch_worker_heartbeat_if_active(self, heartbeat: str) -> bool: """Persist scheduler liveness only when maintenance is not active.""" with self._connection() as db: db.execute("BEGIN IMMEDIATE") if self._maintenance_enabled(db): return False db.execute( """ INSERT INTO app_state (key, value, updated_at) VALUES ('worker_heartbeat', ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at """, (heartbeat, _now().isoformat()), ) return True def queue_stats(self) -> dict[str, int | str | None]: with self._connection() as db: rows = db.execute("SELECT status, COUNT(*) AS count FROM scan_jobs GROUP BY status").fetchall() counts = {str(row["status"]): int(row["count"]) for row in rows} heartbeat = db.execute("SELECT value FROM app_state WHERE key = 'worker_heartbeat'").fetchone() sync = db.execute("SELECT value FROM app_state WHERE key = 'last_gitea_sync'").fetchone() return { "queued": counts.get(ScanJobStatus.QUEUED.value, 0), "active": sum(counts.get(status, 0) for status in ACTIVE_JOB_STATUSES[1:]), "completed": counts.get(ScanJobStatus.COMPLETED.value, 0), "failed": counts.get(ScanJobStatus.FAILED.value, 0), "worker_heartbeat": heartbeat["value"] if heartbeat else None, "last_gitea_sync": sync["value"] if sync else None, } @staticmethod def _tree_stats(root: Path) -> tuple[int, int]: """Return bounded managed-cache file count and bytes without following links.""" if not root.exists() or root.is_symlink(): return 0, 0 files = 0 total_bytes = 0 pending = [root] while pending and files < 50_000: current = pending.pop() try: entries = list(current.iterdir()) except OSError: continue for entry in entries: if entry.is_symlink(): continue try: if entry.is_dir(): pending.append(entry) elif entry.is_file(): files += 1 total_bytes += entry.stat().st_size except OSError: continue if files >= 50_000: break return files, total_bytes @staticmethod def _managed_entry_count(root: Path) -> int: try: return sum(1 for entry in root.iterdir() if not entry.is_symlink()) except OSError: return 0 def storage_stats(self) -> dict[str, int | str | None]: with self._connection() as db: row = db.execute( """ SELECT COUNT(*) AS scan_count, SUM(CASE WHEN report_compacted = 1 THEN 1 ELSE 0 END) AS compacted_scan_count, MIN(scanned_at) AS oldest_scan_at, MAX(scanned_at) AS newest_scan_at FROM scans """ ).fetchone() migration_backup = db.execute( "SELECT value FROM app_state WHERE key = 'last_schema_migration_backup'" ).fetchone() database_bytes = self.path.stat().st_size if self.path.exists() else 0 journal = Path(f"{self.path}-wal") journal_bytes = journal.stat().st_size if journal.exists() else 0 cache_root = Path(os.getenv("REPOPULSE_REPOSITORY_CACHE", "data/repos")).expanduser() mirrors_root = cache_root / "mirrors" worktrees_root = cache_root / "worktrees" _mirror_files, mirror_cache_bytes = self._tree_stats(mirrors_root) mirror_count = self._managed_entry_count(mirrors_root) worktree_count = self._managed_entry_count(worktrees_root) return { "database_bytes": database_bytes, "journal_bytes": journal_bytes, "mirror_count": mirror_count, "mirror_cache_bytes": mirror_cache_bytes, "worktree_count": worktree_count, "scan_count": int(row["scan_count"] or 0), "compacted_scan_count": int(row["compacted_scan_count"] or 0), "oldest_scan_at": row["oldest_scan_at"], "newest_scan_at": row["newest_scan_at"], "history_per_repository": history_per_repository(), "full_reports_per_repository": full_reports_per_repository(), "report_retention_days": report_retention_days(), "last_schema_migration_backup": migration_backup["value"] if migration_backup else None, } def database_recovery_status(self) -> dict[str, object]: stored = self.get_state("database_recovery") if not stored: return {"status": "healthy"} try: value = json.loads(stored) except json.JSONDecodeError: value = {"status": "unknown", "detail": "Recovery metadata could not be read"} return value if isinstance(value, dict) else {"status": "unknown"} def acknowledge_database_recovery(self) -> None: self.recovery_metadata = None self.set_state("database_recovery", {"status": "acknowledged", "acknowledged_at": _now().isoformat()}) def set_state(self, key: str, value: object) -> None: encoded = value if isinstance(value, str) else json.dumps(value, separators=(",", ":")) with self._connection() as db: db.execute( """ INSERT INTO app_state (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at """, (key, encoded, _now().isoformat()), ) def get_state(self, key: str) -> str | None: with self._connection() as db: row = db.execute("SELECT value FROM app_state WHERE key = ?", (key,)).fetchone() return row["value"] if row else None def update_repository_sync( self, repository: Repository, *, synced_at: datetime, error: str | None = None, ) -> None: updated = repository.model_copy( update={"last_synced_at": synced_at, "sync_error": error} ) self.upsert_repository(updated) def record_sync_result(self, result: RepositorySyncResult) -> None: self.set_state("last_gitea_sync", result.model_dump_json())