diff --git a/src/cleveragents/application/container.py b/src/cleveragents/application/container.py index c8a2bd941..4c15fefa3 100644 --- a/src/cleveragents/application/container.py +++ b/src/cleveragents/application/container.py @@ -300,9 +300,9 @@ def _build_project_resource_link_repo( from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker - engine = create_engine(database_url, echo=False) - factory = sessionmaker(bind=engine, expire_on_commit=False) - return ProjectResourceLinkRepository(session_factory=factory) + engine = create_engine(database_url, echo=False) + factory = sessionmaker(bind=engine, expire_on_commit=False) + return ProjectResourceLinkRepository(session_factory=factory) def _build_invariant_service( diff --git a/src/cleveragents/application/services/invariant_service.py b/src/cleveragents/application/services/invariant_service.py index 846001f4f..4ccea3cd1 100644 --- a/src/cleveragents/application/services/invariant_service.py +++ b/src/cleveragents/application/services/invariant_service.py @@ -64,6 +64,7 @@ class InvariantService: """ self._repository = repository self._enforcement_records: list[InvariantEnforcementRecord] = [] + self._invariants: dict[str, Invariant] = {} self._logger = logger.bind(service="invariant") self._sanitizer = PromptSanitizer() self._event_bus = event_bus @@ -106,6 +107,8 @@ class InvariantService: # Persist to database if repository is available if self._repository is not None: invariant = self._repository.add(invariant) + else: + self._invariants[invariant.id] = invariant self._logger.info( "Invariant added", @@ -147,6 +150,12 @@ class InvariantService: else: # Fallback to in-memory storage (for backward compatibility) result = [] + all_invs = [v for v in self._invariants.values() if v.active] + if scope is not None: + all_invs = [i for i in all_invs if i.scope == scope] + if source_name is not None: + all_invs = [i for i in all_invs if i.source_name == source_name] + result = all_invs return result @@ -170,10 +179,11 @@ class InvariantService: deactivated = self._repository.soft_delete(invariant_id) else: # Fallback to in-memory storage (for backward compatibility) - raise NotFoundError( - resource_type="invariant", - resource_id=invariant_id, - ) + inv = self._invariants.get(invariant_id) + if inv is None: + raise NotFoundError(resource_type="invariant", resource_id=invariant_id) + deactivated = inv.model_copy(update={"active": False}) + self._invariants[invariant_id] = deactivated self._logger.info("Invariant removed (soft-delete)", invariant_id=invariant_id) return deactivated @@ -221,6 +231,16 @@ class InvariantService: plan_invs = [] project_invs = [] global_invs = [] + all_active = [v for v in self._invariants.values() if v.active] + plan_invs = [i for i in all_active if i.scope == InvariantScope.PLAN] + if plan_id is not None: + plan_invs = [i for i in plan_invs if i.source_name == plan_id] + project_invs = [i for i in all_active if i.scope == InvariantScope.PROJECT] + if project_name is not None: + project_invs = [ + i for i in project_invs if i.source_name == project_name + ] + global_invs = [i for i in all_active if i.scope == InvariantScope.GLOBAL] return merge_invariants(plan_invs, project_invs, global_invs) diff --git a/src/cleveragents/cli/commands/invariant.py b/src/cleveragents/cli/commands/invariant.py index 2376cb5bd..5b100ee11 100644 --- a/src/cleveragents/cli/commands/invariant.py +++ b/src/cleveragents/cli/commands/invariant.py @@ -224,7 +224,7 @@ def list_invariants( table.add_column("ID", style="cyan", max_width=26) table.add_column("Scope", style="yellow") table.add_column("Source", style="magenta") - table.add_column("Text", style="white") + table.add_column("Text", style="white", no_wrap=True) table.add_column("Active", justify="center") for inv in invariants: diff --git a/src/cleveragents/infrastructure/database/invariant_repository.py b/src/cleveragents/infrastructure/database/invariant_repository.py new file mode 100644 index 000000000..705b82fd0 --- /dev/null +++ b/src/cleveragents/infrastructure/database/invariant_repository.py @@ -0,0 +1,164 @@ +"""InvariantRepository -- Standalone Invariant Persistence. + +Provides database-backed CRUD operations for standalone invariants, +enabling invariants to persist across process restarts. + +Based on ADR-007 (Repository Pattern) and ADR-033 (Retry Patterns). +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +import structlog +from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session + +from cleveragents.core.exceptions import DatabaseError +from cleveragents.core.retry_patterns import retry_database_operation as database_retry +from cleveragents.infrastructure.database.models import InvariantModel + +if TYPE_CHECKING: + from cleveragents.domain.models.core.invariant import Invariant + +_log = structlog.get_logger(__name__) + + +class InvariantRepository: + """Repository for standalone invariant persistence. + + Implements CRUD operations for invariants with database backing, + enabling invariants to persist across process restarts. + + Uses a session-factory pattern: each public method obtains its own + session from the factory, ensuring proper session lifecycle management. + + All mutating methods flush (but do NOT commit); the caller or a + ``UnitOfWork`` wrapper is responsible for committing the transaction. + """ + + def __init__(self, session_factory: Callable[[], Session]) -> None: + """Initialise with a callable that returns a new SQLAlchemy Session.""" + self._session_factory = session_factory + + def _session(self) -> Session: + """Convenience helper to obtain a session.""" + return self._session_factory() + + @database_retry + def add(self, invariant: Invariant) -> Invariant: + """Persist a new invariant to the database. + + Args: + invariant: An ``Invariant`` domain model instance. + + Returns: + The same ``Invariant`` after persistence. + + Raises: + DatabaseError: On transient or unexpected DB errors. + """ + session = self._session() + try: + db_model = InvariantModel.from_domain(invariant) + session.add(db_model) + session.flush() + session.commit() + return invariant + except (OperationalError, SQLAlchemyDatabaseError) as exc: + session.rollback() + raise DatabaseError(f"Failed to add invariant: {exc}") from exc + + @database_retry + def list( + self, + scope: str | None = None, + source_name: str | None = None, + ) -> list[Invariant]: + """List invariants, optionally filtered by scope and/or source_name. + + Args: + scope: Filter by scope (None = all scopes). + source_name: Filter by source name (None = all sources). + + Returns: + List of ``Invariant`` domain objects. + + Raises: + DatabaseError: On transient or unexpected DB errors. + """ + session = self._session() + try: + query = session.query(InvariantModel).filter_by(active=True) + + if scope is not None: + query = query.filter_by(scope=scope) + + if source_name is not None: + query = query.filter_by(source_name=source_name) + + rows = query.all() + return [row.to_domain() for row in rows] + except (OperationalError, SQLAlchemyDatabaseError) as exc: + raise DatabaseError(f"Failed to list invariants: {exc}") from exc + + @database_retry + def get_by_id(self, invariant_id: str) -> Invariant | None: + """Retrieve an invariant by its ULID. + + Args: + invariant_id: The ULID of the invariant. + + Returns: + The ``Invariant`` domain object, or ``None`` if not found. + + Raises: + DatabaseError: On transient or unexpected DB errors. + """ + session = self._session() + try: + row = session.query(InvariantModel).filter_by(id=invariant_id).first() + if row is None: + return None + return row.to_domain() + except (OperationalError, SQLAlchemyDatabaseError) as exc: + raise DatabaseError( + f"Failed to get invariant {invariant_id}: {exc}" + ) from exc + + @database_retry + def soft_delete(self, invariant_id: str) -> Invariant: + """Soft-delete an invariant by setting active=False. + + Args: + invariant_id: The ULID of the invariant to delete. + + Returns: + The updated ``Invariant`` domain object. + + Raises: + DatabaseError: On transient or unexpected DB errors. + """ + from cleveragents.core.exceptions import NotFoundError + + session = self._session() + try: + row = session.query(InvariantModel).filter_by(id=invariant_id).first() + if row is None: + raise NotFoundError( + resource_type="invariant", + resource_id=invariant_id, + ) + row.active = False # type: ignore[assignment] + session.flush() + session.commit() + return row.to_domain() + except NotFoundError: + raise + except (OperationalError, SQLAlchemyDatabaseError) as exc: + session.rollback() + raise DatabaseError( + f"Failed to soft-delete invariant {invariant_id}: {exc}" + ) from exc diff --git a/src/cleveragents/infrastructure/database/models.py b/src/cleveragents/infrastructure/database/models.py index 1005c7b50..eb45ee2ca 100644 --- a/src/cleveragents/infrastructure/database/models.py +++ b/src/cleveragents/infrastructure/database/models.py @@ -1306,9 +1306,9 @@ class InvariantModel(Base): # type: ignore[misc] text = Column(Text, nullable=False) scope = Column(String(20), nullable=False) source_name = Column(String(255), nullable=False) - active = Column(Boolean, nullable=False, default=True, server_default=sa.text("1")) + active = Column(Boolean, nullable=False, default=True, server_default="1") non_overridable = Column( - Boolean, nullable=False, default=False, server_default=sa.text("0") + Boolean, nullable=False, default=False, server_default="0" ) created_at = Column(String(30), nullable=False) @@ -1320,17 +1320,17 @@ class InvariantModel(Base): # type: ignore[misc] ) return Invariant( - id=self.id, - text=self.text, - scope=InvariantScope(self.scope), - source_name=self.source_name, - active=self.active, - non_overridable=self.non_overridable, - created_at=datetime.fromisoformat(self.created_at), + id=cast(str, self.id), + text=cast(str, self.text), + scope=InvariantScope(cast(str, self.scope)), + source_name=cast(str, self.source_name), + active=cast(bool, self.active), + non_overridable=cast(bool, self.non_overridable), + created_at=datetime.fromisoformat(cast(str, self.created_at)), ) @classmethod - def from_domain(cls, invariant: "Invariant") -> "InvariantModel": + def from_domain(cls, invariant: Invariant) -> InvariantModel: """Create database model from domain model.""" return cls( id=invariant.id, diff --git a/src/cleveragents/infrastructure/database/repositories.py b/src/cleveragents/infrastructure/database/repositories.py index 53c1cea43..9c1f0177a 100644 --- a/src/cleveragents/infrastructure/database/repositories.py +++ b/src/cleveragents/infrastructure/database/repositories.py @@ -141,6 +141,8 @@ if TYPE_CHECKING: CorrectionAttemptRecord, ) from cleveragents.domain.models.core.decision import Decision + from cleveragents.domain.models.core.invariant import Invariant + from cleveragents.domain.models.core.correction import ( CORRECTION_ATTEMPT_TERMINAL_STATES, @@ -6081,11 +6083,11 @@ class CorrectionAttemptRepository: session.delete(row) session.flush() return True - except (OperationalError, SQLAlchemyDatabaseError) as exc: - session.rollback() - raise DatabaseError( - f"Failed to delete correction attempt {correction_attempt_id}: {exc}" - ) from exc + except (OperationalError, SQLAlchemyDatabaseError) as exc: + session.rollback() + raise DatabaseError( + f"Failed to delete correction attempt {correction_attempt_id}: {exc}" + ) from exc # --------------------------------------------------------------------------- @@ -6115,7 +6117,7 @@ class InvariantRepository: return self._session_factory() @database_retry - def add(self, invariant: Any) -> Any: + def add(self, invariant: Invariant) -> Invariant: """Persist a new invariant to the database. Args: @@ -6127,13 +6129,13 @@ class InvariantRepository: Raises: DatabaseError: On transient or unexpected DB errors. """ - from cleveragents.domain.models.core.invariant import Invariant session = self._session() try: db_model = InvariantModel.from_domain(invariant) session.add(db_model) session.flush() + session.commit() return invariant except (OperationalError, SQLAlchemyDatabaseError) as exc: session.rollback() @@ -6144,7 +6146,7 @@ class InvariantRepository: self, scope: str | None = None, source_name: str | None = None, - ) -> list[Any]: + ) -> list[Invariant]: """List invariants, optionally filtered by scope and/or source_name. Args: @@ -6173,7 +6175,7 @@ class InvariantRepository: raise DatabaseError(f"Failed to list invariants: {exc}") from exc @database_retry - def get_by_id(self, invariant_id: str) -> Any | None: + def get_by_id(self, invariant_id: str) -> Invariant | None: """Retrieve an invariant by its ULID. Args: @@ -6197,7 +6199,7 @@ class InvariantRepository: ) from exc @database_retry - def soft_delete(self, invariant_id: str) -> Any: + def soft_delete(self, invariant_id: str) -> Invariant: """Soft-delete an invariant by setting active=False. Args: @@ -6219,8 +6221,9 @@ class InvariantRepository: resource_type="invariant", resource_id=invariant_id, ) - row.active = False + row.active = False # type: ignore[assignment] session.flush() + session.commit() return row.to_domain() except NotFoundError: raise