Spaces:
Sleeping
Sleeping
| """Database models and session management for the SelfRepair webapp. | |
| Prefers a configured Postgres ``DATABASE_URL`` (e.g. Neon) and gracefully falls | |
| back to ephemeral SQLite on ``/tmp`` if the database is unreachable, so the | |
| Space stays up even when outbound Postgres egress is blocked. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os as _os | |
| import uuid | |
| from datetime import datetime, timezone | |
| from sqlalchemy import ( | |
| Boolean, | |
| Column, | |
| DateTime, | |
| Float, | |
| ForeignKey, | |
| Integer, | |
| String, | |
| Text, | |
| UniqueConstraint, | |
| create_engine, | |
| text, | |
| ) | |
| from sqlalchemy.orm import DeclarativeBase, Session, relationship, sessionmaker | |
| logger = logging.getLogger(__name__) | |
| _DB_DIR = _os.environ.get("SELFREPAIR_DB_DIR", "/tmp/selfrepair") | |
| _SQLITE_URL = f"sqlite:///{_DB_DIR}/selfrepair.db" | |
| DATABASE_URL = _os.environ.get("DATABASE_URL", _SQLITE_URL) | |
| def _normalize(url: str) -> str: | |
| # SQLAlchemy needs an explicit driver for psycopg2. | |
| if url.startswith("postgresql://"): | |
| return "postgresql+psycopg2://" + url[len("postgresql://"):] | |
| if url.startswith("postgres://"): | |
| return "postgresql+psycopg2://" + url[len("postgres://"):] | |
| return url | |
| def _build_engine(url: str): | |
| if url.startswith("sqlite"): | |
| _os.makedirs(_DB_DIR, exist_ok=True) | |
| return create_engine(url, connect_args={"check_same_thread": False}) | |
| # Postgres: pre-ping + recycle for serverless/pooled backends like Neon. | |
| return create_engine( | |
| _normalize(url), | |
| pool_pre_ping=True, | |
| pool_recycle=300, | |
| pool_size=5, | |
| max_overflow=5, | |
| connect_args={"connect_timeout": 10}, | |
| ) | |
| def _make_engine(): | |
| """Use DATABASE_URL; fall back to SQLite if the DB can't be reached.""" | |
| url = DATABASE_URL | |
| try: | |
| eng = _build_engine(url) | |
| with eng.connect() as conn: | |
| conn.execute(text("SELECT 1")) | |
| if not url.startswith("sqlite"): | |
| logger.info("SelfRepair DB: connected to Postgres backend.") | |
| return eng | |
| except Exception as exc: # pragma: no cover - depends on runtime egress | |
| if url.startswith("sqlite"): | |
| raise | |
| logger.warning( | |
| "SelfRepair DB: Postgres unreachable (%s); falling back to SQLite.", | |
| type(exc).__name__, | |
| ) | |
| _os.makedirs(_DB_DIR, exist_ok=True) | |
| return _build_engine(_SQLITE_URL) | |
| engine = _make_engine() | |
| SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) | |
| class Base(DeclarativeBase): | |
| pass | |
| def _uuid() -> str: | |
| return uuid.uuid4().hex | |
| def _utcnow() -> datetime: | |
| return datetime.now(timezone.utc) | |
| class User(Base): | |
| __tablename__ = "users" | |
| id = Column(String(32), primary_key=True, default=_uuid) | |
| email = Column(String(255), unique=True, nullable=False, index=True) | |
| username = Column(String(100), unique=True, nullable=False, index=True) | |
| password_hash = Column(String(255), nullable=False) | |
| display_name = Column(String(200), nullable=True) | |
| role = Column(String(20), default="user") # user | admin | |
| is_active = Column(Boolean, default=True) | |
| email_verified = Column(Boolean, default=False, nullable=False) | |
| # The single bootstrap superuser, created on first run. Protected: it can | |
| # never be demoted, deactivated, or deleted. It grants admin to others. | |
| is_root = Column(Boolean, default=False, nullable=False) | |
| created_at = Column(DateTime, default=_utcnow) | |
| last_login = Column(DateTime, nullable=True) | |
| # Platform tokens (encrypted at rest in production) | |
| github_token = Column(Text, nullable=True) | |
| gitlab_token = Column(Text, nullable=True) | |
| hf_token = Column(Text, nullable=True) | |
| scans = relationship("ScanRun", back_populates="user", cascade="all, delete-orphan") | |
| class ScanRun(Base): | |
| __tablename__ = "scan_runs" | |
| id = Column(String(32), primary_key=True, default=_uuid) | |
| user_id = Column(String(32), ForeignKey("users.id"), nullable=False) | |
| started_at = Column(DateTime, default=_utcnow) | |
| finished_at = Column(DateTime, nullable=True) | |
| status = Column(String(20), default="running") # running | completed | failed | |
| total_repos = Column(Integer, default=0) | |
| healthy = Column(Integer, default=0) | |
| degraded = Column(Integer, default=0) | |
| down = Column(Integer, default=0) | |
| repaired = Column(Integer, default=0) | |
| report_json = Column(Text, nullable=True) | |
| trigger = Column(String(50), default="manual") # manual | scheduled | |
| user = relationship("User", back_populates="scans") | |
| reports = relationship("RepoReport", back_populates="scan", cascade="all, delete-orphan") | |
| class RepoReport(Base): | |
| __tablename__ = "repo_reports" | |
| id = Column(String(32), primary_key=True, default=_uuid) | |
| scan_id = Column(String(32), ForeignKey("scan_runs.id"), nullable=False) | |
| repo_name = Column(String(300), nullable=False) | |
| platform = Column(String(20), default="github") | |
| kind = Column(String(20), default="code") | |
| status = Column(String(20), default="unknown") | |
| makefile_ok = Column(Boolean, default=False) | |
| pyproject_ok = Column(Boolean, default=False) | |
| health_test_ok = Column(Boolean, default=False) | |
| python311_ok = Column(Boolean, default=False) | |
| install_ok = Column(Boolean, default=False) | |
| test_ok = Column(Boolean, default=False) | |
| start_ok = Column(Boolean, default=False) | |
| fix_attempts = Column(Integer, default=0) | |
| changed_files = Column(Text, default="") | |
| notes = Column(Text, default="") | |
| pr_url = Column(String(500), nullable=True) | |
| duration_seconds = Column(Float, default=0.0) | |
| created_at = Column(DateTime, default=_utcnow) | |
| scan = relationship("ScanRun", back_populates="reports") | |
| class AuditLog(Base): | |
| __tablename__ = "audit_log" | |
| id = Column(String(32), primary_key=True, default=_uuid) | |
| user_id = Column(String(32), ForeignKey("users.id"), nullable=True) | |
| action = Column(String(100), nullable=False) | |
| details = Column(Text, nullable=True) | |
| ip_address = Column(String(45), nullable=True) | |
| created_at = Column(DateTime, default=_utcnow) | |
| class Connection(Base): | |
| """A configured connection to a SelfRepair service provider. | |
| provider is one of: ollabridge | gitpilot | matrixlab. | |
| The secret (OllaBridge ob_* key or service token) is stored | |
| Fernet-encrypted in ``api_key_enc`` — never in plaintext. | |
| """ | |
| __tablename__ = "connections" | |
| id = Column(String(32), primary_key=True, default=_uuid) | |
| user_id = Column(String(32), ForeignKey("users.id"), nullable=False, index=True) | |
| provider = Column(String(40), nullable=False) | |
| base_url = Column(String(500), nullable=False, default="") | |
| api_key_enc = Column(Text, nullable=True) | |
| status = Column(String(20), default="unknown") # unknown | ok | error | |
| detail = Column(Text, default="") | |
| last_checked_at = Column(DateTime, nullable=True) | |
| created_at = Column(DateTime, default=_utcnow) | |
| updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) | |
| __table_args__ = (UniqueConstraint("user_id", "provider", name="uq_user_provider"),) | |
| class Message(Base): | |
| """An incoming maintenance/repair request from a client (machine or admin). | |
| Recorded as the system-of-record intake row. Deduplicated in code via | |
| ``idempotency_key`` (we index it rather than relying on a partial unique | |
| constraint, which SQLite cannot express for NULLs portably). | |
| """ | |
| __tablename__ = "messages" | |
| id = Column(String(32), primary_key=True, default=_uuid) | |
| client_id = Column(String(200), nullable=False) | |
| requested_by = Column(String(200), nullable=True) | |
| type = Column(String(40), default="maintenance_request") # maintenance_request | repair_plan | |
| repo_url = Column(String(500), nullable=False) | |
| branch = Column(String(200), default="main") | |
| mode = Column(String(40), default="dry_run") | |
| message = Column(Text, nullable=True) | |
| payload = Column(Text, nullable=True) # JSON-encoded plan/extra | |
| idempotency_key = Column(String(200), nullable=True, index=True) | |
| status = Column(String(20), default="queued") # queued | running | done | failed | |
| job_id = Column(String(32), nullable=True) | |
| created_at = Column(DateTime, default=_utcnow) | |
| updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) | |
| class Job(Base): | |
| """A real repo health-check job produced from a Message.""" | |
| __tablename__ = "jobs" | |
| id = Column(String(32), primary_key=True, default=_uuid) | |
| message_id = Column(String(32), nullable=True, index=True) | |
| repo_url = Column(String(500), nullable=False) | |
| branch = Column(String(200), default="main") | |
| status = Column(String(20), default="queued") # queued | running | done | failed | |
| health_score = Column(Integer, nullable=True) | |
| report_json = Column(Text, nullable=True) | |
| error = Column(Text, nullable=True) | |
| created_at = Column(DateTime, default=_utcnow) | |
| finished_at = Column(DateTime, nullable=True) | |
| class Notification(Base): | |
| """An operator-facing notification surfaced in the console bell/inbox.""" | |
| __tablename__ = "notifications" | |
| id = Column(String(32), primary_key=True, default=_uuid) | |
| source = Column(String(200), default="system") # client_id or "system" | |
| kind = Column(String(40), default="request_received") # request_received | report_ready | action_needed | |
| title = Column(String(400), nullable=False) | |
| body = Column(Text, nullable=True) | |
| link = Column(String(200), nullable=True) # job id | |
| read = Column(Boolean, default=False, nullable=False) | |
| created_at = Column(DateTime, default=_utcnow) | |
| def _ensure_columns() -> None: | |
| """Lightweight in-place migration for columns added after first deploy.""" | |
| insp_bind = engine | |
| try: | |
| from sqlalchemy import inspect as _inspect | |
| insp = _inspect(insp_bind) | |
| if "users" not in insp.get_table_names(): | |
| return | |
| cols = {c["name"] for c in insp.get_columns("users")} | |
| bool_default = "0" if engine.dialect.name == "sqlite" else "false" | |
| for col in ("email_verified", "is_root"): | |
| if col not in cols: | |
| with engine.begin() as conn: | |
| conn.execute( | |
| text( | |
| f"ALTER TABLE users ADD COLUMN {col} BOOLEAN " | |
| f"NOT NULL DEFAULT {bool_default}" | |
| ) | |
| ) | |
| logger.info("SelfRepair DB: added users.%s column.", col) | |
| except Exception as exc: # pragma: no cover | |
| logger.warning("SelfRepair DB: column check skipped (%s).", type(exc).__name__) | |
| def init_db() -> None: | |
| """Create all tables and apply lightweight migrations.""" | |
| Base.metadata.create_all(bind=engine) | |
| _ensure_columns() | |
| def get_db() -> Session: | |
| db = SessionLocal() | |
| try: | |
| return db | |
| except Exception: | |
| db.close() | |
| raise | |