f8233000cc
CI / push-validation (pull_request) Successful in 28s
CI / helm (pull_request) Successful in 36s
CI / build (pull_request) Successful in 4m1s
CI / lint (pull_request) Successful in 4m14s
CI / quality (pull_request) Successful in 4m33s
CI / typecheck (pull_request) Successful in 4m54s
CI / security (pull_request) Successful in 4m55s
CI / unit_tests (pull_request) Successful in 7m48s
CI / e2e_tests (pull_request) Successful in 7m51s
CI / integration_tests (pull_request) Successful in 7m59s
CI / docker (pull_request) Successful in 2m7s
CI / coverage (pull_request) Successful in 15m56s
CI / status-check (pull_request) Successful in 3s
CI / benchmark-publish (push) Waiting to run
CI / benchmark-regression (push) Waiting to run
CI / push-validation (push) Successful in 22s
CI / helm (push) Successful in 27s
CI / build (push) Successful in 3m47s
CI / lint (push) Successful in 3m56s
CI / quality (push) Successful in 4m21s
CI / typecheck (push) Successful in 4m34s
CI / security (push) Successful in 4m44s
CI / e2e_tests (push) Successful in 6m43s
CI / unit_tests (push) Successful in 7m19s
CI / integration_tests (push) Successful in 7m44s
CI / docker (push) Successful in 1m29s
CI / coverage (push) Successful in 15m57s
CI / status-check (push) Successful in 4s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Failing after 1h9m42s
Align resource_links, checkpoint_metadata, and decisions schema behavior with the spec DDL. Add SQLite runtime trigger guards for checkpoint foreign-key semantics and migration-focused Behave/Robot checks that orphan checkpoint references are rejected. ISSUES CLOSED: #922
346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""Step definitions for db_migration_lifecycle.feature — core lifecycle.
|
|
|
|
Exercises the full Alembic migration lifecycle: forward application,
|
|
rollback, round-trip testing, CLI wrappers, stamp logic, and the
|
|
init_database function.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
from behave import given, then, when
|
|
from sqlalchemy import inspect as sa_inspect
|
|
|
|
|
|
def _create_temp_db_path() -> str:
|
|
"""Create a temporary file path for a SQLite database.
|
|
|
|
Returns the path as a string; the caller is responsible for cleanup.
|
|
"""
|
|
fd, path = tempfile.mkstemp(suffix=".db")
|
|
os.close(fd)
|
|
return path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Tables that are created by the Alembic migration chain.
|
|
# NOTE: ``llm_traces`` and ``audit_log`` are defined as ORM models but
|
|
# do not have dedicated migration scripts yet — they were historically
|
|
# created only via ``Base.metadata.create_all()``. They are excluded
|
|
# from this set until their migration scripts are added.
|
|
_EXPECTED_TABLES = {
|
|
"projects",
|
|
"plans",
|
|
"contexts",
|
|
"changes",
|
|
"debug_attempts",
|
|
"actors",
|
|
"actions",
|
|
"action_invariants",
|
|
"action_arguments",
|
|
"v3_plans",
|
|
"plan_projects",
|
|
"plan_arguments",
|
|
"plan_invariants",
|
|
"ns_projects",
|
|
"project_resource_links",
|
|
"resource_types",
|
|
"resources",
|
|
"resource_edges",
|
|
"resource_links",
|
|
"tools",
|
|
"tool_resource_bindings",
|
|
"validation_attachments",
|
|
"sessions",
|
|
"session_messages",
|
|
"automation_profiles",
|
|
"skills",
|
|
"skill_items",
|
|
"locks",
|
|
"decisions",
|
|
"decision_dependencies",
|
|
"checkpoint_metadata",
|
|
"changeset_entries",
|
|
"tool_invocations",
|
|
"async_jobs",
|
|
"repo_indexes",
|
|
"indexed_files",
|
|
}
|
|
|
|
|
|
def _get_table_names(engine: Any) -> set[str]:
|
|
inspector = sa_inspect(engine)
|
|
return set(inspector.get_table_names())
|
|
|
|
|
|
def _make_engine(url: str = "sqlite:///:memory:") -> Any:
|
|
from sqlalchemy import create_engine
|
|
|
|
return create_engine(url, connect_args={"check_same_thread": False})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a fresh in-memory database for migration lifecycle testing")
|
|
def step_fresh_in_memory_db(context: Any) -> None:
|
|
context.db_url = "sqlite:///:memory:"
|
|
context.engine = _make_engine(context.db_url)
|
|
# Ensure the migration runner module's engine cache is clean for this test
|
|
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
|
|
|
|
MEMORY_ENGINES.pop(context.db_url, None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("all migrations have been applied")
|
|
def step_all_migrations_applied(context: Any) -> None:
|
|
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
|
|
|
|
runner = MigrationRunner(context.db_url)
|
|
runner.run_migrations(engine=context.engine)
|
|
context.runner = runner
|
|
|
|
|
|
@given("a database with legacy tables but no alembic_version")
|
|
def step_legacy_db_no_alembic(context: Any) -> None:
|
|
from cleveragents.infrastructure.database.models import Base
|
|
|
|
# Create all tables via create_all (simulates pre-Alembic state)
|
|
Base.metadata.create_all(context.engine)
|
|
# Verify alembic_version does NOT exist yet
|
|
tables = _get_table_names(context.engine)
|
|
assert "alembic_version" not in tables
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I run all migrations forward to head")
|
|
def step_run_migrations_forward(context: Any) -> None:
|
|
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
|
|
|
|
runner = MigrationRunner(context.db_url)
|
|
runner.run_migrations(engine=context.engine)
|
|
context.runner = runner
|
|
|
|
|
|
@when("I downgrade all migrations to base")
|
|
def step_downgrade_to_base(context: Any) -> None:
|
|
from alembic import command
|
|
|
|
runner = context.runner
|
|
with context.engine.connect() as conn:
|
|
runner.alembic_cfg.attributes["connection"] = conn
|
|
try:
|
|
command.downgrade(runner.alembic_cfg, "base")
|
|
conn.commit()
|
|
finally:
|
|
runner.alembic_cfg.attributes.pop("connection", None)
|
|
|
|
|
|
@when('I downgrade to the initial migration "{revision}"')
|
|
def step_downgrade_to_revision(context: Any, revision: str) -> None:
|
|
from alembic import command
|
|
|
|
runner = context.runner
|
|
with context.engine.connect() as conn:
|
|
runner.alembic_cfg.attributes["connection"] = conn
|
|
try:
|
|
command.downgrade(runner.alembic_cfg, revision)
|
|
conn.commit()
|
|
finally:
|
|
runner.alembic_cfg.attributes.pop("connection", None)
|
|
|
|
|
|
@when("I upgrade back to head")
|
|
def step_upgrade_to_head(context: Any) -> None:
|
|
runner = context.runner
|
|
runner.run_migrations(engine=context.engine)
|
|
|
|
|
|
@when("I invoke the db upgrade CLI command")
|
|
def step_invoke_db_upgrade(context: Any) -> None:
|
|
|
|
from cleveragents.cli.main import main
|
|
|
|
# CLI commands create their own engine, so we must use a file-based
|
|
# DB to persist across engine instances.
|
|
db_path = _create_temp_db_path()
|
|
context.cli_db_path = db_path
|
|
cli_url = f"sqlite:///{db_path}"
|
|
with patch.dict(os.environ, {"CLEVERAGENTS_DATABASE_URL": cli_url}):
|
|
context.cli_exit_code = main(["db", "upgrade"])
|
|
# Re-read the DB to verify tables
|
|
context.engine = _make_engine(cli_url)
|
|
|
|
|
|
@when("I invoke the db current CLI command")
|
|
def step_invoke_db_current(context: Any) -> None:
|
|
|
|
from cleveragents.cli.main import main
|
|
|
|
# Use a file-based DB so the CLI sees the migrated schema
|
|
db_path = _create_temp_db_path()
|
|
cli_url = f"sqlite:///{db_path}"
|
|
# First apply migrations so there's something to report
|
|
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
|
|
|
|
MigrationRunner(cli_url).init_or_upgrade()
|
|
with patch.dict(os.environ, {"CLEVERAGENTS_DATABASE_URL": cli_url}):
|
|
context.cli_exit_code = main(["db", "current"])
|
|
|
|
|
|
@when('I invoke the db downgrade CLI command with revision "{revision}"')
|
|
def step_invoke_db_downgrade(context: Any, revision: str) -> None:
|
|
|
|
from cleveragents.cli.main import main
|
|
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
|
|
|
|
# Use a file-based DB so the CLI sees the migrated schema
|
|
db_path = _create_temp_db_path()
|
|
cli_url = f"sqlite:///{db_path}"
|
|
MigrationRunner(cli_url).init_or_upgrade()
|
|
with patch.dict(os.environ, {"CLEVERAGENTS_DATABASE_URL": cli_url}):
|
|
context.cli_exit_code = main(["db", "downgrade", revision])
|
|
|
|
|
|
@when("I run init_or_upgrade on the legacy database")
|
|
def step_init_or_upgrade_legacy(context: Any) -> None:
|
|
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
|
|
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
|
|
|
|
MEMORY_ENGINES[context.db_url] = context.engine
|
|
try:
|
|
runner = MigrationRunner(context.db_url)
|
|
runner.init_or_upgrade()
|
|
context.runner = runner
|
|
finally:
|
|
MEMORY_ENGINES.pop(context.db_url, None)
|
|
|
|
|
|
@when("I call init_database with an in-memory URL")
|
|
def step_call_init_database(context: Any) -> None:
|
|
|
|
from cleveragents.infrastructure.database.models import init_database
|
|
|
|
# Use a file-based temporary database so the engine can be reopened
|
|
# and the tables inspected after init_database returns.
|
|
db_path = _create_temp_db_path()
|
|
file_url = f"sqlite:///{db_path}"
|
|
context.result_engine = init_database(file_url)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the database should have all expected tables")
|
|
def step_db_has_expected_tables(context: Any) -> None:
|
|
tables = _get_table_names(context.engine)
|
|
missing = _EXPECTED_TABLES - tables
|
|
assert not missing, f"Missing tables: {missing}"
|
|
|
|
|
|
@then("the current revision should be the head revision")
|
|
def step_current_is_head(context: Any) -> None:
|
|
from alembic.script import ScriptDirectory
|
|
|
|
runner = context.runner
|
|
head = ScriptDirectory.from_config(runner.alembic_cfg).get_current_head()
|
|
|
|
from alembic.runtime.migration import MigrationContext
|
|
|
|
with context.engine.connect() as conn:
|
|
current = MigrationContext.configure(conn).get_current_revision()
|
|
|
|
assert current == head, f"Expected {head}, got {current}"
|
|
|
|
|
|
@then("the current revision should be None")
|
|
def step_current_is_none(context: Any) -> None:
|
|
from alembic.runtime.migration import MigrationContext
|
|
|
|
with context.engine.connect() as conn:
|
|
current = MigrationContext.configure(conn).get_current_revision()
|
|
assert current is None, f"Expected None, got {current}"
|
|
|
|
|
|
@then("the database should have no application tables")
|
|
def step_db_has_no_app_tables(context: Any) -> None:
|
|
tables = _get_table_names(context.engine)
|
|
# Only alembic_version may remain after full downgrade
|
|
app_tables = tables - {"alembic_version"}
|
|
assert not app_tables, f"Unexpected tables remain: {app_tables}"
|
|
|
|
|
|
@then('the current revision should be "{expected}"')
|
|
def step_current_revision_is(context: Any, expected: str) -> None:
|
|
from alembic.runtime.migration import MigrationContext
|
|
|
|
with context.engine.connect() as conn:
|
|
current = MigrationContext.configure(conn).get_current_revision()
|
|
assert current == expected, f"Expected {expected}, got {current}"
|
|
|
|
|
|
@then("the CLI should report a successful upgrade")
|
|
def step_cli_upgrade_success(context: Any) -> None:
|
|
assert context.cli_exit_code == 0, f"CLI exited with code {context.cli_exit_code}"
|
|
|
|
|
|
@then("the CLI should display the current revision")
|
|
def step_cli_displays_revision(context: Any) -> None:
|
|
assert context.cli_exit_code == 0, f"CLI exited with code {context.cli_exit_code}"
|
|
|
|
|
|
@then("the CLI should report a successful downgrade")
|
|
def step_cli_downgrade_success(context: Any) -> None:
|
|
assert context.cli_exit_code == 0, f"CLI exited with code {context.cli_exit_code}"
|
|
|
|
|
|
@then("the database should be stamped with alembic_version")
|
|
def step_db_stamped_with_alembic(context: Any) -> None:
|
|
tables = _get_table_names(context.engine)
|
|
assert "alembic_version" in tables, f"alembic_version not found in {tables}"
|
|
|
|
|
|
@then("the database revision should be at head")
|
|
def step_db_revision_at_head(context: Any) -> None:
|
|
from alembic.runtime.migration import MigrationContext
|
|
from alembic.script import ScriptDirectory
|
|
|
|
runner = context.runner
|
|
head = ScriptDirectory.from_config(runner.alembic_cfg).get_current_head()
|
|
with context.engine.connect() as conn:
|
|
current = MigrationContext.configure(conn).get_current_revision()
|
|
assert current == head, f"Expected head {head}, got {current}"
|
|
|
|
|
|
@then("the resulting engine should have a valid schema")
|
|
def step_engine_has_valid_schema(context: Any) -> None:
|
|
tables = _get_table_names(context.result_engine)
|
|
# At minimum, core tables should exist
|
|
assert "actors" in tables or "projects" in tables, f"Got tables: {tables}"
|
|
|
|
|
|
@then("the alembic_version table should exist")
|
|
def step_alembic_version_exists(context: Any) -> None:
|
|
tables = _get_table_names(context.result_engine)
|
|
assert "alembic_version" in tables, f"alembic_version not in {tables}"
|