7235d46ade
CI / quality (pull_request) Successful in 19s
CI / lint (pull_request) Successful in 21s
CI / benchmark-publish (pull_request) Has been skipped
CI / security (pull_request) Successful in 50s
CI / typecheck (pull_request) Successful in 58s
CI / build (pull_request) Successful in 29s
CI / integration_tests (pull_request) Successful in 4m16s
CI / unit_tests (pull_request) Successful in 12m18s
CI / docker (pull_request) Successful in 1m30s
CI / benchmark-regression (pull_request) Successful in 25m15s
CI / coverage (pull_request) Successful in 1h21m51s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 17s
CI / lint (push) Successful in 21s
CI / security (push) Successful in 28s
CI / typecheck (push) Successful in 31s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 2m47s
CI / unit_tests (push) Successful in 11m0s
CI / docker (push) Successful in 40s
CI / benchmark-publish (push) Successful in 12m22s
CI / coverage (push) Successful in 44m42s
Add Alembic migration m4_002_skill_flattened_tools to extend the skills table with five new columns: flattened_tools_json, includes_json, capability_summary_json, yaml_text, and flattening_hash (SHA-256). A defence-in-depth uniqueness constraint (uq_skills_name) is also added. Update SkillModel with the new column definitions and extend SkillRepository with update_flattened_tools(), get_flattened_tools(), needs_refresh(), recompute_flattening_hash(), and invalidate_cached_summaries() methods. The existing update() method now nulls all cached fields on mutation (hash-based invalidation). All new repository methods follow the session-factory pattern with @database_retry and flush-but-don-t-commit semantics. Structured logging via structlog records cache updates and invalidations. Database schema docs updated with the new skills table columns and a persistence-field-to-domain-model mapping table. Tests: - 6 Behave scenarios covering create, invalidation, hash staleness, refresh recomputation, uniqueness constraint, and namespace filtering - 2 Robot Framework smoke tests (round-trip and invalidation) - 3 ASV benchmarks (persist, refresh check, namespace list) ISSUES CLOSED: #166
276 lines
9.4 KiB
Python
276 lines
9.4 KiB
Python
"""Step definitions for Skill Registry flattened tool persistence.
|
|
|
|
Covers the m4_002 columns: ``flattened_tools_json``, ``includes_json``,
|
|
``capability_summary_json``, ``yaml_text``, ``flattening_hash`` and
|
|
the related ``SkillRepository`` helper methods.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from cleveragents.domain.models.core.skill import Skill
|
|
from cleveragents.infrastructure.database.models import Base
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
DuplicateSkillError,
|
|
SkillRepository,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _enable_fk_pragma(dbapi_conn: Any, _connection_record: Any) -> None:
|
|
"""Enable SQLite foreign key enforcement per connection."""
|
|
cursor = dbapi_conn.cursor()
|
|
cursor.execute("PRAGMA foreign_keys=ON")
|
|
cursor.close()
|
|
|
|
|
|
def _factory(context: Context) -> Callable[[], Session]:
|
|
return context.sp_session_factory # type: ignore[no-any-return]
|
|
|
|
|
|
def _commit(context: Context) -> None:
|
|
_factory(context)().commit()
|
|
|
|
|
|
def _make_skill(name: str, description: str = "Test skill") -> Skill:
|
|
return Skill(name=name, description=description)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a clean in-memory database with the skill persistence schema")
|
|
def step_clean_db(context: Context) -> None:
|
|
engine = create_engine("sqlite:///:memory:", echo=False)
|
|
event.listen(engine, "connect", _enable_fk_pragma)
|
|
Base.metadata.create_all(engine)
|
|
|
|
shared_session: Session = sessionmaker(bind=engine)()
|
|
|
|
def _sf() -> Session:
|
|
return shared_session
|
|
|
|
context.sp_engine = engine
|
|
context.sp_session_factory = _sf
|
|
context.sp_error: BaseException | None = None
|
|
context.sp_recomputed_hash: str | None = None
|
|
context.sp_listed: list[Skill] = []
|
|
|
|
|
|
@given("a skill persistence repository")
|
|
def step_repo(context: Context) -> None:
|
|
context.sp_repo = SkillRepository(session_factory=_factory(context))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skill creation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a skill "{name}" with description "{desc}"')
|
|
def step_skill(context: Context, name: str, desc: str) -> None:
|
|
context.sp_current_skill = _make_skill(name, desc)
|
|
|
|
|
|
@when("the skill is created via the persistence repository")
|
|
def step_create(context: Context) -> None:
|
|
context.sp_repo.create(context.sp_current_skill)
|
|
_commit(context)
|
|
|
|
|
|
@given("the skill is created via the persistence repository")
|
|
def step_create_given(context: Context) -> None:
|
|
context.sp_repo.create(context.sp_current_skill)
|
|
_commit(context)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flattened tools update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the flattened tools are updated with tools json '{tools}' and hash \"{h}\"")
|
|
def step_update_flat(context: Context, tools: str, h: str) -> None:
|
|
name = context.sp_current_skill.name
|
|
context.sp_repo.update_flattened_tools(
|
|
name=name,
|
|
flattened_tools_json=tools,
|
|
includes_json="[]",
|
|
capability_summary_json="{}",
|
|
yaml_text="name: " + name,
|
|
flattening_hash=h,
|
|
)
|
|
_commit(context)
|
|
|
|
|
|
@given("the flattened tools are updated with tools json '{tools}' and hash \"{h}\"")
|
|
def step_update_flat_given(context: Context, tools: str, h: str) -> None:
|
|
name = context.sp_current_skill.name
|
|
context.sp_repo.update_flattened_tools(
|
|
name=name,
|
|
flattened_tools_json=tools,
|
|
includes_json="[]",
|
|
capability_summary_json="{}",
|
|
yaml_text="name: " + name,
|
|
flattening_hash=h,
|
|
)
|
|
_commit(context)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Assertions on stored fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the stored flattened tools json should be '{expected}'")
|
|
def step_check_tools(context: Context, expected: str) -> None:
|
|
data = context.sp_repo.get_flattened_tools(context.sp_current_skill.name)
|
|
assert data["flattened_tools_json"] == expected, (
|
|
f"Expected {expected!r}, got {data['flattened_tools_json']!r}"
|
|
)
|
|
|
|
|
|
@then('the stored flattening hash should be "{expected}"')
|
|
def step_check_hash(context: Context, expected: str) -> None:
|
|
data = context.sp_repo.get_flattened_tools(context.sp_current_skill.name)
|
|
assert data["flattening_hash"] == expected, (
|
|
f"Expected {expected!r}, got {data['flattening_hash']!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Update & invalidation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the skill "{name}" is updated with description "{desc}"')
|
|
def step_update_skill(context: Context, name: str, desc: str) -> None:
|
|
updated = _make_skill(name, desc)
|
|
context.sp_repo.update(updated)
|
|
_commit(context)
|
|
|
|
|
|
@then('the stored flattened tools json for "{name}" should be null')
|
|
def step_check_tools_null(context: Context, name: str) -> None:
|
|
data = context.sp_repo.get_flattened_tools(name)
|
|
assert data["flattened_tools_json"] is None, (
|
|
f"Expected None, got {data['flattened_tools_json']!r}"
|
|
)
|
|
|
|
|
|
@then('the stored capability summary json for "{name}" should be null')
|
|
def step_check_cap_null(context: Context, name: str) -> None:
|
|
data = context.sp_repo.get_flattened_tools(name)
|
|
assert data["capability_summary_json"] is None, (
|
|
f"Expected None, got {data['capability_summary_json']!r}"
|
|
)
|
|
|
|
|
|
@then('the stored flattening hash for "{name}" should be null')
|
|
def step_check_hash_null(context: Context, name: str) -> None:
|
|
data = context.sp_repo.get_flattened_tools(name)
|
|
assert data["flattening_hash"] is None, (
|
|
f"Expected None, got {data['flattening_hash']!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hash-based staleness
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the skill "{name}" should not need refresh for hash "{h}"')
|
|
def step_no_refresh(context: Context, name: str, h: str) -> None:
|
|
assert not context.sp_repo.needs_refresh(name, h)
|
|
|
|
|
|
@then('the skill "{name}" should need refresh for hash "{h}"')
|
|
def step_needs_refresh(context: Context, name: str, h: str) -> None:
|
|
assert context.sp_repo.needs_refresh(name, h)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recompute
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the flattening hash for "{name}" is recomputed from yaml "{yaml}"')
|
|
def step_recompute(context: Context, name: str, yaml: str) -> None:
|
|
context.sp_recomputed_hash = context.sp_repo.recompute_flattening_hash(name, yaml)
|
|
_commit(context)
|
|
|
|
|
|
@then('the stored flattening hash for "{name}" should not be null')
|
|
def step_hash_not_null(context: Context, name: str) -> None:
|
|
data = context.sp_repo.get_flattened_tools(name)
|
|
assert data["flattening_hash"] is not None
|
|
|
|
|
|
@then('the skill "{name}" should not need refresh for the recomputed hash')
|
|
def step_no_refresh_recomputed(context: Context, name: str) -> None:
|
|
h = context.sp_recomputed_hash
|
|
assert h is not None, "Recomputed hash is None"
|
|
assert not context.sp_repo.needs_refresh(name, h)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Uniqueness constraint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('a second skill "{name}" with description "{desc}" is created')
|
|
def step_create_duplicate(context: Context, name: str, desc: str) -> None:
|
|
try:
|
|
context.sp_repo.create(_make_skill(name, desc))
|
|
_commit(context)
|
|
context.sp_error = None
|
|
except DuplicateSkillError as exc:
|
|
context.sp_error = exc
|
|
except Exception as exc:
|
|
cause = exc.__cause__ or exc
|
|
if isinstance(cause, DuplicateSkillError):
|
|
context.sp_error = cause
|
|
else:
|
|
context.sp_error = exc
|
|
|
|
|
|
@then("the second creation should raise a duplicate error")
|
|
def step_check_dup(context: Context) -> None:
|
|
assert isinstance(context.sp_error, DuplicateSkillError), (
|
|
f"Expected DuplicateSkillError, got {type(context.sp_error)}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Namespace filtering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('skills are listed with namespace "{ns}"')
|
|
def step_list_ns(context: Context, ns: str) -> None:
|
|
context.sp_listed = context.sp_repo.list_all(namespace=ns)
|
|
|
|
|
|
@then('the listed skills should contain "{name}"')
|
|
def step_list_contains(context: Context, name: str) -> None:
|
|
names = [s.name for s in context.sp_listed]
|
|
assert name in names, f"{name} not in {names}"
|
|
|
|
|
|
@then('the listed skills should not contain "{name}"')
|
|
def step_list_not_contains(context: Context, name: str) -> None:
|
|
names = [s.name for s in context.sp_listed]
|
|
assert name not in names, f"{name} unexpectedly in {names}"
|