Compare commits
4 Commits
pr-practice
...
pr-11166
| Author | SHA1 | Date | |
|---|---|---|---|
| 3918d9f05b | |||
| f770e3e451 | |||
| 1c802ecdd9 | |||
| e695b754b6 |
@@ -43,6 +43,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- **Plan Rollback Command** (#8557): Implemented `agents plan rollback <plan-id> [<checkpoint-id>]` for checkpoint-based plan state restoration in Epic #8493. The command restores a plan's sandbox to the state captured at a given checkpoint, discarding all decisions made after that checkpoint. The checkpoint can be specified as an optional positional second argument or via the `--to-checkpoint` named option. Supports `--yes/-y` flag to skip confirmation prompts and `--format/-f` for output format selection (rich/plain/json/yaml). Included with comprehensive BDD test coverage (>= 97%) and spec-aligned output formatting showing rollback summary, changes reverted, impact analysis, and post-rollback state panels.
|
||||
|
||||
### Fixed
|
||||
- **InvariantService now persists invariants to SQLite database (issue #8573):**
|
||||
``InvariantService`` was using pure in-memory storage, causing all
|
||||
invariant constraints to be lost on CLI process restart. Added an
|
||||
``InvariantModel`` SQLAlchemy model for standalone invariant persistence,
|
||||
a domain ``InvariantRepositoryProtocol`` and its SQLAlchemy-backed
|
||||
``InvariantRepository`` implementation, an Alembic migration creating
|
||||
the ``invariants`` table (``m11_001_standalone_invariants.py``), and
|
||||
updated ``InvariantService`` to accept an optional ``database_url``
|
||||
parameter. The application container passes this URL from Settings, so
|
||||
invariants added via ``agents invariant add/list/remove`` survive process
|
||||
restarts — all changes follow ADR-007 (Repository Pattern) conventions.
|
||||
|
||||
- **Guard cleanup_stale against execute/processing and execute/complete plans** (#11121):
|
||||
``_create_sandbox_for_plan()`` in ``src/cleveragents/cli/commands/plan.py`` now
|
||||
skips ``GitWorktreeSandbox.cleanup_stale()`` when the plan is in
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
|
||||
<<<<<<< HEAD
|
||||
* Jeffrey Phillips Freeman has contributed the InvariantService database persistence fix (PR #11166 / issue #8573): implemented SQLAlchemy-backed ``InvariantRepository``, added ``InvariantModel`` to the database models layer, created Alembic migration for the standalone ``invariants`` table, updated ``InvariantService`` with optional ``database_url`` parameter for cross-invocation persistence, and wired it into the application container (ADR-007 compliant).
|
||||
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix (#7875 / PR #7957): updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
* Jeffrey Phillips Freeman has contributed the complete AUTO-BUG-POOL to AUTO-BUG-SUP tracking prefix fix across agent-system-specification.md, automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
"""Step definitions for tdd_invariant_persistence.feature (bug #1022).
|
||||
"""Step definitions for tdd_invariant_persistence.feature (bug #1022, now fixed).
|
||||
|
||||
TDD issue-capture tests verifying that ``InvariantService`` persists invariants
|
||||
across simulated CLI process restarts (separate service instances).
|
||||
|
||||
Bug #1022: ``InvariantService`` stores invariants in an in-memory dict
|
||||
(``self._invariants``) with no database persistence layer. Each CLI
|
||||
invocation spawns a fresh process with a new ``InvariantService()``
|
||||
instance, so all invariants are lost when the process exits.
|
||||
|
||||
These steps exercise the current (buggy) behaviour by creating fresh
|
||||
``InvariantService`` instances to simulate separate process invocations.
|
||||
When the bug is fixed, the service will use a database repository and
|
||||
fresh instances backed by the same database will share state.
|
||||
|
||||
The tests carry ``@tdd_expected_fail`` so CI passes while the bug is
|
||||
unfixed. The tag will be removed when bug #1022 is fixed.
|
||||
Tests verify that ``InvariantService`` persists invariants across simulated
|
||||
CLI process restarts (separate service instances), confirming that Bug #8573
|
||||
/#1022 is resolved: the database-backed InvariantService stores data in SQLite,
|
||||
so separate CLI invocations share the same underlying ``cleveragents.db`` and
|
||||
cross-instance data visibility is confirmed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,49 +1,45 @@
|
||||
# TDD issue-capture test for bug #1022 — InvariantService in-memory storage only.
|
||||
# TDD issue-capture test for bug #1022 — InvariantService persistence.
|
||||
#
|
||||
# InvariantService stores invariants in an in-memory dict (self._invariants)
|
||||
# with no database persistence layer. Each CLI invocation spawns a fresh
|
||||
# process with a new InvariantService() instance, so all invariants added in
|
||||
# one invocation are lost when the process exits.
|
||||
# Bug #1022 has been fixed: InvariantService now uses SQLite-based storage
|
||||
# via a lazy session-factory pattern. Invariants added in one CLI invocation
|
||||
# persist across process restarts because all instances share the same
|
||||
# ``cleveragents.db`` (or equivalent) database configured in Settings.
|
||||
#
|
||||
# These scenarios prove the bug exists by simulating separate CLI invocations
|
||||
# (fresh InvariantService instances) and asserting that data added in one
|
||||
# invocation is visible in the next. They FAIL until the bug is fixed.
|
||||
# The @tag inverts the result so CI passes.
|
||||
#
|
||||
# See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/1022
|
||||
# These scenarios verify cross-instance data visibility by simulating
|
||||
# separate CLI invocations (fresh service instances backing a shared DB).
|
||||
|
||||
@tdd_issue @tdd_issue_1022 @mock_only
|
||||
Feature: TDD Issue #1022 — InvariantService invariants lost across process restarts
|
||||
@tdd_issue_1022 @mock_only
|
||||
Feature: TDD Issue #1022 — InvariantService persistence across process restarts
|
||||
As a developer using the agents CLI
|
||||
I want invariants added via "agents invariant add" to persist across CLI invocations
|
||||
So that "agents invariant list" in a subsequent invocation returns previously added invariants
|
||||
|
||||
InvariantService uses in-memory dict storage only. Each CLI invocation
|
||||
creates a fresh InvariantService() instance, so invariants are lost when
|
||||
the process exits. These tests simulate separate process invocations by
|
||||
InvariantService uses SQLite-based storage backed by the configured database URL.
|
||||
Fresh service instances share the same underlying database, so data persists across
|
||||
process restarts. These tests simulate separate process invocations by
|
||||
creating fresh service instances and verifying cross-instance data visibility.
|
||||
|
||||
@tdd_issue @tdd_issue_4283 @tdd_expected_fail
|
||||
@tdd_issue_1022
|
||||
Scenario: Invariant added in one service instance is visible in a fresh instance
|
||||
Given I add a project invariant "All APIs must validate auth tokens" to project "local/api-service" via invariant service instance A
|
||||
When I create a fresh invariant service instance B
|
||||
And I list project invariants for "local/api-service" via instance B
|
||||
Then the invariant list from instance B should contain "All APIs must validate auth tokens"
|
||||
|
||||
@tdd_issue @tdd_issue_4283 @tdd_expected_fail
|
||||
@tdd_issue_1022
|
||||
Scenario: Global invariant persists across simulated process restarts
|
||||
Given I add a global invariant "Never delete production data" via invariant service instance A
|
||||
When I create a fresh invariant service instance B
|
||||
And I list global invariants via instance B
|
||||
Then the invariant list from instance B should contain "Never delete production data"
|
||||
|
||||
@tdd_issue @tdd_issue_4283 @tdd_expected_fail
|
||||
@tdd_issue_1022
|
||||
Scenario: Invariant added via CLI add is visible via CLI list in a new invocation
|
||||
Given I invoke invariant add via CLI with "--project local/webapp" and text "All changes need tests" using service invocation 1
|
||||
When I invoke invariant list via CLI with "--project local/webapp" using service invocation 2
|
||||
Then the CLI list output from invocation 2 should contain "All changes need tests"
|
||||
|
||||
@tdd_issue @tdd_issue_4283 @tdd_expected_fail
|
||||
@tdd_issue_1022
|
||||
Scenario: Invariant soft-deleted in a fresh instance after being added in another
|
||||
Given I add a project invariant "Temporary constraint" to project "local/temp" via invariant service instance A
|
||||
And I capture the invariant ID from instance A
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Helper script for tdd_invariant_persistence.robot (bug #1022).
|
||||
"""Helper script for tdd_invariant_persistence.robot (bug #1022, now fixed).
|
||||
|
||||
Exercises InvariantService cross-invocation persistence at the integration
|
||||
level. Each subcommand simulates a fresh CLI process by creating a new
|
||||
InvariantService instance, mirroring how the real CLI works (each
|
||||
``python -m cleveragents`` call gets its own service).
|
||||
|
||||
Bug #1022: InvariantService stores invariants in an in-memory dict only.
|
||||
Invariants added in one CLI invocation are lost when the process exits.
|
||||
Bug #8573 / #1022 is now FIXED: InvariantService uses SQLite-based persistence
|
||||
via the configured ``database_url``, so invariants added in one CLI invocation
|
||||
persist across process restarts.
|
||||
|
||||
This helper is called from Robot Framework via ``Run Process``.
|
||||
"""
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
*** Settings ***
|
||||
Documentation TDD Issue #1022 — InvariantService invariants lost across CLI invocations
|
||||
... Integration tests verifying that invariants added via the CLI
|
||||
... persist across simulated process restarts. InvariantService
|
||||
... stores invariants in an in-memory dict only, so each CLI
|
||||
... invocation starts with an empty service. These tests exercise
|
||||
... add-then-list and add-then-remove across fresh service
|
||||
... instances. They fail until the bug is fixed; the
|
||||
... tag inverts the result so CI passes.
|
||||
Documentation TDD Issue #1022 — InvariantService invariant persistence
|
||||
... These tests verify that invariants added via the CLI persist
|
||||
... across simulated process restarts. Each test is a separate
|
||||
... CLI invocation that adds then lists or removes an invariant.
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
@@ -17,7 +13,7 @@ ${HELPER} ${CURDIR}/helper_tdd_invariant_persistence.py
|
||||
*** Test Cases ***
|
||||
TDD Invariant Add Then List Project Across Invocations
|
||||
[Documentation] Add a project invariant in invocation 1, list in invocation 2.
|
||||
[Tags] tdd_issue tdd_issue_1022 tdd_issue tdd_issue_4318 tdd_expected_fail
|
||||
[Tags] tdd_issue tdd_issue_1022 tdd_issue tdd_issue_4318
|
||||
|
||||
${result}= Run Process ${PYTHON} ${HELPER} add-then-list-project cwd=${WORKSPACE} timeout=30s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
@@ -27,7 +23,7 @@ TDD Invariant Add Then List Project Across Invocations
|
||||
|
||||
TDD Invariant Add Then List Global Across Invocations
|
||||
[Documentation] Add a global invariant in invocation 1, list in invocation 2.
|
||||
[Tags] tdd_issue tdd_issue_1022 tdd_issue tdd_issue_4318 tdd_expected_fail
|
||||
[Tags] tdd_issue tdd_issue_1022 tdd_issue tdd_issue_4318
|
||||
|
||||
${result}= Run Process ${PYTHON} ${HELPER} add-then-list-global cwd=${WORKSPACE} timeout=30s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
@@ -37,7 +33,7 @@ TDD Invariant Add Then List Global Across Invocations
|
||||
|
||||
TDD Invariant Remove Cross Instance
|
||||
[Documentation] Add invariant in instance 1, remove by ID in instance 2.
|
||||
[Tags] tdd_issue tdd_issue_1022 tdd_issue tdd_issue_4318 tdd_expected_fail
|
||||
[Tags] tdd_issue tdd_issue_1022 tdd_issue tdd_issue_4318
|
||||
|
||||
${result}= Run Process ${PYTHON} ${HELPER} add-then-remove-cross cwd=${WORKSPACE} timeout=30s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
|
||||
@@ -104,6 +104,7 @@ from cleveragents.infrastructure.database.llm_trace_repository import (
|
||||
)
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
CheckpointRepository,
|
||||
GlobalInvariantRepository,
|
||||
NamespacedProjectRepository,
|
||||
ProjectResourceLinkRepository,
|
||||
SessionMessageRepository,
|
||||
@@ -541,6 +542,27 @@ def _build_automation_profile_service(
|
||||
return AutomationProfileService(repo=profile_repo)
|
||||
|
||||
|
||||
def _build_global_invariant_repository(
|
||||
database_url: str,
|
||||
) -> GlobalInvariantRepository:
|
||||
"""Build a ``GlobalInvariantRepository`` backed by the database URL.
|
||||
|
||||
Follows the same ``_build_*`` pattern used by ``_build_session_factory``,
|
||||
``_build_skill_service``, and other DB-backed helpers. The repository is
|
||||
constructed with ``auto_commit=True`` so that CLI commands outside a
|
||||
``UnitOfWork`` can commit inline immediately (Bug #8573).
|
||||
"""
|
||||
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 GlobalInvariantRepository(
|
||||
session_factory=factory,
|
||||
auto_commit=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_tool_registry_service(
|
||||
database_url: str,
|
||||
) -> ToolRegistryService:
|
||||
@@ -726,9 +748,17 @@ class Container(containers.DeclarativeContainer):
|
||||
event_bus=event_bus,
|
||||
)
|
||||
|
||||
# Invariant Service - Singleton (in-memory invariant management)
|
||||
# Global Invariant Repository - Singleton (Bug #8573: persistence for CLI global/project invariants)
|
||||
invariant_repo = providers.Singleton(
|
||||
_build_global_invariant_repository,
|
||||
database_url=database_url,
|
||||
)
|
||||
|
||||
# Invariant Service - Singleton (delegates global/project scope CRUD to ``invariant_repo`` when wired)
|
||||
invariant_service = providers.Singleton(
|
||||
InvariantService,
|
||||
database_url=database_url,
|
||||
global_invariant_repo=invariant_repo,
|
||||
)
|
||||
|
||||
# Lock Service - Singleton (shared advisory-lock state per process, #7989)
|
||||
|
||||
@@ -6,8 +6,15 @@ lifecycle operations.
|
||||
|
||||
## Storage
|
||||
|
||||
Uses in-memory storage (same pattern as ``PlanLifecycleService``) with
|
||||
a dict keyed by invariant ID.
|
||||
Uses SQLite-based persistence via a lazy session-factory pattern (ADR-007).
|
||||
When a ``database_url`` is provided at construction, invariants are stored
|
||||
in the ``invariants`` table and persist across CLI invocations (process
|
||||
restarts). When no ``database_url`` is provided the service falls back to
|
||||
pure in-memory mode (unchanged legacy behaviour — useful for testing).
|
||||
|
||||
Standalone invariants are stored in the ``invariants`` table, separate
|
||||
from action-level (``action_invariants``) and plan-level
|
||||
(``plan_invariants``) child tables.
|
||||
|
||||
## Merge Precedence
|
||||
|
||||
@@ -22,6 +29,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import create_engine as _create_engine
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.application.services.prompt_sanitizer import PromptSanitizer
|
||||
@@ -36,6 +44,8 @@ from cleveragents.infrastructure.events.models import DomainEvent
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from cleveragents.infrastructure.events.protocol import EventBus
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -45,14 +55,29 @@ class InvariantService:
|
||||
"""Service for managing invariant constraints.
|
||||
|
||||
Provides add, list, remove (soft-delete), effective-set computation,
|
||||
and enforcement record creation. All storage is in-memory.
|
||||
and enforcement record creation. Storage is database-backed when a
|
||||
``database_url`` is provided at construction; otherwise in-memory.
|
||||
"""
|
||||
|
||||
def __init__(self, event_bus: EventBus | None = None) -> None:
|
||||
"""Initialise the invariant service with empty in-memory storage.
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
event_bus: EventBus | None = None,
|
||||
database_url: str | None = None,
|
||||
global_invariant_repo: Any | None = None,
|
||||
) -> None:
|
||||
"""Initialise the invariant service.
|
||||
|
||||
Args:
|
||||
event_bus: Optional EventBus for domain event emission.
|
||||
database_url: SQLAlchemy database URL for persistence (action/plan
|
||||
scope invariants). When provided, all CRUD operations use the
|
||||
database. When ``None``, operates in pure in-memory mode.
|
||||
global_invariant_repo: Optional repository for global/project-
|
||||
scoped invariant persistence (Bug #8573). When provided,
|
||||
add/list/remove operations delegate to this repo instead of
|
||||
building ad-hoc sessions. Useful when DI container manages
|
||||
the repository lifecycle.
|
||||
"""
|
||||
self._invariants: dict[str, Invariant] = {}
|
||||
self._enforcement_records: list[InvariantEnforcementRecord] = []
|
||||
@@ -60,11 +85,80 @@ class InvariantService:
|
||||
self._sanitizer = PromptSanitizer()
|
||||
self._event_bus = event_bus
|
||||
|
||||
# Repository-based path (Bug #8573)
|
||||
self._global_invariant_repo = global_invariant_repo
|
||||
|
||||
# Legacy session-factory path (fallback for standalone usage)
|
||||
self._database_url = database_url
|
||||
self._session_factory: sessionmaker[Session] | None = None
|
||||
self._has_loaded_from_db: bool = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session-factory helpers (lazy init)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_session_factory(self) -> sessionmaker[Session]:
|
||||
"""Get or create a lazy session factory from ``database_url``."""
|
||||
if self._session_factory is None and self._database_url is not None:
|
||||
engine = _create_engine(
|
||||
self._database_url,
|
||||
echo=False,
|
||||
future=True,
|
||||
isolation_level="SERIALIZABLE",
|
||||
connect_args={"check_same_thread": False}
|
||||
if self._database_url.startswith("sqlite")
|
||||
else {},
|
||||
)
|
||||
# SQLAlchemy 2.x compatible – use the engine's sessionmaker_class
|
||||
try:
|
||||
# SQLAlchemy >= 2.0
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
self._session_factory = sessionmaker(
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
class_=Session,
|
||||
)
|
||||
except ImportError:
|
||||
# SQLAlchemy < 2.0 – fallback (shouldn't happen in practice)
|
||||
self._session_factory = engine.sessionmaker(
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
return self._session_factory # type: ignore[return-value]
|
||||
|
||||
def _ensure_loaded_from_db(self) -> None:
|
||||
"""Populate the in-memory cache from the database (one-shot)."""
|
||||
if (
|
||||
self._database_url is not None
|
||||
and self._session_factory is not None
|
||||
and not self._has_loaded_from_db
|
||||
):
|
||||
session = self._session_factory()
|
||||
try:
|
||||
from cleveragents.infrastructure.database.models import InvariantModel
|
||||
|
||||
rows = (
|
||||
session.query(InvariantModel)
|
||||
.filter(InvariantModel.active == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
domain_inv = row.to_domain()
|
||||
self._invariants[domain_inv.id] = domain_inv
|
||||
self._has_loaded_from_db = True
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_invariant(
|
||||
self,
|
||||
text: str,
|
||||
scope: InvariantScope,
|
||||
source_name: str,
|
||||
self, text: str, scope: InvariantScope, source_name: str
|
||||
) -> Invariant:
|
||||
"""Add a new invariant with validation.
|
||||
|
||||
@@ -77,14 +171,13 @@ class InvariantService:
|
||||
The created ``Invariant``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If text is empty/blank or source_name is blank.
|
||||
ValidationError: If text is empty / blank or source_name is blank.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
raise ValidationError("Invariant text must not be empty")
|
||||
if not source_name or not source_name.strip():
|
||||
raise ValidationError("Source name must not be empty")
|
||||
|
||||
# Sanitize invariant text before storage (mechanism 1)
|
||||
sanitized = self._sanitizer.sanitize_user_input(text.strip())
|
||||
text = sanitized.sanitized
|
||||
|
||||
@@ -95,8 +188,27 @@ class InvariantService:
|
||||
source_name=source_name.strip(),
|
||||
)
|
||||
|
||||
# Delegate to DI-wired repository for global/project scope (Bug #8573)
|
||||
if self._global_invariant_repo is not None and scope in (
|
||||
InvariantScope.GLOBAL,
|
||||
InvariantScope.PROJECT,
|
||||
):
|
||||
self._global_invariant_repo.create(invariant)
|
||||
elif self._database_url is not None and self._ensure_session_factory() is not None:
|
||||
session = self._session_factory()
|
||||
try:
|
||||
from cleveragents.infrastructure.database.models import InvariantModel
|
||||
|
||||
model = InvariantModel.from_domain(invariant)
|
||||
session.add(model)
|
||||
session.flush()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Cache in memory regardless of storage path
|
||||
self._invariants[invariant.id] = invariant
|
||||
self._logger.info(
|
||||
|
||||
logger.info(
|
||||
"Invariant added",
|
||||
invariant_id=invariant.id,
|
||||
scope=scope.value,
|
||||
@@ -113,9 +225,9 @@ class InvariantService:
|
||||
"""Filter and list invariants.
|
||||
|
||||
Args:
|
||||
scope: Filter by scope (None = all scopes).
|
||||
source_name: Filter by source name (None = all sources).
|
||||
effective: When True, returns merged set for the given
|
||||
scope: Filter by scope (``None`` = all scopes).
|
||||
source_name: Filter by source name (``None`` = all sources).
|
||||
effective: When ``True``, returns merged set for the given
|
||||
scope chain (requires ``scope`` and ``source_name``).
|
||||
|
||||
Returns:
|
||||
@@ -127,6 +239,50 @@ class InvariantService:
|
||||
project_name=source_name if scope == InvariantScope.PROJECT else None,
|
||||
)
|
||||
|
||||
# Delegate to DI-wired repository for global/project scope (Bug #8573)
|
||||
if self._global_invariant_repo is not None and scope in (
|
||||
InvariantScope.GLOBAL,
|
||||
InvariantScope.PROJECT,
|
||||
):
|
||||
result = self._global_invariant_repo.list_scope(scope=scope)
|
||||
# Build / refresh cache
|
||||
for inv in result:
|
||||
self._invariants.setdefault(inv.id, inv)
|
||||
return result
|
||||
|
||||
# Database-backed query path (one-shot cache population)
|
||||
if (
|
||||
self._database_url is not None
|
||||
and self._ensure_session_factory() is not None
|
||||
):
|
||||
session = self._session_factory()
|
||||
try:
|
||||
from cleveragents.infrastructure.database.models import InvariantModel
|
||||
|
||||
query = session.query(InvariantModel).filter(
|
||||
InvariantModel.active == True # noqa: E712
|
||||
)
|
||||
|
||||
if scope is not None:
|
||||
query = query.filter(InvariantModel.scope == scope.value)
|
||||
|
||||
if source_name is not None:
|
||||
query = query.filter(InvariantModel.source_name == source_name)
|
||||
|
||||
rows = query.all()
|
||||
result = [row.to_domain() for row in rows]
|
||||
|
||||
# Build / refresh cache
|
||||
for inv in result:
|
||||
self._invariants.setdefault(inv.id, inv)
|
||||
|
||||
return result
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Pure-in-memory fallback
|
||||
self._ensure_loaded_from_db() # one-shot pull on demand
|
||||
|
||||
result = [inv for inv in self._invariants.values() if inv.active]
|
||||
|
||||
if scope is not None:
|
||||
@@ -138,13 +294,13 @@ class InvariantService:
|
||||
return result
|
||||
|
||||
def remove_invariant(self, invariant_id: str) -> Invariant:
|
||||
"""Soft-delete an invariant by setting active=False.
|
||||
"""Soft-delete an invariant by setting ``active=False``.
|
||||
|
||||
Args:
|
||||
invariant_id: The ULID of the invariant to remove.
|
||||
|
||||
Returns:
|
||||
The updated ``Invariant``.
|
||||
The updated (deactivated) ``Invariant``.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the invariant does not exist.
|
||||
@@ -152,22 +308,84 @@ class InvariantService:
|
||||
if not invariant_id or not invariant_id.strip():
|
||||
raise ValidationError("Invariant ID must not be empty")
|
||||
|
||||
inv = self._invariants.get(invariant_id)
|
||||
# Look up current state from cache or DB
|
||||
inv = self._get_invariant_by_id(invariant_id)
|
||||
if inv is None:
|
||||
raise NotFoundError(
|
||||
resource_type="invariant",
|
||||
resource_id=invariant_id,
|
||||
)
|
||||
# Invariant is frozen (immutable); create a new instance with active=False
|
||||
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
|
||||
|
||||
inactive = inv.model_copy(update={"active": False})
|
||||
|
||||
# Persist to DB when configured
|
||||
# Delegate to DI-wired repository for global/project scope (Bug #8573)
|
||||
if self._global_invariant_repo is not None:
|
||||
removed = self._global_invariant_repo.remove(invariant_id)
|
||||
if not removed:
|
||||
raise NotFoundError(
|
||||
resource_type="invariant",
|
||||
resource_id=invariant_id,
|
||||
)
|
||||
elif self._database_url is not None and self._session_factory is not None:
|
||||
session = self._session_factory()
|
||||
try:
|
||||
from cleveragents.infrastructure.database.models import InvariantModel
|
||||
|
||||
# Use filter_by instead of deprecated .get()
|
||||
row = (
|
||||
session.query(InvariantModel)
|
||||
.filter(InvariantModel.id == invariant_id)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError(
|
||||
resource_type="invariant",
|
||||
resource_id=invariant_id,
|
||||
)
|
||||
row.active = False
|
||||
session.flush()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
self._invariants[invariant_id] = inactive
|
||||
logger.info("Invariant removed (soft-delete)", invariant_id=invariant_id)
|
||||
return inactive
|
||||
|
||||
def _get_invariant_by_id(self, invariant_id: str) -> Invariant | None:
|
||||
"""Lookup a single invariant by ID (cache, repo, or DB)."""
|
||||
if invariant_id in self._invariants:
|
||||
return self._invariants[invariant_id]
|
||||
|
||||
# Check DI-wired repository first (Bug #8573)
|
||||
if self._global_invariant_repo is not None:
|
||||
inv = self._global_invariant_repo.get_by_id(invariant_id)
|
||||
if inv is not None:
|
||||
self._invariants[invariant_id] = inv
|
||||
return inv
|
||||
|
||||
# Fallback to legacy session-factory path
|
||||
if self._database_url is not None and self._session_factory is not None:
|
||||
session = self._session_factory()
|
||||
try:
|
||||
from cleveragents.infrastructure.database.models import InvariantModel
|
||||
|
||||
row = (
|
||||
session.query(InvariantModel)
|
||||
.filter_by(id=invariant_id)
|
||||
.first()
|
||||
)
|
||||
if row is not None:
|
||||
inv = row.to_domain()
|
||||
self._invariants[invariant_id] = inv
|
||||
return inv
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
return None
|
||||
|
||||
def get_effective_invariants(
|
||||
self,
|
||||
plan_id: str | None = None,
|
||||
project_name: str | None = None,
|
||||
self, plan_id: str | None = None, project_name: str | None = None
|
||||
) -> list[Invariant]:
|
||||
"""Return the merged precedence chain for a plan/project context.
|
||||
|
||||
@@ -175,10 +393,8 @@ class InvariantService:
|
||||
using plan > project > global precedence.
|
||||
|
||||
Args:
|
||||
plan_id: Optional plan identifier to collect plan-scoped
|
||||
invariants.
|
||||
project_name: Optional project name to collect project-scoped
|
||||
invariants.
|
||||
plan_id: Optional plan identifier to collect plan-scoped invariants.
|
||||
project_name: Optional project name to collect project-scoped invariants.
|
||||
|
||||
Returns:
|
||||
Merged, de-duplicated list of effective invariants.
|
||||
@@ -257,7 +473,7 @@ class InvariantService:
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
self._logger.warning( # type: ignore[attr-defined]
|
||||
"event_bus_emit_failed",
|
||||
event_type="INVARIANT_VIOLATED",
|
||||
plan_id=plan_id,
|
||||
@@ -285,7 +501,7 @@ class InvariantService:
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
self._logger.warning( # type: ignore[attr-defined]
|
||||
"event_bus_emit_failed",
|
||||
event_type="INVARIANT_ENFORCED",
|
||||
plan_id=plan_id,
|
||||
@@ -303,7 +519,7 @@ class InvariantService:
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
self._logger.warning( # type: ignore[attr-defined]
|
||||
"event_bus_emit_failed",
|
||||
event_type="INVARIANT_RECONCILED",
|
||||
plan_id=plan_id,
|
||||
|
||||
@@ -43,6 +43,7 @@ import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from cleveragents.application.container import get_container
|
||||
from cleveragents.application.services.invariant_service import InvariantService
|
||||
from cleveragents.cli.formatting import OutputFormat, format_output
|
||||
from cleveragents.core.exceptions import CleverAgentsError, NotFoundError
|
||||
@@ -55,16 +56,19 @@ console = Console()
|
||||
|
||||
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
|
||||
|
||||
# Module-level service instance (in-memory, same lifetime as CLI process)
|
||||
_service: InvariantService | None = None
|
||||
|
||||
def _get_service() -> InvariantService | None:
|
||||
"""Return the DI-wired InvariantService for the current process.
|
||||
|
||||
def _get_service() -> InvariantService:
|
||||
"""Return (or lazily create) the module-level InvariantService."""
|
||||
global _service
|
||||
if _service is None:
|
||||
_service = InvariantService()
|
||||
return _service
|
||||
Uses the global container so that the invariant service is properly wired
|
||||
with its repository dependency (Bug #8573). Returns ``None`` when no
|
||||
container is available (e.g. in tests without a DB).
|
||||
"""
|
||||
try:
|
||||
container = get_container()
|
||||
return container.invariant_service()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_scope(
|
||||
@@ -131,6 +135,11 @@ def add(
|
||||
try:
|
||||
scope, source_name = _resolve_scope(is_global, project, plan, action)
|
||||
service = _get_service()
|
||||
if service is None:
|
||||
console.print(
|
||||
"[red]Error:[/red] Invariant service not available (no database?)"
|
||||
)
|
||||
raise typer.Abort()
|
||||
inv = service.add_invariant(text=text, scope=scope, source_name=source_name)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
@@ -180,6 +189,11 @@ def list_invariants(
|
||||
"""
|
||||
try:
|
||||
service = _get_service()
|
||||
if service is None:
|
||||
console.print(
|
||||
"[red]Error:[/red] Invariant service not available (no database?)"
|
||||
)
|
||||
raise typer.Abort()
|
||||
|
||||
scope: InvariantScope | None = None
|
||||
source_name: str | None = None
|
||||
@@ -265,6 +279,11 @@ def remove(
|
||||
raise typer.Abort()
|
||||
|
||||
service = _get_service()
|
||||
if service is None:
|
||||
console.print(
|
||||
"[red]Error:[/red] Invariant service not available (no database?)"
|
||||
)
|
||||
raise typer.Abort()
|
||||
inv = service.remove_invariant(invariant_id)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Domain repository protocol for standalone invariant constraints.
|
||||
|
||||
Defines the ``InvariantRepositoryProtocol`` — the port that the application
|
||||
layer uses to persist and retrieve standalone (top-level) invariant
|
||||
constraints. Infrastructure adapters (e.g. the SQLAlchemy-backed
|
||||
``InvariantRepository``) must satisfy this protocol.
|
||||
|
||||
Based on the clean architecture principle described in the specification:
|
||||
adapters live at the edge; the domain layer defines the contracts.
|
||||
|
||||
Standalone invariants are those managed via ``agents invariant add/list/remove``
|
||||
commands — unlike action-level (``action_invariants`` table) and plan-level
|
||||
(``plan_invariants`` table) child tables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from cleveragents.domain.models.core.invariant import Invariant
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InvariantRepositoryProtocol(Protocol):
|
||||
"""Port for standalone invariant persistence.
|
||||
|
||||
All methods that mutate state flush but do **not** commit; the caller
|
||||
or a Unit-of-Work wrapper is responsible for committing the transaction.
|
||||
"""
|
||||
|
||||
def create(self, invariant: Invariant) -> None:
|
||||
"""Persist a new standalone invariant.
|
||||
|
||||
Args:
|
||||
invariant: The ``Invariant`` domain model to persist.
|
||||
"""
|
||||
...
|
||||
|
||||
def get(self, invariant_id: str) -> Invariant | None:
|
||||
"""Retrieve one invariant by its ULID.
|
||||
|
||||
Args:
|
||||
invariant_id: ULID string of the invariant.
|
||||
|
||||
Returns:
|
||||
The ``Invariant`` domain model, or ``None`` if not found.
|
||||
"""
|
||||
...
|
||||
|
||||
def list_invariants(
|
||||
self,
|
||||
scope: str | None = None,
|
||||
source_name: str | None = None,
|
||||
active_only: bool = True,
|
||||
) -> list[Invariant]:
|
||||
"""List invariants with optional filters.
|
||||
|
||||
Args:
|
||||
scope: Filter by scope value ('global', 'project', 'action', 'plan').
|
||||
source_name: Filter by source name.
|
||||
active_only: If ``True``, only return active (non-deleted) invariants.
|
||||
|
||||
Returns:
|
||||
List of ``Invariant`` domain models.
|
||||
"""
|
||||
...
|
||||
|
||||
def update(self, invariant: Invariant) -> None:
|
||||
"""Update a standalone invariant.
|
||||
|
||||
Used primarily for soft-delete (setting ``active`` to ``False``).
|
||||
|
||||
Args:
|
||||
invariant: The updated ``Invariant`` (same id as persisted record).
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,145 @@
|
||||
"""SQLAlchemy-backed repository for standalone invariant constraints.
|
||||
|
||||
Implements :class:`~cleveragents.domain.repositories.invariant_repository.InvariantRepositoryProtocol`
|
||||
using SQLAlchemy with the session-factory pattern. Operations flush but do NOT
|
||||
commit — callers are responsible for committing transactions.
|
||||
|
||||
Based on ADR-007 (Repository Pattern) and Phase 0 discovery.
|
||||
Includes retry logic per ADR-033.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from cleveragents.core.exceptions import NotFoundError
|
||||
from cleveragents.core.retry_patterns import (
|
||||
retry_database_operation as database_retry,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.domain.models.core.invariant import InvariantScope
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class InvariantRepository:
|
||||
"""Repository for standalone (top-level) invariant persistence.
|
||||
|
||||
Uses SQLAlchemy with the session-factory pattern required by
|
||||
:class:`~cleveragents.infrastructure.database.repositories` peers.
|
||||
Operations flush but do not commit.
|
||||
"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""Initialize repository with a session factory.
|
||||
|
||||
Args:
|
||||
session_factory: Callable returning a new SQLAlchemy ``Session``.
|
||||
"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
@database_retry
|
||||
def create(self, invariant: "Invariant") -> None:
|
||||
"""Persist a new standalone invariant to the database."""
|
||||
from cleveragents.infrastructure.database.models import InvariantModel
|
||||
|
||||
session = self._session_factory()
|
||||
try:
|
||||
model = InvariantModel.from_domain(invariant)
|
||||
session.add(model)
|
||||
session.flush()
|
||||
logger.info(
|
||||
"Invariant persisted",
|
||||
invariant_id=invariant.id,
|
||||
scope=invariant.scope.value,
|
||||
source_name=invariant.source_name,
|
||||
)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@database_retry
|
||||
def get(self, invariant_id: str) -> "Invariant | None":
|
||||
"""Retrieve one invariant by its ULID."""
|
||||
from cleveragents.infrastructure.database.models import InvariantModel
|
||||
|
||||
session = self._session_factory()
|
||||
try:
|
||||
row = session.query(InvariantModel).get(invariant_id)
|
||||
if row is None:
|
||||
return None
|
||||
return row.to_domain()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@database_retry
|
||||
def list_invariants(
|
||||
self,
|
||||
scope: "InvariantScope | str | None" = None,
|
||||
source_name: str | None = None,
|
||||
active_only: bool = True,
|
||||
) -> list["Invariant"]:
|
||||
"""List invariants with optional filters."""
|
||||
from cleveragents.infrastructure.database.models import InvariantModel
|
||||
from cleveragents.domain.models.core.invariant import InvariantScope
|
||||
|
||||
session = self._session_factory()
|
||||
try:
|
||||
query = session.query(InvariantModel)
|
||||
|
||||
if scope is not None:
|
||||
scope_val = scope.value if isinstance(scope, InvariantScope) else scope
|
||||
query = query.filter(InvariantModel.scope == scope_val)
|
||||
|
||||
if source_name is not None:
|
||||
query = query.filter(InvariantModel.source_name == source_name)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(InvariantModel.active == True) # noqa: E712
|
||||
|
||||
rows = query.all()
|
||||
return [row.to_domain() for row in rows]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@database_retry
|
||||
def update(self, invariant: "Invariant") -> None:
|
||||
"""Update a standalone invariant (used for soft-delete)."""
|
||||
from cleveragents.infrastructure.database.models import InvariantModel
|
||||
|
||||
session = self._session_factory()
|
||||
try:
|
||||
model = session.query(InvariantModel).get(invariant.id)
|
||||
if model is None:
|
||||
raise NotFoundError(
|
||||
resource_type="invariant",
|
||||
resource_id=invariant.id,
|
||||
)
|
||||
|
||||
# Update mutable fields
|
||||
model.text = invariant.text
|
||||
model.scope = invariant.scope.value
|
||||
model.source_name = invariant.source_name
|
||||
model.active = invariant.active
|
||||
model.non_overridable = invariant.non_overridable
|
||||
model.created_at = invariant.created_at.isoformat()
|
||||
|
||||
session.flush()
|
||||
logger.info(
|
||||
"Invariant updated",
|
||||
invariant_id=invariant.id,
|
||||
)
|
||||
except NotFoundError:
|
||||
raise
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Create standalone invariants table for InvariantService persistence.
|
||||
|
||||
Adds a new ``invariants`` table for top-level invariant constraints managed
|
||||
by :class:`~cleveragents.application.services.invariant_service.InvariantService`
|
||||
via the CLI ``agents invariant add/list/remove`` commands. This table is
|
||||
separate from action-level (``action_invariants``) and plan-level
|
||||
(``plan_invariants``) child tables, allowing invariants to persist across
|
||||
CLI invocations even when not attached to a specific action or plan.
|
||||
|
||||
Revision ID: m11_001_standalone_invariants
|
||||
Revises: m9_003_plan_result_success_column
|
||||
Create Date: 2026-05-12 00:00:00
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m11_001_standalone_invariants"
|
||||
down_revision: str | None = "m9_003_plan_result_success_column"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the standalone invariants table and indexes."""
|
||||
op.create_table(
|
||||
"invariants",
|
||||
sa.Column("id", sa.String(26), primary_key=True, nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column("scope", sa.String(20), nullable=False),
|
||||
sa.Column("source_name", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.String(30),
|
||||
nullable=False,
|
||||
server_default=sa.func.datetime("now"),
|
||||
),
|
||||
sa.Column(
|
||||
"active", sa.Boolean(), nullable=False, server_default=sa.text("1")
|
||||
),
|
||||
sa.Column(
|
||||
"non_overridable",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"scope IN ('global', 'project', 'action', 'plan')",
|
||||
name="ck_invariants_scope",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_invariants_scope", "invariants", ["scope"])
|
||||
op.create_index("ix_invariants_source_name", "invariants", ["source_name"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the standalone invariants table and indexes."""
|
||||
op.drop_index("ix_invariants_source_name", table_name="invariants")
|
||||
op.drop_index("ix_invariants_scope", table_name="invariants")
|
||||
op.drop_table("invariants")
|
||||
@@ -14,6 +14,7 @@ Alembic migrations.
|
||||
| ``plan_projects`` | ``PlanProjectModel`` | Plan-project links |
|
||||
| ``plan_arguments`` | ``PlanArgumentModel`` | Plan argument values |
|
||||
| ``plan_invariants`` | ``PlanInvariantModel`` | Plan invariant rules |
|
||||
| ``invariants`` | ``InvariantModel`` | Standalone invariants |
|
||||
| ``resource_types`` | ``ResourceTypeModel`` | Resource type defs |
|
||||
| ``resources`` | ``ResourceModel`` | Resource instances |
|
||||
| ``resource_edges`` | ``ResourceEdgeModel`` | Resource DAG edges |
|
||||
@@ -37,6 +38,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from ulid import ULID
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -59,7 +61,7 @@ from sqlalchemy import (
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
create_engine,
|
||||
text,
|
||||
text as sa_text,
|
||||
)
|
||||
from sqlalchemy.orm import (
|
||||
Mapped,
|
||||
@@ -1772,7 +1774,7 @@ class ResourceLinkModel(Base): # type: ignore[misc]
|
||||
Text,
|
||||
nullable=False,
|
||||
default="contains",
|
||||
server_default=text("'contains'"),
|
||||
server_default=sa_text("'contains'"),
|
||||
)
|
||||
|
||||
# Timestamp (ISO-8601 string)
|
||||
@@ -2841,8 +2843,8 @@ class DecisionModel(Base): # type: ignore[misc]
|
||||
Index(
|
||||
"idx_decisions_superseded",
|
||||
"superseded_by",
|
||||
postgresql_where=text("superseded_by IS NOT NULL"),
|
||||
sqlite_where=text("superseded_by IS NOT NULL"),
|
||||
postgresql_where=sa_text("superseded_by IS NOT NULL"),
|
||||
sqlite_where=sa_text("superseded_by IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3268,7 +3270,7 @@ class CorrectionAttemptModel(Base): # type: ignore[misc]
|
||||
created_at = Column(
|
||||
String(30),
|
||||
nullable=False,
|
||||
server_default=text("(strftime('%Y-%m-%dT%H:%M:%f', 'now'))"),
|
||||
server_default=sa_text("(strftime('%Y-%m-%dT%H:%M:%f', 'now'))"),
|
||||
)
|
||||
completed_at = Column(String(30), nullable=True)
|
||||
|
||||
@@ -3392,6 +3394,110 @@ class CorrectionAttemptModel(Base): # type: ignore[misc]
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standalone Invariant Model (Stage M3.5 - migration m11_001)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InvariantModel(Base): # type: ignore[misc]
|
||||
"""Database model for standalone invariant constraints.
|
||||
|
||||
Stores top-level invariant rules managed by :class:`~cleveragents.application.services.invariant_service.InvariantService`.
|
||||
Separate from action-level (``action_invariants``) and plan-level
|
||||
(``plan_invariants``) child tables, allowing invariants to persist
|
||||
across CLI invocations even when not attached to a specific action or plan.
|
||||
|
||||
Table: ``invariants``
|
||||
"""
|
||||
|
||||
__allow_unmapped__ = True
|
||||
__tablename__ = "invariants"
|
||||
|
||||
# PK: ULID (26-char string)
|
||||
id = Column(String(26), primary_key=True)
|
||||
|
||||
# Constraint text
|
||||
text = Column(Text, nullable=False)
|
||||
|
||||
# Scope: global | project | action | plan
|
||||
scope = Column(String(20), nullable=False)
|
||||
|
||||
# Owning source (project name, action namespaced name, or "system" for global)
|
||||
source_name = Column(String(255), nullable=False)
|
||||
|
||||
# Soft-delete flag (active vs archived)
|
||||
active = Column(Boolean, nullable=False, default=True, server_default=sa_text("1"))
|
||||
|
||||
# Whether this invariant cannot be overridden by project-level invariants
|
||||
non_overridable = Column(
|
||||
Boolean, nullable=False, default=False, server_default=sa_text("0"),
|
||||
)
|
||||
|
||||
# Timestamp (ISO-8601 string)
|
||||
created_at = Column(String(30), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"scope IN ('global', 'project', 'action', 'plan')",
|
||||
name="ck_invariants_scope",
|
||||
),
|
||||
Index("ix_invariants_scope", "scope"),
|
||||
Index("ix_invariants_source_name", "source_name"),
|
||||
)
|
||||
|
||||
# -- Domain conversion helpers ------------------------------------------
|
||||
|
||||
def to_domain(self) -> Any:
|
||||
"""Convert to ``Invariant`` domain model.
|
||||
|
||||
Returns:
|
||||
An ``Invariant`` domain instance.
|
||||
"""
|
||||
from cleveragents.domain.models.core.invariant import Invariant, InvariantScope
|
||||
|
||||
return Invariant(
|
||||
id=cast(str, self.id),
|
||||
text=cast(str, self.text),
|
||||
scope=InvariantScope(cast(str, self.scope)),
|
||||
source_name=cast(str, self.source_name),
|
||||
created_at=datetime.fromisoformat(cast(str, self.created_at)),
|
||||
active=self.active,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, invariant: Any) -> InvariantModel:
|
||||
"""Create from ``Invariant`` domain model.
|
||||
|
||||
Args:
|
||||
invariant: An ``Invariant`` domain instance.
|
||||
|
||||
Returns:
|
||||
An ``InvariantModel`` ready for persistence.
|
||||
"""
|
||||
return cls(
|
||||
id=cast(str, invariant.id),
|
||||
text=invariant.text if hasattr(invariant, "text") else str(invariant),
|
||||
scope=(
|
||||
invariant.scope.value
|
||||
if hasattr(invariant.scope, "value")
|
||||
else str(invariant.scope)
|
||||
),
|
||||
source_name=(
|
||||
invariant.source_name
|
||||
if hasattr(invariant, "source_name")
|
||||
else ""
|
||||
),
|
||||
active=getattr(invariant, "active", True),
|
||||
non_overridable=getattr(invariant, "non_overridable", False),
|
||||
created_at=(
|
||||
invariant.created_at.isoformat()
|
||||
if hasattr(invariant, "created_at") and invariant.created_at
|
||||
else datetime.now(tz=UTC).isoformat()
|
||||
),
|
||||
)
|
||||
|
||||
# Database initialization functions
|
||||
def init_database(database_url: str = "sqlite:///.cleveragents/db.sqlite") -> Any:
|
||||
"""Initialize the database.
|
||||
|
||||
@@ -13,6 +13,7 @@ Provides database-backed repositories following the session-factory pattern
|
||||
| ``ProjectResourceLinkRepository``| Project-resource link management |
|
||||
| ``DecisionRepository`` | CRUD for decision tree nodes |
|
||||
| ``CorrectionAttemptRepository``| CRUD for correction attempt records |
|
||||
| ``GlobalInvariantRepository`` | Persistence for CLI global/project invariants (Bug #8573) |
|
||||
|
||||
## Session-Factory Pattern
|
||||
|
||||
@@ -112,6 +113,7 @@ from cleveragents.infrastructure.database.models import (
|
||||
CorrectionAttemptModel,
|
||||
DebugAttemptModel,
|
||||
DecisionModel,
|
||||
InvariantModel,
|
||||
LifecycleActionModel,
|
||||
LifecyclePlanModel,
|
||||
NamespacedProjectModel,
|
||||
@@ -147,6 +149,7 @@ from cleveragents.domain.models.core.correction import (
|
||||
CorrectionAttemptState,
|
||||
validate_correction_state_transition,
|
||||
)
|
||||
from cleveragents.domain.models.core.invariant import InvariantScope
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
@@ -6227,8 +6230,158 @@ 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global Invariant Repository (Bug #8573 - persistence for CLI global/project invariants)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GlobalInvariantRepository:
|
||||
"""Repository for persisted global and project-scoped invariants.
|
||||
|
||||
Uses the session-factory pattern consistent with other repositories.
|
||||
Unlike action/plan invariants that are children of their parent resources,
|
||||
global and project invariants are standalone rows in the ``invariants``
|
||||
table filtered by scope ('global' | 'project').
|
||||
|
||||
All mutating methods flush but do NOT commit by default; the caller
|
||||
or a ``UnitOfWork`` wrapper is responsible for committing the
|
||||
transaction. When ``auto_commit`` is ``True`` (e.g. CLI usage
|
||||
outside a UoW), each method commits and closes its own session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
*,
|
||||
auto_commit: bool = False,
|
||||
) -> None:
|
||||
"""Initialise with a callable that returns a new SQLAlchemy Session.
|
||||
|
||||
Args:
|
||||
session_factory: Factory returning a new SQLAlchemy ``Session``.
|
||||
auto_commit: When ``True``, each public method commits and
|
||||
closes its session automatically. Useful for CLI commands
|
||||
that operate outside a ``UnitOfWork``.
|
||||
"""
|
||||
self._session_factory = session_factory
|
||||
self._auto_commit = auto_commit
|
||||
|
||||
def _session(self) -> Session:
|
||||
"""Convenience helper to obtain a session."""
|
||||
return self._session_factory()
|
||||
|
||||
@database_retry
|
||||
def create(self, invariant: Any) -> Any:
|
||||
"""Persist a new global/project ``Invariant`` domain object.
|
||||
|
||||
Args:
|
||||
invariant: A domain ``Invariant`` instance with scope 'global' or
|
||||
'project'.
|
||||
|
||||
Returns:
|
||||
The 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()
|
||||
if self._auto_commit:
|
||||
session.commit()
|
||||
session.close()
|
||||
return invariant
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise DatabaseError(f"Failed to create global invariant: {exc}") from exc
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
session.rollback()
|
||||
raise DatabaseError(f"Failed to create global invariant: {exc}") from exc
|
||||
|
||||
@database_retry
|
||||
def list_scope(
|
||||
self,
|
||||
scope: InvariantScope | None = None,
|
||||
) -> list[Any]:
|
||||
"""List global (or project-scoped) invariants.
|
||||
|
||||
Args:
|
||||
scope: Optional scope filter ('global' or 'project').
|
||||
|
||||
Returns:
|
||||
List of ``Invariant`` domain objects.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
query = session.query(InvariantModel).filter(
|
||||
InvariantModel.active == True, # noqa: E712
|
||||
)
|
||||
if scope is not None:
|
||||
query = query.filter(InvariantModel.scope == scope.value)
|
||||
rows = query.order_by(InvariantModel.created_at).all()
|
||||
return [row.to_domain() for row in rows]
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(f"Failed to list global invariants: {exc}") from exc
|
||||
finally:
|
||||
if self._auto_commit:
|
||||
session.close()
|
||||
|
||||
@database_retry
|
||||
def remove(self, invariant_id: str) -> bool:
|
||||
"""Soft-delete a global/project invariant by setting ``active=False``.
|
||||
|
||||
Args:
|
||||
invariant_id: ULID of the invariant to remove.
|
||||
|
||||
Returns:
|
||||
``True`` if an invariant was deactivated, ``False`` 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 False
|
||||
row.active = False
|
||||
session.flush()
|
||||
if self._auto_commit:
|
||||
session.commit()
|
||||
session.close()
|
||||
return True
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
session.rollback()
|
||||
raise DatabaseError(
|
||||
f"Failed to delete correction attempt {correction_attempt_id}: {exc}"
|
||||
f"Failed to remove global invariant {invariant_id}: {exc}",
|
||||
) from exc
|
||||
|
||||
@database_retry
|
||||
def get_by_id(self, invariant_id: str) -> Any | None:
|
||||
"""Look up a single global/project invariant by ID.
|
||||
|
||||
Args:
|
||||
invariant_id: ULID of the invariant.
|
||||
|
||||
Returns:
|
||||
An ``Invariant`` domain object, or ``None``.
|
||||
"""
|
||||
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 global invariant {invariant_id}: {exc}",
|
||||
) from exc
|
||||
|
||||
Reference in New Issue
Block a user