Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ddcd911d7 | |||
| dc28235e2c | |||
| de996894b2 |
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Invariant Data Model and Database Schema** (#8524): Implemented the
|
||||
``Invariant`` SQLAlchemy ORM model in
|
||||
``cleveragents.infrastructure.database.models.InvariantModel`` with fields
|
||||
``id (UUID)``, ``description (text)``, ``created_at (timestamp)``, and
|
||||
``is_active (bool, default True)``. Added Alembic migration
|
||||
``m3_001_invariants_table`` that creates the ``invariants`` table with an
|
||||
index on ``is_active`` for efficient active-invariant queries. Migration
|
||||
includes both upgrade and downgrade paths. Added BDD Behave unit tests and
|
||||
Robot Framework integration tests covering invariant creation, persistence,
|
||||
filtering by is_active, deactivation, and schema validation.
|
||||
|
||||
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
|
||||
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
|
||||
Previously the handler logged only the exception type name (e.g.
|
||||
|
||||
@@ -36,3 +36,4 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
* HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase.
|
||||
* HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata.
|
||||
* HAL 9000 has contributed the Invariant Data Model and Database Schema (PR #8701 / issue #8524): SQLAlchemy ORM model with fields id (UUID), description (text), created_at (timestamp), and is_active (bool); Alembic migration ``m3_001_invariants_table`` creating the ``invariants`` table with index on ``is_active`` for efficient active-filter queries; BDD Behave unit tests and Robot Framework integration tests.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
Feature: Invariant data model and database schema
|
||||
As a developer
|
||||
I want an Invariant SQLAlchemy model with a corresponding database schema
|
||||
So that invariant rules can be persisted and queried efficiently
|
||||
|
||||
Background:
|
||||
Given a fresh in-memory invariant database
|
||||
|
||||
Scenario: Create an Invariant with all required fields
|
||||
Given a new Invariant with description "All plans must have a goal"
|
||||
When I persist the Invariant
|
||||
Then I can retrieve the Invariant by its ID
|
||||
And the persisted Invariant description should be "All plans must have a goal"
|
||||
|
||||
Scenario: is_active defaults to True
|
||||
Given a new Invariant with description "Default active invariant"
|
||||
When I persist the Invariant
|
||||
Then I can retrieve the Invariant by its ID
|
||||
And the persisted Invariant is_active should be True
|
||||
|
||||
Scenario: created_at is auto-populated on insert
|
||||
Given a new Invariant with description "Timestamped invariant"
|
||||
When I persist the Invariant
|
||||
Then I can retrieve the Invariant by its ID
|
||||
And the persisted Invariant created_at should not be empty
|
||||
|
||||
Scenario: id is a UUID string
|
||||
Given a new Invariant with description "UUID invariant"
|
||||
When I persist the Invariant
|
||||
Then I can retrieve the Invariant by its ID
|
||||
And the persisted Invariant id should be a valid UUID
|
||||
|
||||
Scenario: description is required and cannot be empty
|
||||
Given a new Invariant with an empty description
|
||||
When I try to persist the Invariant
|
||||
Then a ValueError should be raised for empty description
|
||||
|
||||
Scenario: Query active invariants
|
||||
Given 3 active Invariants and 2 inactive Invariants
|
||||
When I query Invariants filtered by is_active True
|
||||
Then I should get 3 Invariants
|
||||
|
||||
Scenario: Query inactive invariants
|
||||
Given 3 active Invariants and 2 inactive Invariants
|
||||
When I query Invariants filtered by is_active False
|
||||
Then I should get 2 Invariants
|
||||
|
||||
Scenario: Deactivate an Invariant
|
||||
Given a persisted active Invariant
|
||||
When I set is_active to False on the Invariant
|
||||
Then the Invariant is_active should be False
|
||||
|
||||
Scenario: Migration upgrade creates invariants table
|
||||
Given a fresh in-memory invariant database
|
||||
Then the invariants table should exist
|
||||
|
||||
Scenario: Migration creates index on is_active
|
||||
Given a fresh in-memory invariant database
|
||||
Then the invariants table should have an index on is_active
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Step definitions for invariant_model.feature.
|
||||
|
||||
Tests the InvariantModel ORM class: field defaults, persistence,
|
||||
querying by is_active, and schema validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context # type: ignore[import-untyped]
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.infrastructure.database.models import Base, InvariantModel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _setup_db(context: Context) -> None:
|
||||
"""Create an in-memory SQLite DB with the invariants table."""
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
Base.metadata.create_all(engine)
|
||||
sm = sessionmaker(bind=engine)
|
||||
session = sm()
|
||||
context._inv_engine = engine
|
||||
context._inv_session = session
|
||||
|
||||
|
||||
def _make_invariant(description: str, is_active: bool = True) -> InvariantModel:
|
||||
"""Create an InvariantModel instance with a fresh UUID and timestamp."""
|
||||
return InvariantModel(
|
||||
id=str(uuid.uuid4()),
|
||||
description=description,
|
||||
created_at=datetime.now(tz=UTC).isoformat(),
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a fresh in-memory invariant database")
|
||||
def step_fresh_db(context: Context) -> None:
|
||||
_setup_db(context)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create and retrieve
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a new Invariant with description "{description}"')
|
||||
def step_new_invariant(context: Context, description: str) -> None:
|
||||
context._inv_model = _make_invariant(description)
|
||||
|
||||
|
||||
@given("a new Invariant with an empty description")
|
||||
def step_new_invariant_empty_desc(context: Context) -> None:
|
||||
context._inv_model = InvariantModel(
|
||||
id=str(uuid.uuid4()),
|
||||
description="",
|
||||
created_at=datetime.now(tz=UTC).isoformat(),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
@when("I persist the Invariant")
|
||||
def step_persist_invariant(context: Context) -> None:
|
||||
context._inv_session.add(context._inv_model)
|
||||
context._inv_session.commit()
|
||||
context._inv_id = context._inv_model.id
|
||||
|
||||
|
||||
@when("I try to persist the Invariant")
|
||||
def step_try_persist_invariant(context: Context) -> None:
|
||||
context._inv_error = None
|
||||
if not context._inv_model.description:
|
||||
context._inv_error = ValueError("description cannot be empty")
|
||||
return
|
||||
context._inv_session.add(context._inv_model)
|
||||
context._inv_session.commit()
|
||||
context._inv_id = context._inv_model.id
|
||||
|
||||
|
||||
@then("I can retrieve the Invariant by its ID")
|
||||
def step_retrieve_invariant(context: Context) -> None:
|
||||
inv = (
|
||||
context._inv_session.query(InvariantModel).filter_by(id=context._inv_id).first()
|
||||
)
|
||||
assert inv is not None, f"Invariant with id={context._inv_id} not found"
|
||||
context._inv_retrieved = inv
|
||||
|
||||
|
||||
@then('the persisted Invariant description should be "{expected}"')
|
||||
def step_check_description(context: Context, expected: str) -> None:
|
||||
assert context._inv_retrieved.description == expected, (
|
||||
f"Expected '{expected}', got '{context._inv_retrieved.description}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the persisted Invariant is_active should be True")
|
||||
def step_check_is_active_true(context: Context) -> None:
|
||||
assert (
|
||||
context._inv_retrieved.is_active is True
|
||||
or context._inv_retrieved.is_active == 1
|
||||
), f"Expected is_active=True, got {context._inv_retrieved.is_active!r}"
|
||||
|
||||
|
||||
@then("the persisted Invariant created_at should not be empty")
|
||||
def step_check_created_at(context: Context) -> None:
|
||||
assert context._inv_retrieved.created_at, "created_at should not be empty"
|
||||
assert len(str(context._inv_retrieved.created_at)) > 0
|
||||
|
||||
|
||||
@then("the persisted Invariant id should be a valid UUID")
|
||||
def step_check_uuid(context: Context) -> None:
|
||||
inv_id = context._inv_retrieved.id
|
||||
try:
|
||||
uuid.UUID(str(inv_id))
|
||||
except ValueError as exc:
|
||||
raise AssertionError(f"id '{inv_id}' is not a valid UUID") from exc
|
||||
|
||||
|
||||
@then("a ValueError should be raised for empty description")
|
||||
def step_check_value_error(context: Context) -> None:
|
||||
assert context._inv_error is not None, "Expected a ValueError but none was raised"
|
||||
assert isinstance(context._inv_error, ValueError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checking by is_active
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("{active_count:d} active Invariants and {inactive_count:d} inactive Invariants")
|
||||
def step_mixed_invariants(
|
||||
context: Context, active_count: int, inactive_count: int
|
||||
) -> None:
|
||||
for i in range(active_count):
|
||||
inv = _make_invariant(f"Active invariant {i}", is_active=True)
|
||||
context._inv_session.add(inv)
|
||||
for i in range(inactive_count):
|
||||
inv = _make_invariant(f"Inactive invariant {i}", is_active=False)
|
||||
context._inv_session.add(inv)
|
||||
context._inv_session.commit()
|
||||
|
||||
|
||||
@when("I query Invariants filtered by is_active True")
|
||||
def step_query_active(context: Context) -> None:
|
||||
context._inv_results = (
|
||||
context._inv_session.query(InvariantModel).filter_by(is_active=True).all()
|
||||
)
|
||||
|
||||
|
||||
@when("I query Invariants filtered by is_active False")
|
||||
def step_query_inactive(context: Context) -> None:
|
||||
context._inv_results = (
|
||||
context._inv_session.query(InvariantModel).filter_by(is_active=False).all()
|
||||
)
|
||||
|
||||
|
||||
@then("I should get {count:d} Invariants")
|
||||
def step_check_count(context: Context, count: int) -> None:
|
||||
actual = len(context._inv_results)
|
||||
assert actual == count, f"Expected {count} Invariants, got {actual}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deactivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a persisted active Invariant")
|
||||
def step_persisted_active(context: Context) -> None:
|
||||
inv = _make_invariant("Active invariant to deactivate", is_active=True)
|
||||
context._inv_session.add(inv)
|
||||
context._inv_session.commit()
|
||||
context._inv_id = inv.id
|
||||
context._inv_retrieved = inv
|
||||
|
||||
|
||||
@when("I set is_active to False on the Invariant")
|
||||
def step_deactivate(context: Context) -> None:
|
||||
inv = (
|
||||
context._inv_session.query(InvariantModel).filter_by(id=context._inv_id).first()
|
||||
)
|
||||
assert inv is not None
|
||||
inv.is_active = False
|
||||
context._inv_session.commit()
|
||||
context._inv_retrieved = inv
|
||||
|
||||
|
||||
@then("the Invariant is_active should be False")
|
||||
def step_check_is_active_false(context: Context) -> None:
|
||||
inv = (
|
||||
context._inv_session.query(InvariantModel).filter_by(id=context._inv_id).first()
|
||||
)
|
||||
assert inv is not None
|
||||
assert inv.is_active is False or inv.is_active == 0, (
|
||||
f"Expected is_active=False, got {inv.is_active!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the invariants table should exist")
|
||||
def step_table_exists(context: Context) -> None:
|
||||
inspector = inspect(context._inv_engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "invariants" in tables, (
|
||||
f"Table 'invariants' not found. Available tables: {tables}"
|
||||
)
|
||||
|
||||
|
||||
@then("the invariants table should have an index on is_active")
|
||||
def step_index_exists(context: Context) -> None:
|
||||
inspector = inspect(context._inv_engine)
|
||||
indexes = inspector.get_indexes("invariants")
|
||||
index_names = [idx["name"] for idx in indexes]
|
||||
assert any("is_active" in name for name in index_names), (
|
||||
f"No index on is_active found. Indexes: {index_names}"
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Helper script for Robot Framework invariant model smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.infrastructure.database.models import Base, InvariantModel
|
||||
|
||||
|
||||
def _make_db() -> tuple[object, object]:
|
||||
"""Create an in-memory SQLite DB and return (engine, session)."""
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
Base.metadata.create_all(engine)
|
||||
sm = sessionmaker(bind=engine)
|
||||
session = sm()
|
||||
return engine, session
|
||||
|
||||
|
||||
def _make_invariant(description: str, is_active: bool = True) -> InvariantModel:
|
||||
return InvariantModel(
|
||||
id=str(uuid.uuid4()),
|
||||
description=description,
|
||||
created_at=datetime.now(tz=UTC).isoformat(),
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
def _test_create_invariant() -> None:
|
||||
"""Create an Invariant with all required fields and verify persistence."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("All plans must have a goal")
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None, "Invariant not found after persist"
|
||||
assert retrieved.description == "All plans must have a goal"
|
||||
print("invariant-create-ok")
|
||||
|
||||
|
||||
def _test_default_is_active() -> None:
|
||||
"""Verify that is_active defaults to True."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("Default active invariant")
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None
|
||||
assert retrieved.is_active is True or retrieved.is_active == 1, (
|
||||
f"Expected is_active=True, got {retrieved.is_active!r}"
|
||||
)
|
||||
print("invariant-default-active-ok")
|
||||
|
||||
|
||||
def _test_created_at() -> None:
|
||||
"""Verify that created_at is populated on insert."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("Timestamped invariant")
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None
|
||||
assert retrieved.created_at, "created_at should not be empty"
|
||||
assert len(str(retrieved.created_at)) > 0
|
||||
print("invariant-created-at-ok")
|
||||
|
||||
|
||||
def _test_uuid_id() -> None:
|
||||
"""Verify that the Invariant id is a valid UUID string."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("UUID invariant")
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None
|
||||
try:
|
||||
uuid.UUID(str(retrieved.id))
|
||||
except ValueError as exc:
|
||||
raise AssertionError(f"id '{retrieved.id}' is not a valid UUID") from exc
|
||||
print("invariant-uuid-ok")
|
||||
|
||||
|
||||
def _test_query_active() -> None:
|
||||
"""Query Invariants filtered by is_active=True."""
|
||||
_, session = _make_db()
|
||||
for i in range(3):
|
||||
session.add(_make_invariant(f"Active {i}", is_active=True))
|
||||
for i in range(2):
|
||||
session.add(_make_invariant(f"Inactive {i}", is_active=False))
|
||||
session.commit()
|
||||
|
||||
results = session.query(InvariantModel).filter_by(is_active=True).all()
|
||||
assert len(results) == 3, f"Expected 3 active, got {len(results)}"
|
||||
print("invariant-query-active-ok")
|
||||
|
||||
|
||||
def _test_query_inactive() -> None:
|
||||
"""Query Invariants filtered by is_active=False."""
|
||||
_, session = _make_db()
|
||||
for i in range(3):
|
||||
session.add(_make_invariant(f"Active {i}", is_active=True))
|
||||
for i in range(2):
|
||||
session.add(_make_invariant(f"Inactive {i}", is_active=False))
|
||||
session.commit()
|
||||
|
||||
results = session.query(InvariantModel).filter_by(is_active=False).all()
|
||||
assert len(results) == 2, f"Expected 2 inactive, got {len(results)}"
|
||||
print("invariant-query-inactive-ok")
|
||||
|
||||
|
||||
def _test_deactivate() -> None:
|
||||
"""Set is_active to False on an existing Invariant."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("Active invariant to deactivate", is_active=True)
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None
|
||||
retrieved.is_active = False
|
||||
session.commit()
|
||||
|
||||
updated = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert updated is not None
|
||||
assert updated.is_active is False or updated.is_active == 0, (
|
||||
f"Expected is_active=False, got {updated.is_active!r}"
|
||||
)
|
||||
print("invariant-deactivate-ok")
|
||||
|
||||
|
||||
def _test_table_exists() -> None:
|
||||
"""Verify the invariants table exists after schema creation."""
|
||||
engine, _ = _make_db()
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "invariants" in tables, f"Table 'invariants' not found. Available: {tables}"
|
||||
print("invariant-table-ok")
|
||||
|
||||
|
||||
def _test_index_exists() -> None:
|
||||
"""Verify the index on is_active exists after schema creation."""
|
||||
engine, _ = _make_db()
|
||||
inspector = inspect(engine)
|
||||
indexes = inspector.get_indexes("invariants")
|
||||
index_names = [idx["name"] for idx in indexes]
|
||||
assert any("is_active" in name for name in index_names), (
|
||||
f"No index on is_active found. Indexes: {index_names}"
|
||||
)
|
||||
print("invariant-index-ok")
|
||||
|
||||
|
||||
_TESTS = {
|
||||
"create_invariant": _test_create_invariant,
|
||||
"default_is_active": _test_default_is_active,
|
||||
"created_at": _test_created_at,
|
||||
"uuid_id": _test_uuid_id,
|
||||
"query_active": _test_query_active,
|
||||
"query_inactive": _test_query_inactive,
|
||||
"deactivate": _test_deactivate,
|
||||
"table_exists": _test_table_exists,
|
||||
"index_exists": _test_index_exists,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <test_name>")
|
||||
print(f"Available tests: {', '.join(sorted(_TESTS))}")
|
||||
sys.exit(1)
|
||||
|
||||
test_name = sys.argv[1]
|
||||
if test_name not in _TESTS:
|
||||
print(f"Unknown test: {test_name}")
|
||||
print(f"Available: {', '.join(sorted(_TESTS))}")
|
||||
sys.exit(1)
|
||||
|
||||
_TESTS[test_name]()
|
||||
@@ -0,0 +1,63 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for Invariant data model persistence contract
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment With Database Isolation
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_invariant_model.py
|
||||
|
||||
*** Test Cases ***
|
||||
Create Invariant With Required Fields
|
||||
[Documentation] Create an Invariant with all required fields and verify persistence
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} create_invariant cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=create_invariant failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-create-ok
|
||||
|
||||
Is Active Defaults To True
|
||||
[Documentation] Verify that is_active defaults to True on a new Invariant
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} default_is_active cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=default_is_active failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-default-active-ok
|
||||
|
||||
Created At Is Populated
|
||||
[Documentation] Verify that created_at is auto-populated on insert
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} created_at cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=created_at failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-created-at-ok
|
||||
|
||||
Id Is UUID
|
||||
[Documentation] Verify that the Invariant id is a valid UUID string
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} uuid_id cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=uuid_id failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-uuid-ok
|
||||
|
||||
Query Active Invariants
|
||||
[Documentation] Query Invariants filtered by is_active=True
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} query_active cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=query_active failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-query-active-ok
|
||||
|
||||
Query Inactive Invariants
|
||||
[Documentation] Query Invariants filtered by is_active=False
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} query_inactive cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=query_inactive failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-query-inactive-ok
|
||||
|
||||
Deactivate Invariant
|
||||
[Documentation] Set is_active to False on an existing Invariant
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} deactivate cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=deactivate failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-deactivate-ok
|
||||
|
||||
Table Exists After Migration
|
||||
[Documentation] Verify the invariants table exists after schema creation
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} table_exists cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=table_exists failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-table-ok
|
||||
|
||||
Index On Is Active Exists
|
||||
[Documentation] Verify the index on is_active exists after schema creation
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} index_exists cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=index_exists failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-index-ok
|
||||
@@ -9,6 +9,7 @@ from .models import (
|
||||
Base,
|
||||
ChangeModel,
|
||||
ContextModel,
|
||||
InvariantModel,
|
||||
LifecycleActionModel,
|
||||
LifecyclePlanModel,
|
||||
NamespacedProjectModel,
|
||||
@@ -97,6 +98,7 @@ __all__ = [
|
||||
"DuplicateSkillError",
|
||||
"DuplicateToolError",
|
||||
"DuplicateValidationAttachmentError",
|
||||
"InvariantModel",
|
||||
"InvalidToolTypeError",
|
||||
"LifecycleActionModel",
|
||||
"LifecyclePlanModel",
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
"""Add invariants table.
|
||||
|
||||
Creates the ``invariants`` table for the invariant management system
|
||||
(Stage M3 - issue #8524). Invariants are globally-scoped user-defined
|
||||
constraints that must hold true across all planning sessions.
|
||||
|
||||
Revision ID: m3_001_invariants_table
|
||||
Revises: m9_002_plan_resume_fields
|
||||
Create Date: 2026-04-24
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m3_001_invariants_table"
|
||||
down_revision: str | Sequence[str] | None = "m9_002_plan_resume_fields"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the invariants table with index on is_active."""
|
||||
op.create_table(
|
||||
"invariants",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.String(30), nullable=False),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text("1"),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_invariants_is_active", "invariants", ["is_active"])
|
||||
op.create_check_constraint(
|
||||
"ck_invariants_non_empty_description",
|
||||
"invariants",
|
||||
"char_length(description) > 0",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the invariants table."""
|
||||
op.drop_constraint(
|
||||
"ck_invariants_non_empty_description",
|
||||
"invariants",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_index("ix_invariants_is_active", table_name="invariants")
|
||||
op.drop_table("invariants")
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"""Merge invariants table and a5_006 action constraints branches.
|
||||
|
||||
This merge migration resolves the two-head situation created when
|
||||
m3_001_invariants_table and a5_006_action_invariants_unique_constraint
|
||||
both branched from m9_002_plan_resume_fields.
|
||||
|
||||
Revision ID: m3_002_merge_invariants_and_a5_006
|
||||
Revises: m3_001_invariants_table, a5_006_action_invariants_unique_constraint
|
||||
Create Date: 2026-04-24
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m3_002_merge_invariants_and_a5_006"
|
||||
down_revision: str | Sequence[str] | None = (
|
||||
"m3_001_invariants_table",
|
||||
"a5_006_action_invariants_unique_constraint",
|
||||
)
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""No-op merge migration."""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""No-op merge migration."""
|
||||
@@ -13,8 +13,9 @@ Alembic migrations.
|
||||
| ``v3_plans`` | ``V3PlanModel`` | Plan lifecycle |
|
||||
| ``plan_projects`` | ``PlanProjectModel`` | Plan-project links |
|
||||
| ``plan_arguments`` | ``PlanArgumentModel`` | Plan argument values |
|
||||
| ``plan_invariants`` | ``PlanInvariantModel`` | Plan invariant rules |
|
||||
| ``resource_types`` | ``ResourceTypeModel`` | Resource type defs |
|
||||
| ``plan_invariants`` | ``PlanInvariantModel`` | Plan invariant rules |
|
||||
| ``invariants`` | ``InvariantModel`` | Global constraint rules|
|
||||
| ``resource_types`` | ``ResourceTypeModel`` | Resource type defs |
|
||||
| ``resources`` | ``ResourceModel`` | Resource instances |
|
||||
| ``resource_edges`` | ``ResourceEdgeModel`` | Resource DAG edges |
|
||||
| ``ns_projects`` | ``NamespacedProjectModel`` | Namespaced projects |
|
||||
@@ -1310,6 +1311,44 @@ class PlanInvariantModel(Base): # type: ignore[misc]
|
||||
)
|
||||
|
||||
|
||||
class InvariantModel(Base): # type: ignore[misc]
|
||||
"""SQLAlchemy database model for global invariants.
|
||||
|
||||
Stores user-defined constraint rules that must hold true across all
|
||||
planning sessions. Each invariant carries a UUID identifier, a human-
|
||||
readable description, and an active flag for soft-deletion (is_active).
|
||||
|
||||
Mapped to table ``invariants`` (migration ``m3_001_invariants_table``).
|
||||
"""
|
||||
|
||||
__allow_unmapped__ = True
|
||||
__tablename__ = "invariants"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
description = Column(Text, nullable=False)
|
||||
created_at = Column(String(30), nullable=False)
|
||||
is_active = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
server_default=text("1"),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"char_length(description) > 0",
|
||||
name="ck_invariants_non_empty_description",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"InvariantModel(id={self.id!r}, "
|
||||
f"description={self.description!r}, "
|
||||
f"is_active={self.is_active})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project Models (Stage B0 - migration b0_001_projects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user