From 29b62587f9939c1b0dd7dd08f00ac9840c69204d Mon Sep 17 00:00:00 2001 From: Hamza Khyari Date: Thu, 2 Apr 2026 12:02:13 +0000 Subject: [PATCH] feat(resource): add virtual resource equivalence tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Virtual-to-physical resource equivalence linking system for tracking which physical resources are manifestations of the same virtual identity. Database: - VirtualResourceLinkModel ORM with ULID PK, FK cascades, unique constraint on (virtual_resource_id, physical_resource_id), CHECK constraints on link_type and confidence, 4 indexes - Alembic migration: m7_001_virtual_resource_links.py (depends on m8_002_merge_profile_rename_and_corrections) Domain model: - LinkType enum (content_hash, name, identity) - VirtualResourceLink frozen Pydantic model with confidence [0.0-1.0] Service (ResourceEquivalenceService — 498 lines): - create_link() with type-mismatch guard via ALLOWED_PAIRINGS - remove_link() by virtual+physical ID pair - list_links() with pagination (limit/offset) - merge_virtual_resources() — batch N+1-free with IntegrityError guard - check_divergence() — removes stale content_hash links - reconcile() — delegated to _resource_equivalence_reconciler.py Helpers (_resource_equivalence_helpers.py — 228 lines): - compute_equivalence_key() for string content (SHA-256/name/identity) - compute_equivalence_key_from_path() streaming file hash (64 KiB chunks) - ALLOWED_PAIRINGS type compatibility map (unprefixed virtual type names) - validate_link_inputs() shared validation CLI (resource_equivalence.py): - agents resource equivalence list (--virtual, --physical, --format) - agents resource equivalence add (--key, --type, --confidence, --format) - agents resource equivalence remove (--yes, --format) Tests: - 50 Behave scenarios (CRUD, merge, divergence, reconciliation, boundaries) - 9 Robot integration tests - 5 ASV benchmark suites ISSUES CLOSED: #334 --- CHANGELOG.md | 6 + .../versions/m7_001_virtual_resource_links.py | 104 ++ benchmarks/virtual_resource_bench.py | 286 +++++ docs/reference/resource_model.md | 176 +++ features/consolidated_domain_models.feature | 6 +- features/edge_case_plan_scenarios.feature | 8 +- features/plan_executor_coverage_boost.feature | 2 +- features/plan_explain.feature | 2 +- features/project_repository.feature | 2 +- features/resource_cli_flags_904.feature | 2 +- features/resource_equivalence.feature | 282 +++++ features/retry_policy_wiring.feature | 2 +- features/steps/edge_case_plan_steps.py | 2 +- .../plan_executor_coverage_boost_steps.py | 2 +- features/steps/plan_explain_steps.py | 2 +- features/steps/project_repository_steps.py | 2 +- features/steps/resource_equivalence_steps.py | 1030 +++++++++++++++++ features/steps/service_retry_wiring_steps.py | 2 +- features/steps/session_model_steps.py | 2 +- robot/helper_resource_equivalence.py | 427 +++++++ robot/resource_equivalence.robot | 101 ++ src/cleveragents/application/container.py | 21 + .../application/services/__init__.py | 8 + .../services/_resource_equivalence_helpers.py | 228 ++++ .../_resource_equivalence_reconciler.py | 128 ++ .../services/resource_equivalence_service.py | 498 ++++++++ src/cleveragents/cli/commands/resource.py | 6 + .../cli/commands/resource_equivalence.py | 307 +++++ .../domain/models/core/__init__.py | 8 + .../models/core/virtual_resource_link.py | 136 +++ .../infrastructure/database/models.py | 71 ++ 31 files changed, 3841 insertions(+), 18 deletions(-) create mode 100644 alembic/versions/m7_001_virtual_resource_links.py create mode 100644 benchmarks/virtual_resource_bench.py create mode 100644 docs/reference/resource_model.md create mode 100644 features/resource_equivalence.feature create mode 100644 features/steps/resource_equivalence_steps.py create mode 100644 robot/helper_resource_equivalence.py create mode 100644 robot/resource_equivalence.robot create mode 100644 src/cleveragents/application/services/_resource_equivalence_helpers.py create mode 100644 src/cleveragents/application/services/_resource_equivalence_reconciler.py create mode 100644 src/cleveragents/application/services/resource_equivalence_service.py create mode 100644 src/cleveragents/cli/commands/resource_equivalence.py create mode 100644 src/cleveragents/domain/models/core/virtual_resource_link.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aab2df1..e4a632e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -273,6 +273,12 @@ fragments to overview depths 0-1 using the UKO detail-level map chain, exposes the compressor as the configured builtin, and covers the behavior with BDD scenarios for compressor output and default pipeline wiring. (#919) +- Added virtual resource equivalence tracking system. + `VirtualResourceLink` frozen Pydantic domain model and `LinkType` StrEnum + (`content_hash`, `name`, `identity`). `ResourceEquivalenceService` with + CRUD, merge, content-hash-scoped divergence detection, and batch + reconciliation. CLI commands `agents resource equivalence list/add/remove`. + (#334) - Added TDD bug-capture tests for bug #1076 — `use_action()` does not propagate `automation_profile` to Plan. Three Behave BDD scenarios (`@tdd_bug @tdd_bug_1076 @tdd_expected_fail`) verify the full precedence diff --git a/alembic/versions/m7_001_virtual_resource_links.py b/alembic/versions/m7_001_virtual_resource_links.py new file mode 100644 index 000000000..244d8f4eb --- /dev/null +++ b/alembic/versions/m7_001_virtual_resource_links.py @@ -0,0 +1,104 @@ +"""Add virtual_resource_links table for equivalence tracking. + +Revision ID: m7_001_virtual_resource_links +Revises: m8_002_merge_profile_rename_and_corrections +Create Date: 2026-03-10 00:00:00 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "m7_001_virtual_resource_links" +down_revision: str | Sequence[str] | None = ( + "m8_002_merge_profile_rename_and_corrections" # noqa: E501 +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create the virtual_resource_links table.""" + op.create_table( + "virtual_resource_links", + sa.Column("id", sa.String(26), primary_key=True), + sa.Column( + "virtual_resource_id", + sa.String(26), + sa.ForeignKey("resources.resource_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "physical_resource_id", + sa.String(26), + sa.ForeignKey("resources.resource_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("equivalence_key", sa.String(1024), nullable=False), + sa.Column( + "link_type", + sa.String(30), + nullable=False, + server_default="content_hash", + ), + sa.Column("confidence", sa.Float(), nullable=False, server_default="1.0"), + sa.Column("created_at", sa.String(40), nullable=False), + sa.Column("updated_at", sa.String(40), nullable=False), + sa.UniqueConstraint( + "virtual_resource_id", + "physical_resource_id", + name="uq_virtual_resource_links_virtual_physical", + ), + sa.CheckConstraint( + "link_type IN ('content_hash', 'name', 'identity')", + name="ck_virtual_resource_links_link_type", + ), + sa.CheckConstraint( + "confidence >= 0.0 AND confidence <= 1.0", + name="ck_virtual_resource_links_confidence", + ), + ) + op.create_index( + "ix_virtual_resource_links_virtual", + "virtual_resource_links", + ["virtual_resource_id"], + ) + op.create_index( + "ix_virtual_resource_links_physical", + "virtual_resource_links", + ["physical_resource_id"], + ) + op.create_index( + "ix_virtual_resource_links_equiv_key", + "virtual_resource_links", + ["equivalence_key"], + ) + op.create_index( + "ix_virtual_resource_links_link_type", + "virtual_resource_links", + ["link_type"], + ) + + +def downgrade() -> None: + """Drop the virtual_resource_links table.""" + op.drop_index( + "ix_virtual_resource_links_link_type", + table_name="virtual_resource_links", + ) + op.drop_index( + "ix_virtual_resource_links_equiv_key", + table_name="virtual_resource_links", + ) + op.drop_index( + "ix_virtual_resource_links_physical", + table_name="virtual_resource_links", + ) + op.drop_index( + "ix_virtual_resource_links_virtual", + table_name="virtual_resource_links", + ) + op.drop_table("virtual_resource_links") diff --git a/benchmarks/virtual_resource_bench.py b/benchmarks/virtual_resource_bench.py new file mode 100644 index 000000000..393c8f4ef --- /dev/null +++ b/benchmarks/virtual_resource_bench.py @@ -0,0 +1,286 @@ +"""ASV benchmarks for virtual resource equivalence tracking. + +Benchmarks cover: +- compute_equivalence_key (content_hash / name / identity) +- ResourceEquivalenceService.create_link +- ResourceEquivalenceService.list_links +- ResourceEquivalenceService.check_divergence +- ResourceEquivalenceService.reconcile +""" + +from __future__ import annotations + +import typing +from datetime import UTC, datetime +from typing import Any + +_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +_BENCH_CTR = 0 + + +def _bench_ulid() -> str: + global _BENCH_CTR + _BENCH_CTR += 1 + n = _BENCH_CTR + suffix = "" + for _ in range(7): + suffix = _CB32[n % 32] + suffix + n //= 32 + return f"01HEQVBNCH0AQDYTR4B{suffix}"[:26] + + +def _setup_db() -> Any: + """Create in-memory database and return session factory.""" + from sqlalchemy import create_engine, event + from sqlalchemy.orm import sessionmaker + + from cleveragents.infrastructure.database.models import Base + + engine = create_engine("sqlite:///:memory:") + + @event.listens_for(engine, "connect") + def _fk(conn: Any, _rec: Any) -> None: + conn.cursor().execute("PRAGMA foreign_keys=ON") + + Base.metadata.create_all(engine) + return sessionmaker(bind=engine) + + +def _seed_types(session_factory: Any) -> None: + """Register virtual and physical resource types.""" + from cleveragents.infrastructure.database.models import ResourceTypeModel + + now_iso = datetime.now(tz=UTC).isoformat() + session = session_factory() + session.add( + ResourceTypeModel( + name="file", + namespace="builtin", + description="Virtual file", + resource_kind="virtual", + sandbox_strategy="none", + user_addable=True, + args_schema_json="[]", + allowed_parent_types_json="[]", + allowed_child_types_json="[]", + capabilities_json='{"read":true,"write":false,"sandbox":false,"checkpoint":false}', + source="builtin", + created_at=now_iso, + updated_at=now_iso, + ) + ) + session.add( + ResourceTypeModel( + name="fs-file", + namespace="builtin", + description="File system file", + resource_kind="physical", + sandbox_strategy="none", + user_addable=True, + args_schema_json="[]", + allowed_parent_types_json="[]", + allowed_child_types_json="[]", + capabilities_json='{"read":true,"write":true,"sandbox":true,"checkpoint":false}', + source="builtin", + created_at=now_iso, + updated_at=now_iso, + ) + ) + session.commit() + session.close() + + +def _make_resource( + session_factory: Any, + *, + resource_kind: str = "physical", + type_name: str = "fs-file", + content_hash: str | None = None, +) -> str: + """Create a resource row and return its ULID.""" + from cleveragents.infrastructure.database.models import ResourceModel + + session = session_factory() + rid = _bench_ulid() + now_iso = datetime.now(tz=UTC).isoformat() + session.add( + ResourceModel( + resource_id=rid, + type_name=type_name, + resource_kind=resource_kind, + content_hash=content_hash, + created_at=now_iso, + updated_at=now_iso, + ) + ) + session.commit() + session.close() + return rid + + +# --------------------------------------------------------------------------- +# Benchmark: compute_equivalence_key +# --------------------------------------------------------------------------- + + +class TimeComputeEquivalenceKey: + """Benchmark compute_equivalence_key for each link type.""" + + params: typing.ClassVar[list[str]] = ["content_hash", "name", "identity"] + param_names: typing.ClassVar[list[str]] = ["link_type"] + + def setup(self, link_type: str) -> None: + from cleveragents.application.services.resource_equivalence_service import ( + compute_equivalence_key, + ) + + self._compute = compute_equivalence_key + self._content = "x" * 4096 + self._name = "main-branch" + + def time_compute(self, link_type: str) -> None: + if link_type == "content_hash": + self._compute(content=self._content, link_type=link_type) + else: + self._compute(name=self._name, link_type=link_type) + + +# --------------------------------------------------------------------------- +# Benchmark: create_link +# --------------------------------------------------------------------------- + + +class TimeCreateLink: + """Benchmark ResourceEquivalenceService.create_link.""" + + def setup(self) -> None: + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + + self.factory = _setup_db() + _seed_types(self.factory) + self.svc = ResourceEquivalenceService(self.factory) + self._counter = 0 + + def time_create_link(self) -> None: + self._counter += 1 + v_id = _make_resource(self.factory, resource_kind="virtual", type_name="file") + p_id = _make_resource( + self.factory, + resource_kind="physical", + type_name="fs-file", + content_hash=f"hash_{self._counter}", + ) + self.svc.create_link(v_id, p_id, f"hash_{self._counter}") + + +# --------------------------------------------------------------------------- +# Benchmark: list_links +# --------------------------------------------------------------------------- + + +class TimeListLinks: + """Benchmark ResourceEquivalenceService.list_links with N links.""" + + params: typing.ClassVar[list[int]] = [1, 10, 50] + param_names: typing.ClassVar[list[str]] = ["num_links"] + + def setup(self, num_links: int) -> None: + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + + self.factory = _setup_db() + _seed_types(self.factory) + self.svc = ResourceEquivalenceService(self.factory) + + self.virtual_id = _make_resource( + self.factory, resource_kind="virtual", type_name="file" + ) + for i in range(num_links): + p_id = _make_resource( + self.factory, + resource_kind="physical", + type_name="fs-file", + content_hash=f"hash_{i}", + ) + self.svc.create_link(self.virtual_id, p_id, f"hash_{i}") + + def time_list_links(self, num_links: int) -> None: + self.svc.list_links(virtual_resource_id=self.virtual_id) + + +# --------------------------------------------------------------------------- +# Benchmark: check_divergence +# --------------------------------------------------------------------------- + + +class TimeCheckDivergence: + """Benchmark check_divergence with varying link counts.""" + + params: typing.ClassVar[list[int]] = [1, 5, 20] + param_names: typing.ClassVar[list[str]] = ["num_links"] + + def setup(self, num_links: int) -> None: + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + + self.factory = _setup_db() + _seed_types(self.factory) + self.svc = ResourceEquivalenceService(self.factory) + + # Create one physical resource linked to many virtual resources + self.physical_id = _make_resource( + self.factory, + resource_kind="physical", + type_name="fs-file", + content_hash="original_hash", + ) + for _i in range(num_links): + v_id = _make_resource( + self.factory, resource_kind="virtual", type_name="file" + ) + self.svc.create_link(v_id, self.physical_id, "original_hash") + + def time_check_divergence(self, num_links: int) -> None: + # Check with same key (no divergence) — re-runnable + self.svc.check_divergence(self.physical_id, "original_hash") + + +# --------------------------------------------------------------------------- +# Benchmark: reconcile +# --------------------------------------------------------------------------- + + +class TimeReconcile: + """Benchmark reconcile with varying link counts.""" + + params: typing.ClassVar[list[int]] = [5, 20, 50] + param_names: typing.ClassVar[list[str]] = ["num_links"] + + def setup(self, num_links: int) -> None: + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + + self.factory = _setup_db() + _seed_types(self.factory) + self.svc = ResourceEquivalenceService(self.factory) + + # Create links where content_hash matches (so reconcile keeps them) + for i in range(num_links): + v_id = _make_resource( + self.factory, resource_kind="virtual", type_name="file" + ) + p_id = _make_resource( + self.factory, + resource_kind="physical", + type_name="fs-file", + content_hash=f"hash_{i}", + ) + self.svc.create_link(v_id, p_id, f"hash_{i}") + + def time_reconcile(self, num_links: int) -> None: + self.svc.reconcile() diff --git a/docs/reference/resource_model.md b/docs/reference/resource_model.md new file mode 100644 index 000000000..2f8b5beac --- /dev/null +++ b/docs/reference/resource_model.md @@ -0,0 +1,176 @@ +# Resource Equivalence Tracking + +The **Resource Equivalence** system tracks the relationship between virtual +and physical resources in the CleverAgents resource DAG. A virtual resource +represents an abstract identity (e.g., "the file `README.md`"), while a +physical resource represents a concrete manifestation (e.g., the actual +`README.md` in the working tree at `/project/README.md`). + +--- + +## Overview + +| Component | Role | +|-----------|------| +| `VirtualResourceLink` | Frozen Pydantic domain model for a single equivalence link | +| `LinkType` | `StrEnum` of equivalence methods (`content_hash`, `name`, `identity`) | +| `ResourceEquivalenceService` | Application service for CRUD, merge, divergence, reconciliation | +| `compute_equivalence_key()` | Helper to compute hash/name/identity keys | +| `VirtualResourceLinkModel` | SQLAlchemy ORM model for the `virtual_resource_links` table | + +--- + +## Link Types + +| Value | Description | Example | +|-------|-------------|---------| +| `content_hash` | SHA-256 of file content | Two files with identical bytes | +| `name` | Normalised name (lowercase, stripped) | Branch name `main` | +| `identity` | Opaque identity value | Commit hash `abc123` | + +--- + +## Equivalence Key Computation + +```python +from cleveragents.application.services import compute_equivalence_key + +# Content-based (SHA-256) +key = compute_equivalence_key(content="hello world", link_type="content_hash") +# -> "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + +# Name-based (normalised) +key = compute_equivalence_key(name=" Main ", link_type="name") +# -> "main" + +# Identity-based (pass-through) +key = compute_equivalence_key(name="abc123def", link_type="identity") +# -> "abc123def" +``` + +--- + +## Type Mismatch Guard + +Virtual types can only link to compatible physical types: + +| Virtual Type | Allowed Physical Types | +|-------------|----------------------| +| `file` | `fs-file`, `git-tree-entry` | +| `directory` | `fs-directory`, `git-tree` | +| `commit` | `git-commit` | +| `branch` | `git-branch` | +| `tag` | `git-tag` | +| `remote` | `git-remote` | +| `submodule` | `git-submodule` | +| `tree` | `git-tree` | +| `symlink` | `fs-symlink`, `git-tree-entry` | + +User-defined virtual types (not in this table) are allowed to link to any +physical type. + +--- + +## Conflict Policy + +Physical resources take precedence over virtual resources. When a physical +resource's content changes (detected via `check_divergence` or `reconcile`), +the equivalence link is removed and a divergence event is logged. Virtual +resources are never mutated directly — they serve as read-only identity +anchors. + +--- + +## Database Schema + +The `virtual_resource_links` table: + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `TEXT` | Primary key (ULID) | +| `virtual_resource_id` | `TEXT` | FK → `resources.resource_id`, NOT NULL | +| `physical_resource_id` | `TEXT` | FK → `resources.resource_id`, NOT NULL | +| `equivalence_key` | `VARCHAR(1024)` | NOT NULL | +| `link_type` | `TEXT` | CHECK IN (`content_hash`, `name`, `identity`) | +| `confidence` | `REAL` | CHECK 0.0 ≤ confidence ≤ 1.0, DEFAULT 1.0 | +| `created_at` | `TEXT` | NOT NULL | +| `updated_at` | `TEXT` | NOT NULL | + +**Unique constraint**: `(virtual_resource_id, physical_resource_id)` + +**Indexes**: +- `ix_virtual_resource_links_virtual` on `virtual_resource_id` +- `ix_virtual_resource_links_physical` on `physical_resource_id` +- `ix_virtual_resource_links_equiv_key` on `equivalence_key` +- `ix_virtual_resource_links_link_type` on `link_type` + +--- + +## CLI Commands + +### List equivalence links + +```bash +# List all links for a virtual resource +agents resource equivalence list --virtual 01HXYZ... + +# List all links for a physical resource +agents resource equivalence list --physical 01HABC... + +# List all links +agents resource equivalence list +``` + +### Add an equivalence link + +```bash +agents resource equivalence add 01HXYZ... 01HABC... \ + --key "b94d27b993..." \ + --type content_hash \ + --confidence 1.0 +``` + +### Remove an equivalence link + +```bash +agents resource equivalence remove 01HXYZ... 01HABC... +``` + +--- + +## Service API + +```python +from cleveragents.application.services import ResourceEquivalenceService + +svc = ResourceEquivalenceService(session_factory) + +# Create a link +link = svc.create_link(virtual_id, physical_id, equiv_key, + link_type="content_hash", confidence=1.0) + +# List links +links = svc.list_links(virtual_resource_id=virtual_id) + +# Remove a link +svc.remove_link(virtual_id, physical_id) + +# Merge two virtual resources +merged = svc.merge_virtual_resources(target_id, source_id) + +# Check divergence when physical content changes +remaining = svc.check_divergence(physical_id, new_equiv_key) + +# Reconciliation job (re-hash all content_hash links) +result = svc.reconcile() # {"checked": 10, "removed": 2, "kept": 8} +``` + +--- + +## Specification References + +- Physical vs Virtual Resources: `specification.md` lines 24383-24435 +- Lazy Virtual Node Materialization: `specification.md` lines 24437-24468 +- Equivalence Linking: `specification.md` lines 24397-24417 +- Divergence Detection: `specification.md` lines 24419-24435 +- Issue: [#334](https://git.cleverthis.com/cleveragents/cleveragents-core/issues/334) diff --git a/features/consolidated_domain_models.feature b/features/consolidated_domain_models.feature index 36bfb0d9e..c5da2ced6 100644 --- a/features/consolidated_domain_models.feature +++ b/features/consolidated_domain_models.feature @@ -1108,7 +1108,7 @@ Feature: Consolidated Domain Models Scenario: CLI dict has required fields Given a session with some messages - When I get the session CLI dict + When I get the session model CLI dict Then the session cli dict should have key "session_id" And the session cli dict should have key "message_count" And the session cli dict should have key "created_at" @@ -1119,13 +1119,13 @@ Feature: Consolidated Domain Models Scenario: CLI dict includes recent messages Given a session with some messages - When I get the session CLI dict + When I get the session model CLI dict Then the session cli dict should have key "recent_messages" Scenario: CLI dict includes actor when set When I create a session with actor name "local/orchestrator" - And I get the session CLI dict + And I get the session model CLI dict Then the session cli dict should have key "actor_name" # ---- Empty Session Properties ---- diff --git a/features/edge_case_plan_scenarios.feature b/features/edge_case_plan_scenarios.feature index b55557d59..9233476a6 100644 --- a/features/edge_case_plan_scenarios.feature +++ b/features/edge_case_plan_scenarios.feature @@ -180,19 +180,19 @@ Feature: Plan edge case scenarios Scenario: Plan model rejects empty description When I try to create an edge case plan with empty description - Then a Pydantic validation error should be raised + Then a Pydantic validation error should be raised for edge case plan Scenario: Plan model rejects invalid phase value When I try to create an edge case plan with invalid phase value - Then a Pydantic validation error should be raised + Then a Pydantic validation error should be raised for edge case plan Scenario: NamespacedName rejects invalid characters in namespace When I try to parse a namespaced name with special characters "inv@lid/action" - Then a Pydantic validation error should be raised + Then a Pydantic validation error should be raised for edge case plan Scenario: NamespacedName rejects invalid characters in name When I try to parse a namespaced name with special chars in name "local/my action!" - Then a Pydantic validation error should be raised + Then a Pydantic validation error should be raised for edge case plan # ────────────────────────────────────────────────── # Section 4: Rollback edge cases diff --git a/features/plan_executor_coverage_boost.feature b/features/plan_executor_coverage_boost.feature index aa38b73f2..94905dea9 100644 --- a/features/plan_executor_coverage_boost.feature +++ b/features/plan_executor_coverage_boost.feature @@ -32,7 +32,7 @@ Feature: PlanExecutor Coverage Boost Scenario: _try_rollback_to_last_checkpoint returns False when no sandbox is resolvable Given a PlanExecutor with a checkpoint manager but no sandbox source When I attempt to rollback to the last checkpoint - Then the rollback result should be False + Then the rollback result should be False for executor # --- _resolve_sandbox_for_checkpoint via execution_context (line 458) --- diff --git a/features/plan_explain.feature b/features/plan_explain.feature index 3dd40b8c8..c43af6f25 100644 --- a/features/plan_explain.feature +++ b/features/plan_explain.feature @@ -56,7 +56,7 @@ Feature: Plan explain and decision tree CLI commands Given a test decision for explain When I format the explain dict as json Then the json output should contain "decision_id" - And the json output should be valid json + And the plan explain json output should be valid json # ------------------------------------------------------------------ # plan explain - yaml format diff --git a/features/project_repository.feature b/features/project_repository.feature index afe03f4ce..9bfa6cd94 100644 --- a/features/project_repository.feature +++ b/features/project_repository.feature @@ -125,7 +125,7 @@ Feature: Namespaced project repository operations Scenario: Remove a non-existent link returns False When I remove a link with id "00000000000000000000000099" - Then the remove result should be False + Then the project remove result should be False Scenario: Create link with read_only flag Given a namespaced project "local/ro-proj" exists in the repository diff --git a/features/resource_cli_flags_904.feature b/features/resource_cli_flags_904.feature index d4f9bff89..ddea5c02e 100644 --- a/features/resource_cli_flags_904.feature +++ b/features/resource_cli_flags_904.feature @@ -35,7 +35,7 @@ Feature: Resource and LSP CLI missing flags (issue #904) And resource flags built-in types are bootstrapped When I run resource flags add "git-checkout" "local/bad-clone" with path "/tmp/bc" and clone-into "https://example.com/repo.git:/workspace" Then the resource flags command should fail - And the resource flags output should contain "container types" + And the resource flags output should contain "container resource types" Scenario: Clone-into flag with invalid format Given a fresh resource flags test registry diff --git a/features/resource_equivalence.feature b/features/resource_equivalence.feature new file mode 100644 index 000000000..e5173e550 --- /dev/null +++ b/features/resource_equivalence.feature @@ -0,0 +1,282 @@ +Feature: Virtual Resource Equivalence Tracking + As a CleverAgents user + I want to track equivalence between virtual and physical resources + So that I can discover where the same content exists across the resource DAG + + Background: + Given a fresh in-memory database for equiv + And a virtual resource type registered for equiv + And a physical resource type registered for equiv + + # -- Domain model tests --------------------------------------------------- + + Scenario: VirtualResourceLink model has all spec fields for equiv + When I create a VirtualResourceLink domain model for equiv + Then the link model has fields id, virtual_resource_id, physical_resource_id for equiv + And the link model has fields equivalence_key, link_type, confidence for equiv + And the link model has fields created_at, updated_at for equiv + + Scenario: VirtualResourceLink model is frozen for equiv + When I create a VirtualResourceLink domain model for equiv + Then the link model is immutable for equiv + + Scenario: LinkType enum has all required values for equiv + Then LinkType has values content_hash, name, identity for equiv + + Scenario: VirtualResourceLink rejects empty equivalence_key for equiv + Then creating a link with empty equivalence_key raises ValueError for equiv + + Scenario: VirtualResourceLink rejects confidence below 0.0 for equiv + Then creating a link with confidence -0.1 raises ValueError for equiv + + Scenario: VirtualResourceLink rejects confidence above 1.0 for equiv + Then creating a link with confidence 1.1 raises ValueError for equiv + + # -- Equivalence key computation ------------------------------------------ + + Scenario: Compute content_hash equivalence key for equiv + When I compute an equivalence key with content "hello world" and type content_hash for equiv + Then the equivalence key is a SHA-256 hex digest for equiv + + Scenario: Compute name equivalence key for equiv + When I compute an equivalence key with name "Main Branch" and type name for equiv + Then the equivalence key is "main branch" for equiv + + Scenario: Compute identity equivalence key for equiv + When I compute an equivalence key with name "abc123def" and type identity for equiv + Then the equivalence key is "abc123def" for equiv + + Scenario: Content hash key requires content for equiv + Then computing an equivalence key without content for content_hash raises ValidationError for equiv + + Scenario: Name key requires name for equiv + Then computing an equivalence key without name for name type raises ValidationError for equiv + + Scenario: Identity key requires name for equiv + Then computing an equivalence key without name for identity type raises ValidationError for equiv + + Scenario: Unknown link type raises error for equiv + Then computing an equivalence key with unknown type raises ValidationError for equiv + + # -- Service CRUD --------------------------------------------------------- + + Scenario: Create an equivalence link for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + When I create an equivalence link between them for equiv + Then the link is persisted with correct fields for equiv + + Scenario: List equivalence links for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + When I list equivalence links for equiv + Then the list contains 1 link for equiv + + Scenario: List links filtered by virtual resource for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + When I list equivalence links filtered by virtual resource for equiv + Then the list contains 1 link for equiv + + Scenario: List links filtered by physical resource for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + When I list equivalence links filtered by physical resource for equiv + Then the list contains 1 link for equiv + + Scenario: Remove an equivalence link for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + When I remove the equivalence link for equiv + Then the link no longer exists for equiv + + Scenario: Remove nonexistent link raises NotFoundError for equiv + Then removing a nonexistent equivalence link raises NotFoundError for equiv + + Scenario: Create duplicate link raises ValidationError for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + Then creating a duplicate equivalence link raises ValidationError for equiv + + # -- Type mismatch guard -------------------------------------------------- + + Scenario: Linking incompatible types raises ValidationError for equiv + Given a virtual resource of type "file" exists for equiv + And a physical resource of type "git-commit" exists for equiv + Then creating a link between incompatible types raises ValidationError for equiv + + # -- Physical must be physical, virtual must be virtual ------------------- + + Scenario: Linking two physical resources raises ValidationError for equiv + Given two physical resources exist for equiv + Then creating a link with physical as virtual raises ValidationError for equiv + + Scenario: Linking two virtual resources raises ValidationError for equiv + Given two virtual resources exist for equiv + Then creating a link with virtual as physical raises ValidationError for equiv + + # -- Merge virtual resources ----------------------------------------------- + + Scenario: Merge two virtual resources for equiv + Given two virtual resources with physical links exist for equiv + When I merge the source virtual into the target virtual for equiv + Then the target virtual has all physical links for equiv + + # -- Divergence detection ------------------------------------------------- + + Scenario: Divergence removes stale links for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + When I check divergence with a new key for equiv + Then the stale link is removed for equiv + + Scenario: Non-diverged links remain after check for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + When I check divergence with the same key for equiv + Then the link still exists for equiv + + # -- Reconciliation job --------------------------------------------------- + + Scenario: Reconciliation removes stale hash links for equiv + Given a virtual resource exists for equiv + And a physical resource with content_hash exists for equiv + And an equivalence link with mismatched hash exists for equiv + When I run the reconciliation job for equiv + Then the stale link is removed by reconciliation for equiv + + Scenario: Reconciliation keeps valid hash links for equiv + Given a virtual resource exists for equiv + And a physical resource with content_hash exists for equiv + And an equivalence link with matching hash exists for equiv + When I run the reconciliation job for equiv + Then the valid link is kept by reconciliation for equiv + + # -- Empty equivalence key validation for equiv --------------------------- + + Scenario: Service rejects empty equivalence key for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + Then creating a link with empty key via service raises ValidationError for equiv + + # -- Conflict policy: physical preferred over virtual --------------------- + + Scenario: Physical resource change unlinks from virtual parent for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + When the physical resource content changes for equiv + Then the physical resource is unlinked from its virtual parent for equiv + + # -- Boundary condition tests (TEST-1) ------------------------------------ + + Scenario: Service rejects whitespace-only equivalence key for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + Then creating a link with whitespace key via service raises ValidationError for equiv + + Scenario: Service rejects invalid link_type for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + Then creating a link with invalid link_type via service raises ValidationError for equiv + + Scenario: Confidence 0.0 is valid for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + When I create an equivalence link with confidence 0.0 for equiv + Then the created link has confidence 0.0 for equiv + + Scenario: Confidence 1.0 is valid for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + When I create an equivalence link with confidence 1.0 for equiv + Then the created link has confidence 1.0 for equiv + + Scenario: Confidence -0.1 is rejected by service for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + Then creating a link with confidence -0.1 via service raises ValidationError for equiv + + Scenario: Confidence 1.1 is rejected by service for equiv + Given a virtual resource exists for equiv + And a physical resource exists for equiv + Then creating a link with confidence 1.1 via service raises ValidationError for equiv + + Scenario: Nonexistent virtual resource raises NotFoundError for equiv + Given a physical resource exists for equiv + Then creating a link with nonexistent virtual resource raises NotFoundError for equiv + + Scenario: Nonexistent physical resource raises NotFoundError for equiv + Given a virtual resource exists for equiv + Then creating a link with nonexistent physical resource raises NotFoundError for equiv + + Scenario: Check divergence rejects empty key for equiv + Then checking divergence with empty key raises ValidationError for equiv + + Scenario: Name equivalence key strips and lowercases for equiv + When I compute an equivalence key with name " UPPER " and type name for equiv + Then the equivalence key is "upper" for equiv + + Scenario: Name equivalence key rejects whitespace-only for equiv + Then computing a name key with whitespace-only raises ValidationError for equiv + + Scenario: Identity key preserves case for equiv + When I compute an equivalence key with name "AbCdEf" and type identity for equiv + Then the equivalence key is "AbCdEf" for equiv + + # -- Additional coverage scenarios for equiv_cov --------------------------- + + Scenario: Reconciliation on empty database returns zeros for equiv_cov + When I run the reconciliation job for equiv + Then reconciliation returns checked 0, removed 0, kept 0 for equiv_cov + + Scenario: Merge same source and target raises ValidationError for equiv_cov + Given a virtual resource exists for equiv + Then merging a virtual resource into itself raises ValidationError for equiv_cov + + Scenario: List links on empty database returns empty for equiv_cov + When I list equivalence links for equiv + Then the list is empty for equiv_cov + + Scenario: Reconciliation removes orphan links for equiv_cov + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + And the physical resource is deleted from the database for equiv_cov + When I run the reconciliation job for equiv + Then the orphan link is removed by reconciliation for equiv_cov + + Scenario: Reconciliation keeps non-content-hash links for equiv_cov + Given a virtual resource exists for equiv + And a physical resource exists for equiv + And a name-type equivalence link exists for equiv_cov + When I run the reconciliation job for equiv + Then reconciliation reports kept 1 for equiv_cov + + Scenario: Identity key rejects whitespace-only for equiv_cov + Then computing an identity key with whitespace-only raises ValidationError for equiv_cov + + Scenario: Merge deduplicates when both virtuals link same physical for equiv_cov + Given a virtual resource exists for equiv + And a second virtual resource exists for equiv + And a physical resource exists for equiv + And an equivalence link exists between them for equiv + And a link from source virtual to same physical exists for equiv_cov + When I merge the source virtual into the target virtual for equiv + Then the target has exactly 1 physical link for equiv_cov + + Scenario: Reconcile removes link when content_hash is NULL for equiv_cov + Given a virtual resource exists for equiv + And a physical resource with no content_hash exists for equiv_cov + And a content_hash link from virtual to hashless physical for equiv_cov + When I run the reconciliation job for equiv + Then reconciliation removed the hashless link for equiv_cov + + diff --git a/features/retry_policy_wiring.feature b/features/retry_policy_wiring.feature index 67d2a37d5..15d85a502 100644 --- a/features/retry_policy_wiring.feature +++ b/features/retry_policy_wiring.feature @@ -76,7 +76,7 @@ Feature: Retry Policy Wiring for Services @registry Scenario: Per-service policy defaults with config overrides Given I have Settings with retry_service_overrides setting plan_service max_attempts to 10 - When I create a ServiceRetryWiring from those Settings + When I create a ServiceRetryWiring from those default Settings Then the plan_service policy should have max_attempts 10 @registry diff --git a/features/steps/edge_case_plan_steps.py b/features/steps/edge_case_plan_steps.py index 28e7a0695..f34305993 100644 --- a/features/steps/edge_case_plan_steps.py +++ b/features/steps/edge_case_plan_steps.py @@ -755,7 +755,7 @@ def step_try_create_plan_empty_desc(context: Context) -> None: context.pydantic_error = exc -@then("a Pydantic validation error should be raised") +@then("a Pydantic validation error should be raised for edge case plan") def step_check_pydantic_error(context: Context) -> None: assert context.pydantic_error is not None, "Expected a Pydantic validation error" diff --git a/features/steps/plan_executor_coverage_boost_steps.py b/features/steps/plan_executor_coverage_boost_steps.py index 823ecb5a8..5cb6a87d7 100644 --- a/features/steps/plan_executor_coverage_boost_steps.py +++ b/features/steps/plan_executor_coverage_boost_steps.py @@ -171,7 +171,7 @@ def step_attempt_rollback(context): ) -@then("the rollback result should be False") +@then("the rollback result should be False for executor") def step_verify_rollback_false(context): """Verify the rollback result is False.""" assert context.rollback_result is False diff --git a/features/steps/plan_explain_steps.py b/features/steps/plan_explain_steps.py index 124be3c71..55745fa53 100644 --- a/features/steps/plan_explain_steps.py +++ b/features/steps/plan_explain_steps.py @@ -330,7 +330,7 @@ def step_json_contains(context: Context, text: str) -> None: assert text in context.pe_json_output, f"Expected '{text}' in JSON output" -@then("the json output should be valid json") +@then("the plan explain json output should be valid json") def step_json_valid(context: Context) -> None: parsed = json.loads(context.pe_json_output) assert isinstance(parsed, dict), "Expected a JSON object" diff --git a/features/steps/project_repository_steps.py b/features/steps/project_repository_steps.py index 1122260c3..50689e5f3 100644 --- a/features/steps/project_repository_steps.py +++ b/features/steps/project_repository_steps.py @@ -492,7 +492,7 @@ def step_pr_remove_link_by_id(context: Any, link_id: str) -> None: context.pr_remove_result = context.pr_link_repo.remove_link(link_id) -@then("the remove result should be False") +@then("the project remove result should be False") def step_pr_remove_false(context: Any) -> None: assert context.pr_remove_result is False diff --git a/features/steps/resource_equivalence_steps.py b/features/steps/resource_equivalence_steps.py new file mode 100644 index 000000000..4bcf1fa1c --- /dev/null +++ b/features/steps/resource_equivalence_steps.py @@ -0,0 +1,1030 @@ +"""Step definitions for resource equivalence tracking (issue #334). + +All steps use the suffix 'for equiv' to avoid AmbiguousStep conflicts +with other resource-related step files. +""" + +from __future__ import annotations + +import hashlib +from datetime import UTC, datetime +from typing import Any + +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError as PydanticValidationError +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + compute_equivalence_key, +) +from cleveragents.core.exceptions import NotFoundError, ValidationError +from cleveragents.domain.models.core.virtual_resource_link import ( + LinkType, + VirtualResourceLink, +) +from cleveragents.infrastructure.database.models import ( + Base, + ResourceModel, + ResourceTypeModel, +) + +# ULID-like test constants (valid Crockford base32, 26 chars) +# Crockford base32 alphabet: 0-9 A-H J-K M-N P-T V-Z (excludes I, L, O, U) +_VIRT_RES_ID = "01HEQV00000000000000VRTR01" +_PHYS_RES_ID = "01HEQV00000000000000PHYS01" +_VIRT_RES_ID2 = "01HEQV00000000000000VRTR02" +_PHYS_RES_ID2 = "01HEQV00000000000000PHYS02" +_EQUIV_KEY = "abc123def456" + + +def _setup_engine() -> Any: + """Create an in-memory SQLite engine with FK enforcement.""" + engine = create_engine("sqlite:///:memory:") + + @event.listens_for(engine, "connect") + def _fk(conn: Any, _rec: Any) -> None: + conn.cursor().execute("PRAGMA foreign_keys=ON") + + Base.metadata.create_all(engine) + return engine + + +def _register_type( + session: Any, + name: str, + kind: str, +) -> None: + """Register a resource type in the test DB.""" + now_iso = datetime.now(tz=UTC).isoformat() + existing = session.query(ResourceTypeModel).filter_by(name=name).first() + if existing is not None: + return + session.add( + ResourceTypeModel( + name=name, + namespace="builtin" if "/" not in name else name.split("/")[0], + description=f"Test type {name}", + resource_kind=kind, + sandbox_strategy="none", + user_addable=True, + created_at=now_iso, + updated_at=now_iso, + ) + ) + session.commit() + + +def _register_resource( + session: Any, + resource_id: str, + type_name: str, + kind: str, + content_hash: str | None = None, +) -> None: + """Register a resource in the test DB.""" + now_iso = datetime.now(tz=UTC).isoformat() + existing = session.query(ResourceModel).filter_by(resource_id=resource_id).first() + if existing is not None: + return + session.add( + ResourceModel( + resource_id=resource_id, + type_name=type_name, + resource_kind=kind, + content_hash=content_hash, + created_at=now_iso, + updated_at=now_iso, + ) + ) + session.commit() + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a fresh in-memory database for equiv") +def step_fresh_db(context: Context) -> None: + engine = _setup_engine() + context.equiv_engine = engine + context.equiv_session_factory = sessionmaker(bind=engine) + context.equiv_service = ResourceEquivalenceService(context.equiv_session_factory) + + +@given("a virtual resource type registered for equiv") +def step_virtual_type(context: Context) -> None: + session = context.equiv_session_factory() + _register_type(session, "generic", "virtual") + _register_type(session, "file", "virtual") + session.close() + + +@given("a physical resource type registered for equiv") +def step_physical_type(context: Context) -> None: + session = context.equiv_session_factory() + _register_type(session, "p-generic", "physical") + _register_type(session, "fs-file", "physical") + _register_type(session, "git-tree-entry", "physical") + _register_type(session, "git-commit", "physical") + session.close() + + +# --------------------------------------------------------------------------- +# Domain model steps +# --------------------------------------------------------------------------- + + +@when("I create a VirtualResourceLink domain model for equiv") +def step_create_domain_model(context: Context) -> None: + context.equiv_link = VirtualResourceLink( + id=_VIRT_RES_ID, + virtual_resource_id=_VIRT_RES_ID, + physical_resource_id=_PHYS_RES_ID, + equivalence_key=_EQUIV_KEY, + link_type=LinkType.CONTENT_HASH, + confidence=0.95, + created_at=datetime.now(tz=UTC), + updated_at=datetime.now(tz=UTC), + ) + + +@then( + "the link model has fields id, virtual_resource_id, physical_resource_id for equiv" +) +def step_check_identity_fields(context: Context) -> None: + link = context.equiv_link + assert hasattr(link, "id"), "Missing field: id" + assert hasattr(link, "virtual_resource_id"), "Missing: virtual_resource_id" + assert hasattr(link, "physical_resource_id"), "Missing: physical_resource_id" + assert link.id == _VIRT_RES_ID + assert link.virtual_resource_id == _VIRT_RES_ID + assert link.physical_resource_id == _PHYS_RES_ID + + +@then("the link model has fields equivalence_key, link_type, confidence for equiv") +def step_check_data_fields(context: Context) -> None: + link = context.equiv_link + assert hasattr(link, "equivalence_key"), "Missing: equivalence_key" + assert hasattr(link, "link_type"), "Missing: link_type" + assert hasattr(link, "confidence"), "Missing: confidence" + assert link.equivalence_key == _EQUIV_KEY + assert link.link_type == "content_hash" + assert link.confidence == 0.95 + + +@then("the link model has fields created_at, updated_at for equiv") +def step_check_timestamp_fields(context: Context) -> None: + link = context.equiv_link + assert hasattr(link, "created_at"), "Missing: created_at" + assert hasattr(link, "updated_at"), "Missing: updated_at" + assert isinstance(link.created_at, datetime) + assert isinstance(link.updated_at, datetime) + + +@then("the link model is immutable for equiv") +def step_check_frozen(context: Context) -> None: + link = context.equiv_link + try: + link.confidence = 0.5 # type: ignore[misc] + raise AssertionError("Should have raised on frozen model") + except (AttributeError, TypeError, ValueError, ValidationError): + pass + + +@then("LinkType has values content_hash, name, identity for equiv") +def step_check_link_type_enum(context: Context) -> None: + assert LinkType.CONTENT_HASH == "content_hash" + assert LinkType.NAME == "name" + assert LinkType.IDENTITY == "identity" + assert len(LinkType) == 3 + + +@then("creating a link with empty equivalence_key raises ValueError for equiv") +def step_empty_equiv_key(context: Context) -> None: + try: + VirtualResourceLink( + id=_VIRT_RES_ID, + virtual_resource_id=_VIRT_RES_ID, + physical_resource_id=_PHYS_RES_ID, + equivalence_key=" ", + link_type=LinkType.CONTENT_HASH, + ) + raise AssertionError("Should have raised ValueError") + except (ValueError, PydanticValidationError): + pass + + +@then("creating a link with confidence -0.1 raises ValueError for equiv") +def step_low_confidence(context: Context) -> None: + try: + VirtualResourceLink( + id=_VIRT_RES_ID, + virtual_resource_id=_VIRT_RES_ID, + physical_resource_id=_PHYS_RES_ID, + equivalence_key=_EQUIV_KEY, + confidence=-0.1, + ) + raise AssertionError("Should have raised ValueError") + except (ValueError, PydanticValidationError): + pass + + +@then("creating a link with confidence 1.1 raises ValueError for equiv") +def step_high_confidence(context: Context) -> None: + try: + VirtualResourceLink( + id=_VIRT_RES_ID, + virtual_resource_id=_VIRT_RES_ID, + physical_resource_id=_PHYS_RES_ID, + equivalence_key=_EQUIV_KEY, + confidence=1.1, + ) + raise AssertionError("Should have raised ValueError") + except (ValueError, PydanticValidationError): + pass + + +# --------------------------------------------------------------------------- +# Equivalence key computation steps +# --------------------------------------------------------------------------- + + +@when( + 'I compute an equivalence key with content "{content}" ' + "and type content_hash for equiv" +) +def step_compute_content_hash(context: Context, content: str) -> None: + context.equiv_computed_key = compute_equivalence_key( + content=content, link_type="content_hash" + ) + + +@then("the equivalence key is a SHA-256 hex digest for equiv") +def step_check_sha256(context: Context) -> None: + key = context.equiv_computed_key + expected = hashlib.sha256(b"hello world").hexdigest() + assert key == expected, f"Expected {expected}, got {key}" + assert len(key) == 64 + + +@when('I compute an equivalence key with name "{name}" and type name for equiv') +def step_compute_name_key(context: Context, name: str) -> None: + context.equiv_computed_key = compute_equivalence_key(name=name, link_type="name") + + +@then('the equivalence key is "{expected}" for equiv') +def step_check_key_value(context: Context, expected: str) -> None: + assert context.equiv_computed_key == expected + + +@when('I compute an equivalence key with name "{name}" and type identity for equiv') +def step_compute_identity_key(context: Context, name: str) -> None: + context.equiv_computed_key = compute_equivalence_key( + name=name, link_type="identity" + ) + + +@then( + "computing an equivalence key without content for content_hash " + "raises ValidationError for equiv" +) +def step_no_content_hash(context: Context) -> None: + try: + compute_equivalence_key(link_type="content_hash") + raise AssertionError("Should have raised") + except ValidationError: + pass + + +@then( + "computing an equivalence key without name for name type " + "raises ValidationError for equiv" +) +def step_no_name_name(context: Context) -> None: + try: + compute_equivalence_key(link_type="name") + raise AssertionError("Should have raised") + except ValidationError: + pass + + +@then( + "computing an equivalence key without name for identity type " + "raises ValidationError for equiv" +) +def step_no_name_identity(context: Context) -> None: + try: + compute_equivalence_key(link_type="identity") + raise AssertionError("Should have raised") + except ValidationError: + pass + + +@then("computing an equivalence key with unknown type raises ValidationError for equiv") +def step_unknown_type(context: Context) -> None: + try: + compute_equivalence_key(content="x", link_type="unknown") + raise AssertionError("Should have raised") + except ValidationError: + pass + + +# --------------------------------------------------------------------------- +# Service CRUD steps +# --------------------------------------------------------------------------- + + +@given("a virtual resource exists for equiv") +def step_virtual_resource(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource(session, _VIRT_RES_ID, "generic", "virtual") + session.close() + context.equiv_virtual_id = _VIRT_RES_ID + + +@given("a physical resource exists for equiv") +def step_physical_resource(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource(session, _PHYS_RES_ID, "p-generic", "physical") + session.close() + context.equiv_physical_id = _PHYS_RES_ID + + +@when("I create an equivalence link between them for equiv") +def step_create_link(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_created_link = service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key=_EQUIV_KEY, + link_type="content_hash", + confidence=1.0, + ) + + +@then("the link is persisted with correct fields for equiv") +def step_check_persisted(context: Context) -> None: + link = context.equiv_created_link + assert link.virtual_resource_id == _VIRT_RES_ID + assert link.physical_resource_id == _PHYS_RES_ID + assert link.equivalence_key == _EQUIV_KEY + assert link.link_type == "content_hash" + assert link.confidence == 1.0 + assert len(link.id) == 26 + + +@given("an equivalence link exists between them for equiv") +def step_link_exists(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + # Check if link already exists, create if not + links = service.list_links( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + ) + if not links: + context.equiv_created_link = service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key=_EQUIV_KEY, + link_type="content_hash", + confidence=1.0, + ) + else: + context.equiv_created_link = links[0] + + +@when("I list equivalence links for equiv") +def step_list_links(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_links_list = service.list_links() + + +@then("the list contains {count:d} link for equiv") +def step_check_count(context: Context, count: int) -> None: + assert len(context.equiv_links_list) == count, ( + f"Expected {count} links, got {len(context.equiv_links_list)}" + ) + + +@when("I list equivalence links filtered by virtual resource for equiv") +def step_list_by_virtual(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_links_list = service.list_links( + virtual_resource_id=context.equiv_virtual_id + ) + + +@when("I list equivalence links filtered by physical resource for equiv") +def step_list_by_physical(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_links_list = service.list_links( + physical_resource_id=context.equiv_physical_id + ) + + +@when("I remove the equivalence link for equiv") +def step_remove_link(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + service.remove_link(context.equiv_virtual_id, context.equiv_physical_id) + + +@then("the link no longer exists for equiv") +def step_link_gone(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + links = service.list_links( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + ) + assert len(links) == 0, f"Expected 0 links, got {len(links)}" + + +@then("removing a nonexistent equivalence link raises NotFoundError for equiv") +def step_remove_missing(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.remove_link("01HEQV00000000000000NXST01", _PHYS_RES_ID) + raise AssertionError("Should have raised NotFoundError") + except NotFoundError: + pass + + +@then("creating a duplicate equivalence link raises ValidationError for equiv") +def step_duplicate(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key=_EQUIV_KEY, + ) + raise AssertionError("Should have raised ValidationError") + except ValidationError as exc: + assert "already exists" in exc.message + + +# --------------------------------------------------------------------------- +# Type mismatch guard steps +# --------------------------------------------------------------------------- + + +@given('a virtual resource of type "file" exists for equiv') +def step_vfile_resource(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource(session, _VIRT_RES_ID, "file", "virtual") + session.close() + context.equiv_virtual_id = _VIRT_RES_ID + + +@given('a physical resource of type "git-commit" exists for equiv') +def step_git_commit_resource(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource(session, _PHYS_RES_ID, "git-commit", "physical") + session.close() + context.equiv_physical_id = _PHYS_RES_ID + + +@then("creating a link between incompatible types raises ValidationError for equiv") +def step_incompatible_types(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key=_EQUIV_KEY, + ) + raise AssertionError("Should have raised ValidationError") + except ValidationError as exc: + assert "Type mismatch" in exc.message + + +# --------------------------------------------------------------------------- +# Physical/virtual guard steps +# --------------------------------------------------------------------------- + + +@given("two physical resources exist for equiv") +def step_two_physical(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource(session, _PHYS_RES_ID, "p-generic", "physical") + _register_resource(session, _PHYS_RES_ID2, "p-generic", "physical") + session.close() + + +@then("creating a link with physical as virtual raises ValidationError for equiv") +def step_physical_as_virtual(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id=_PHYS_RES_ID, + physical_resource_id=_PHYS_RES_ID2, + equivalence_key=_EQUIV_KEY, + ) + raise AssertionError("Should have raised") + except ValidationError as exc: + assert "not virtual" in exc.message + + +@given("two virtual resources exist for equiv") +def step_two_virtual(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource(session, _VIRT_RES_ID, "generic", "virtual") + _register_resource(session, _VIRT_RES_ID2, "generic", "virtual") + session.close() + + +@then("creating a link with virtual as physical raises ValidationError for equiv") +def step_virtual_as_physical(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id=_VIRT_RES_ID, + physical_resource_id=_VIRT_RES_ID2, + equivalence_key=_EQUIV_KEY, + ) + raise AssertionError("Should have raised") + except ValidationError as exc: + assert "not physical" in exc.message + + +# --------------------------------------------------------------------------- +# Merge steps +# --------------------------------------------------------------------------- + + +@given("two virtual resources with physical links exist for equiv") +def step_merge_setup(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource(session, _VIRT_RES_ID, "generic", "virtual") + _register_resource(session, _VIRT_RES_ID2, "generic", "virtual") + _register_resource(session, _PHYS_RES_ID, "p-generic", "physical") + _register_resource(session, _PHYS_RES_ID2, "p-generic", "physical") + session.close() + + service: ResourceEquivalenceService = context.equiv_service + # Link phys1 -> virt1, phys2 -> virt2 + service.create_link( + virtual_resource_id=_VIRT_RES_ID, + physical_resource_id=_PHYS_RES_ID, + equivalence_key="key1", + ) + service.create_link( + virtual_resource_id=_VIRT_RES_ID2, + physical_resource_id=_PHYS_RES_ID2, + equivalence_key="key2", + ) + context.equiv_target_virtual = _VIRT_RES_ID + context.equiv_source_virtual = _VIRT_RES_ID2 + + +@when("I merge the source virtual into the target virtual for equiv") +def step_merge(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_merged_links = service.merge_virtual_resources( + target_virtual_id=context.equiv_target_virtual, + source_virtual_id=context.equiv_source_virtual, + ) + + +@then("the target virtual has all physical links for equiv") +def step_merged_result(context: Context) -> None: + links = context.equiv_merged_links + physical_ids = {lnk.physical_resource_id for lnk in links} + assert _PHYS_RES_ID in physical_ids, "Missing phys 1" + assert _PHYS_RES_ID2 in physical_ids, "Missing phys 2" + # All links should be to target virtual + for lnk in links: + assert lnk.virtual_resource_id == context.equiv_target_virtual + + +# --------------------------------------------------------------------------- +# Divergence steps +# --------------------------------------------------------------------------- + + +@when("I check divergence with a new key for equiv") +def step_check_divergence_new(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_remaining = service.check_divergence( + physical_resource_id=context.equiv_physical_id, + new_equivalence_key="new_different_key", + ) + + +@then("the stale link is removed for equiv") +def step_divergence_removed(context: Context) -> None: + assert len(context.equiv_remaining) == 0 + + +@when("I check divergence with the same key for equiv") +def step_check_divergence_same(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_remaining = service.check_divergence( + physical_resource_id=context.equiv_physical_id, + new_equivalence_key=_EQUIV_KEY, + ) + + +@then("the link still exists for equiv") +def step_non_diverged(context: Context) -> None: + assert len(context.equiv_remaining) == 1 + + +# --------------------------------------------------------------------------- +# Reconciliation steps +# --------------------------------------------------------------------------- + + +@given("a physical resource with content_hash exists for equiv") +def step_phys_with_hash(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource( + session, _PHYS_RES_ID, "p-generic", "physical", content_hash="hash_abc" + ) + session.close() + context.equiv_physical_id = _PHYS_RES_ID + + +@given("an equivalence link with mismatched hash exists for equiv") +def step_mismatched_hash_link(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key="wrong_hash", + link_type="content_hash", + ) + + +@given("an equivalence link with matching hash exists for equiv") +def step_matching_hash_link(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key="hash_abc", + link_type="content_hash", + ) + + +@when("I run the reconciliation job for equiv") +def step_reconcile(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_reconcile_result = service.reconcile() + + +@then("the stale link is removed by reconciliation for equiv") +def step_reconcile_removed(context: Context) -> None: + result = context.equiv_reconcile_result + assert result["removed"] >= 1, f"Expected removed >= 1, got {result}" + # Verify link is actually gone + service: ResourceEquivalenceService = context.equiv_service + links = service.list_links( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + ) + assert len(links) == 0 + + +@then("the valid link is kept by reconciliation for equiv") +def step_reconcile_kept(context: Context) -> None: + result = context.equiv_reconcile_result + assert result["kept"] >= 1, f"Expected kept >= 1, got {result}" + + +# --------------------------------------------------------------------------- +# Empty key validation via service +# --------------------------------------------------------------------------- + + +@then("creating a link with empty key via service raises ValidationError for equiv") +def step_service_empty_key(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key=" ", + ) + raise AssertionError("Should have raised") + except ValidationError as exc: + assert "empty" in exc.message.lower() + + +# --------------------------------------------------------------------------- +# Conflict policy: physical preferred over virtual +# --------------------------------------------------------------------------- + + +@when("the physical resource content changes for equiv") +def step_content_changes(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_remaining = service.check_divergence( + physical_resource_id=context.equiv_physical_id, + new_equivalence_key="completely_different_hash", + ) + + +@then("the physical resource is unlinked from its virtual parent for equiv") +def step_unlinked_after_change(context: Context) -> None: + assert len(context.equiv_remaining) == 0 + # Verify via list_links too + service: ResourceEquivalenceService = context.equiv_service + links = service.list_links(physical_resource_id=context.equiv_physical_id) + assert len(links) == 0 + + +# --------------------------------------------------------------------------- +# Boundary condition steps (TEST-1) +# --------------------------------------------------------------------------- + + +@then( + "creating a link with whitespace key via service raises ValidationError for equiv" +) +def step_whitespace_key(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + for key in ["", " ", "\t\n"]: + try: + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key=key, + ) + raise AssertionError(f"Should have raised for key={key!r}") + except ValidationError as exc: + assert "empty" in exc.message.lower() + + +@then( + "creating a link with invalid link_type via service raises ValidationError for equiv" +) +def step_invalid_link_type(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key="valid_key", + link_type="bogus_type", + ) + raise AssertionError("Should have raised ValidationError") + except ValidationError as exc: + assert "link_type" in exc.message.lower() or "invalid" in exc.message.lower() + + +@when("I create an equivalence link with confidence {conf:g} for equiv") +def step_create_with_confidence(context: Context, conf: float) -> None: + service: ResourceEquivalenceService = context.equiv_service + context.equiv_created_link = service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key=f"key_conf_{conf}", + link_type="content_hash", + confidence=conf, + ) + + +@then("the created link has confidence {conf:g} for equiv") +def step_check_confidence(context: Context, conf: float) -> None: + assert context.equiv_created_link.confidence == conf, ( + f"Expected confidence {conf}, got {context.equiv_created_link.confidence}" + ) + + +@then( + "creating a link with confidence -0.1 via service raises ValidationError for equiv" +) +def step_service_low_confidence(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key="test_key", + confidence=-0.1, + ) + raise AssertionError("Should have raised ValidationError") + except ValidationError as exc: + assert "confidence" in exc.message.lower() + + +@then( + "creating a link with confidence 1.1 via service raises ValidationError for equiv" +) +def step_service_high_confidence(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key="test_key", + confidence=1.1, + ) + raise AssertionError("Should have raised ValidationError") + except ValidationError as exc: + assert "confidence" in exc.message.lower() + + +@then( + "creating a link with nonexistent virtual resource raises NotFoundError for equiv" +) +def step_missing_virtual(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id="01HEQV00000000000000GONE01", + physical_resource_id=context.equiv_physical_id, + equivalence_key="test_key", + ) + raise AssertionError("Should have raised NotFoundError") + except NotFoundError: + pass + + +@then( + "creating a link with nonexistent physical resource raises NotFoundError for equiv" +) +def step_missing_physical(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id="01HEQV00000000000000GONE02", + equivalence_key="test_key", + ) + raise AssertionError("Should have raised NotFoundError") + except NotFoundError: + pass + + +@then("checking divergence with empty key raises ValidationError for equiv") +def step_divergence_empty_key(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + for key in ["", " "]: + try: + service.check_divergence( + physical_resource_id=_PHYS_RES_ID, + new_equivalence_key=key, + ) + raise AssertionError(f"Should have raised for key={key!r}") + except ValidationError as exc: + assert "empty" in exc.message.lower() + + +@then("computing a name key with whitespace-only raises ValidationError for equiv") +def step_name_whitespace(context: Context) -> None: + try: + compute_equivalence_key(name=" ", link_type="name") + raise AssertionError("Should have raised ValidationError") + except ValidationError: + pass + + +# --------------------------------------------------------------------------- +# Additional coverage steps (equiv_cov) +# --------------------------------------------------------------------------- + + +@then("reconciliation returns checked 0, removed 0, kept 0 for equiv_cov") +def step_reconcile_empty(context: Context) -> None: + result = context.equiv_reconcile_result + assert result["checked"] == 0, f"Expected checked=0, got {result}" + assert result["removed"] == 0, f"Expected removed=0, got {result}" + assert result["kept"] == 0, f"Expected kept=0, got {result}" + + +@then("merging a virtual resource into itself raises ValidationError for equiv_cov") +def step_merge_self(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + try: + service.merge_virtual_resources( + target_virtual_id=context.equiv_virtual_id, + source_virtual_id=context.equiv_virtual_id, + ) + raise AssertionError("Should have raised ValidationError") + except ValidationError as exc: + assert "itself" in exc.message.lower() + + +@then("the list is empty for equiv_cov") +def step_list_empty(context: Context) -> None: + assert len(context.equiv_links_list) == 0 + + +@given("the physical resource is deleted from the database for equiv_cov") +def step_delete_physical(context: Context) -> None: + # Temporarily disable FK enforcement via raw engine connection so we can + # create an orphan link scenario (link points to a physical_resource_id + # that no longer exists in the resources table). + from sqlalchemy import text + + engine = context.equiv_engine + with engine.connect() as conn: + conn.execute(text("PRAGMA foreign_keys=OFF")) + conn.execute( + text("DELETE FROM resources WHERE resource_id = :rid"), + {"rid": context.equiv_physical_id}, + ) + conn.execute(text("PRAGMA foreign_keys=ON")) + conn.commit() + + +@then("the orphan link is removed by reconciliation for equiv_cov") +def step_reconcile_orphan(context: Context) -> None: + result = context.equiv_reconcile_result + assert result["removed"] >= 1, f"Expected removed >= 1, got {result}" + + +@given("a name-type equivalence link exists for equiv_cov") +def step_name_link(context: Context) -> None: + service: ResourceEquivalenceService = context.equiv_service + service.create_link( + virtual_resource_id=context.equiv_virtual_id, + physical_resource_id=context.equiv_physical_id, + equivalence_key="branch-main", + link_type="name", + confidence=0.9, + ) + + +@then("reconciliation reports kept 1 for equiv_cov") +def step_reconcile_kept_name(context: Context) -> None: + result = context.equiv_reconcile_result + assert result["kept"] >= 1, f"Expected kept >= 1, got {result}" + + +@then( + "computing an identity key with whitespace-only raises ValidationError for equiv_cov" +) +def step_identity_whitespace(context: Context) -> None: + try: + compute_equivalence_key(name=" ", link_type="identity") + raise AssertionError("Should have raised ValidationError") + except ValidationError: + pass + + +# --------------------------------------------------------------------------- +# m10: Merge with overlapping physicals +# --------------------------------------------------------------------------- + +_PHYS_SHARED = "01HEQV00000000000000PHSH01" + + +@given("a second virtual resource exists for equiv") +def step_second_virtual(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource(session, _VIRT_RES_ID2, "generic", "virtual") + session.close() + # Set merge context vars so merge step can find source/target + context.equiv_target_virtual = _VIRT_RES_ID + context.equiv_source_virtual = _VIRT_RES_ID2 + + +@given("a link from source virtual to same physical exists for equiv_cov") +def step_link_source_to_same_phys(context: Context) -> None: + context.equiv_service.create_link( + virtual_resource_id=_VIRT_RES_ID2, + physical_resource_id=_PHYS_RES_ID, + equivalence_key=_EQUIV_KEY, + link_type="content_hash", + ) + + +@then("the target has exactly 1 physical link for equiv_cov") +def step_target_one_link(context: Context) -> None: + links = context.equiv_service.list_links(virtual_resource_id=_VIRT_RES_ID) + assert len(links) == 1, f"Expected 1 link, got {len(links)}" + + +# --------------------------------------------------------------------------- +# m11: Reconcile with NULL content_hash +# --------------------------------------------------------------------------- + +_PHYS_NOHASH = "01HEQV00000000000000PHNH01" + + +@given("a physical resource with no content_hash exists for equiv_cov") +def step_phys_no_hash(context: Context) -> None: + session = context.equiv_session_factory() + _register_resource(session, _PHYS_NOHASH, "fs-file", "physical", content_hash=None) + + +@given("a content_hash link from virtual to hashless physical for equiv_cov") +def step_link_to_hashless(context: Context) -> None: + context.equiv_service.create_link( + virtual_resource_id=_VIRT_RES_ID, + physical_resource_id=_PHYS_NOHASH, + equivalence_key="old_hash_value", + link_type="content_hash", + ) + + +@then("reconciliation removed the hashless link for equiv_cov") +def step_reconcile_hashless_removed(context: Context) -> None: + result = context.equiv_reconcile_result + assert result["removed"] >= 1, f"Expected removed>=1, got {result}" diff --git a/features/steps/service_retry_wiring_steps.py b/features/steps/service_retry_wiring_steps.py index ce180d4c2..ad0459f63 100644 --- a/features/steps/service_retry_wiring_steps.py +++ b/features/steps/service_retry_wiring_steps.py @@ -185,7 +185,7 @@ def step_settings_with_overrides(context: Any, value: int) -> None: os.environ.pop("CLEVERAGENTS_RETRY_SERVICE_OVERRIDES", None) -@when("I create a ServiceRetryWiring from those Settings") +@when("I create a ServiceRetryWiring from those default Settings") def step_create_wiring_from_settings(context: Any) -> None: from cleveragents.application.services.service_retry_wiring import ( ServiceRetryWiring, diff --git a/features/steps/session_model_steps.py b/features/steps/session_model_steps.py index 0c479b139..6191d07bb 100644 --- a/features/steps/session_model_steps.py +++ b/features/steps/session_model_steps.py @@ -446,7 +446,7 @@ def session_model_check_export_messages_count(context: Context) -> None: # --------------------------------------------------------------------------- -@when("I get the session CLI dict") +@when("I get the session model CLI dict") def session_model_get_cli_dict(context: Context) -> None: """Get the CLI dict for the session.""" context.session_cli_dict = context.session_model.as_cli_dict() diff --git a/robot/helper_resource_equivalence.py b/robot/helper_resource_equivalence.py new file mode 100644 index 000000000..0109f2946 --- /dev/null +++ b/robot/helper_resource_equivalence.py @@ -0,0 +1,427 @@ +"""Helper script for resource_equivalence.robot smoke tests. + +Each subcommand is a self-contained check that prints a sentinel on success. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +# Ensure local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from datetime import UTC, datetime # noqa: E402 + +from pydantic import ValidationError # noqa: E402 +from sqlalchemy import create_engine, event # noqa: E402 +from sqlalchemy.orm import sessionmaker # noqa: E402 +from ulid import ULID # noqa: E402 + +from cleveragents.domain.models.core.virtual_resource_link import ( # noqa: E402 + LinkType, + VirtualResourceLink, +) +from cleveragents.infrastructure.database.models import ( # noqa: E402 + Base, + ResourceModel, + ResourceTypeModel, +) + +# --------------------------------------------------------------------------- +# Shared DB setup +# --------------------------------------------------------------------------- + + +def _setup_db() -> sessionmaker: + """Create in-memory database and return session factory.""" + engine = create_engine("sqlite:///:memory:") + + @event.listens_for(engine, "connect") + def _fk(conn: Any, _rec: Any) -> None: + conn.cursor().execute("PRAGMA foreign_keys=ON") + + Base.metadata.create_all(engine) + return sessionmaker(bind=engine) + + +def _seed_types(session_factory: sessionmaker) -> None: + """Register a virtual and a physical resource type.""" + now_iso = datetime.now(tz=UTC).isoformat() + session = session_factory() + # Virtual type + session.add( + ResourceTypeModel( + name="file", + namespace="builtin", + description="Virtual file", + resource_kind="virtual", + sandbox_strategy="none", + user_addable=True, + args_schema_json="[]", + allowed_parent_types_json="[]", + allowed_child_types_json="[]", + capabilities_json='{"read":true,"write":false,"sandbox":false,"checkpoint":false}', + source="builtin", + created_at=now_iso, + updated_at=now_iso, + ) + ) + # Physical type + session.add( + ResourceTypeModel( + name="fs-file", + namespace="builtin", + description="File system file", + resource_kind="physical", + sandbox_strategy="none", + user_addable=True, + args_schema_json="[]", + allowed_parent_types_json="[]", + allowed_child_types_json="[]", + capabilities_json='{"read":true,"write":true,"sandbox":true,"checkpoint":false}', + source="builtin", + created_at=now_iso, + updated_at=now_iso, + ) + ) + session.commit() + session.close() + + +def _make_resource( + session_factory: sessionmaker, + *, + resource_kind: str = "physical", + type_name: str = "fs-file", + content_hash: str | None = None, +) -> str: + """Create a resource row and return its ULID.""" + session = session_factory() + rid = str(ULID()) + now_iso = datetime.now(tz=UTC).isoformat() + session.add( + ResourceModel( + resource_id=rid, + type_name=type_name, + resource_kind=resource_kind, + content_hash=content_hash, + created_at=now_iso, + updated_at=now_iso, + ) + ) + session.commit() + session.close() + return rid + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def link_type_enum() -> None: + """Verify LinkType enum values.""" + expected = {"content_hash", "name", "identity"} + actual = {e.value for e in LinkType} + if actual != expected: + print(f"FAIL: expected {expected}, got {actual}", file=sys.stderr) + sys.exit(1) + print("link-type-enum-ok") + + +def domain_model_frozen() -> None: + """Verify VirtualResourceLink is frozen.""" + link = VirtualResourceLink( + id=str(ULID()), + virtual_resource_id=str(ULID()), + physical_resource_id=str(ULID()), + equivalence_key="abc123", + link_type=LinkType.CONTENT_HASH, + confidence=1.0, + ) + try: + link.confidence = 0.5 # type: ignore[misc] + print("FAIL: should have raised on frozen model", file=sys.stderr) + sys.exit(1) + except (TypeError, ValueError, ValidationError): + pass + print("domain-model-frozen-ok") + + +def equivalence_key_computation() -> None: + """Verify compute_equivalence_key for content_hash and name.""" + from cleveragents.application.services.resource_equivalence_service import ( + compute_equivalence_key, + ) + + key1 = compute_equivalence_key(content="hello world", link_type="content_hash") + if len(key1) != 64: + print( + f"FAIL: expected 64-char SHA-256 hex, got len={len(key1)}", file=sys.stderr + ) + sys.exit(1) + + key2 = compute_equivalence_key(name=" Main ", link_type="name") + if key2 != "main": + print(f"FAIL: expected 'main', got '{key2}'", file=sys.stderr) + sys.exit(1) + + key3 = compute_equivalence_key(name="abc123", link_type="identity") + if key3 != "abc123": + print(f"FAIL: expected 'abc123', got '{key3}'", file=sys.stderr) + sys.exit(1) + + print("equivalence-key-computation-ok") + + +def create_and_list_link() -> None: + """Verify create_link and list_links round-trip.""" + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + + factory = _setup_db() + _seed_types(factory) + + virtual_id = _make_resource(factory, resource_kind="virtual", type_name="file") + physical_id = _make_resource( + factory, resource_kind="physical", type_name="fs-file", content_hash="abc123" + ) + + svc = ResourceEquivalenceService(factory) + link = svc.create_link(virtual_id, physical_id, "abc123", link_type="content_hash") + + if link.virtual_resource_id != virtual_id: + print("FAIL: wrong virtual_resource_id", file=sys.stderr) + sys.exit(1) + + links = svc.list_links(virtual_resource_id=virtual_id) + if len(links) != 1: + print(f"FAIL: expected 1 link, got {len(links)}", file=sys.stderr) + sys.exit(1) + + print("create-and-list-link-ok") + + +def remove_link() -> None: + """Verify remove_link removes an existing link.""" + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + + factory = _setup_db() + _seed_types(factory) + + virtual_id = _make_resource(factory, resource_kind="virtual", type_name="file") + physical_id = _make_resource( + factory, resource_kind="physical", type_name="fs-file", content_hash="abc123" + ) + + svc = ResourceEquivalenceService(factory) + svc.create_link(virtual_id, physical_id, "abc123") + svc.remove_link(virtual_id, physical_id) + + links = svc.list_links(virtual_resource_id=virtual_id) + if len(links) != 0: + print(f"FAIL: expected 0 links after remove, got {len(links)}", file=sys.stderr) + sys.exit(1) + + print("remove-link-ok") + + +def type_mismatch_guard() -> None: + """Verify type mismatch prevents linking incompatible types.""" + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + from cleveragents.core.exceptions import ValidationError + + factory = _setup_db() + _seed_types(factory) + + # Add an incompatible physical type + now_iso = datetime.now(tz=UTC).isoformat() + session = factory() + session.add( + ResourceTypeModel( + name="git-commit", + namespace="builtin", + description="Git commit", + resource_kind="physical", + sandbox_strategy="none", + user_addable=True, + args_schema_json="[]", + allowed_parent_types_json="[]", + allowed_child_types_json="[]", + capabilities_json='{"read":true,"write":false,"sandbox":false,"checkpoint":false}', + source="builtin", + created_at=now_iso, + updated_at=now_iso, + ) + ) + session.commit() + session.close() + + virtual_id = _make_resource(factory, resource_kind="virtual", type_name="file") + physical_id = _make_resource( + factory, resource_kind="physical", type_name="git-commit" + ) + + svc = ResourceEquivalenceService(factory) + try: + svc.create_link(virtual_id, physical_id, "abc123") + print("FAIL: should have raised ValidationError", file=sys.stderr) + sys.exit(1) + except ValidationError: + pass + + print("type-mismatch-guard-ok") + + +def divergence_detection() -> None: + """Verify check_divergence removes stale links.""" + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + + factory = _setup_db() + _seed_types(factory) + + virtual_id = _make_resource(factory, resource_kind="virtual", type_name="file") + physical_id = _make_resource( + factory, resource_kind="physical", type_name="fs-file", content_hash="hash_v1" + ) + + svc = ResourceEquivalenceService(factory) + svc.create_link(virtual_id, physical_id, "hash_v1") + + # Physical content changed — divergence + remaining = svc.check_divergence(physical_id, "hash_v2") + if len(remaining) != 0: + print( + f"FAIL: expected 0 remaining links, got {len(remaining)}", file=sys.stderr + ) + sys.exit(1) + + print("divergence-detection-ok") + + +def reconcile_job() -> None: + """Verify reconcile removes stale content_hash links.""" + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + + factory = _setup_db() + _seed_types(factory) + + virtual_id = _make_resource(factory, resource_kind="virtual", type_name="file") + physical_id = _make_resource( + factory, + resource_kind="physical", + type_name="fs-file", + content_hash="current_hash", + ) + + svc = ResourceEquivalenceService(factory) + svc.create_link(virtual_id, physical_id, "stale_hash", link_type="content_hash") + + result = svc.reconcile() + if result["removed"] != 1: + print(f"FAIL: expected 1 removed, got {result['removed']}", file=sys.stderr) + sys.exit(1) + if result["checked"] != 1: + print(f"FAIL: expected 1 checked, got {result['checked']}", file=sys.stderr) + sys.exit(1) + + print("reconcile-job-ok") + + +def merge_virtual_resources() -> None: + """Verify merge moves links from source to target.""" + from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, + ) + + factory = _setup_db() + _seed_types(factory) + + target_id = _make_resource(factory, resource_kind="virtual", type_name="file") + source_id = _make_resource(factory, resource_kind="virtual", type_name="file") + phys_id = _make_resource( + factory, resource_kind="physical", type_name="fs-file", content_hash="hash1" + ) + + svc = ResourceEquivalenceService(factory) + svc.create_link(source_id, phys_id, "hash1") + + merged = svc.merge_virtual_resources(target_id, source_id) + if len(merged) != 1: + print(f"FAIL: expected 1 merged link, got {len(merged)}", file=sys.stderr) + sys.exit(1) + if merged[0].virtual_resource_id != target_id: + print("FAIL: merged link should point to target", file=sys.stderr) + sys.exit(1) + + # Source should have no links + source_links = svc.list_links(virtual_resource_id=source_id) + if len(source_links) != 0: + print( + f"FAIL: source should have 0 links, got {len(source_links)}", + file=sys.stderr, + ) + sys.exit(1) + + print("merge-virtual-resources-ok") + + +# --------------------------------------------------------------------------- +# CLI integration tests +# --------------------------------------------------------------------------- + + +def cli_equivalence_list() -> None: + """Test the CLI 'agents resource equivalence list' command.""" + import subprocess + + result = subprocess.run( + [sys.executable, "-m", "cleveragents", "resource", "equivalence", "list"], + capture_output=True, + text=True, + timeout=30, + ) + # The command should succeed (exit 0) even with no links + if result.returncode != 0: + print(f"FAIL: exit code {result.returncode}", file=sys.stderr) + print(f"stderr: {result.stderr}", file=sys.stderr) + sys.exit(1) + print("cli-equivalence-list-ok") + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +_COMMANDS = { + "link_type_enum": link_type_enum, + "domain_model_frozen": domain_model_frozen, + "equivalence_key_computation": equivalence_key_computation, + "create_and_list_link": create_and_list_link, + "remove_link": remove_link, + "type_mismatch_guard": type_mismatch_guard, + "divergence_detection": divergence_detection, + "reconcile_job": reconcile_job, + "merge_virtual_resources": merge_virtual_resources, + "cli_equivalence_list": cli_equivalence_list, +} + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + print(f"Commands: {list(_COMMANDS)}", file=sys.stderr) + sys.exit(1) + _COMMANDS[sys.argv[1]]() diff --git a/robot/resource_equivalence.robot b/robot/resource_equivalence.robot new file mode 100644 index 000000000..88f217ed1 --- /dev/null +++ b/robot/resource_equivalence.robot @@ -0,0 +1,101 @@ +*** Settings *** +Documentation Smoke tests for Virtual Resource Equivalence Tracking +... (domain model, equivalence keys, service CRUD, type guard, +... divergence, reconciliation, merge) +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_resource_equivalence.py + +*** Test Cases *** +LinkType Enum Values + [Documentation] Verify LinkType has content_hash, name, identity + ${result}= Run Process ${PYTHON} ${HELPER} link_type_enum + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} link-type-enum-ok + +Domain Model Is Frozen + [Documentation] Verify VirtualResourceLink is immutable + ${result}= Run Process ${PYTHON} ${HELPER} domain_model_frozen + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} domain-model-frozen-ok + +Equivalence Key Computation + [Documentation] Verify compute_equivalence_key for all link types + ${result}= Run Process ${PYTHON} ${HELPER} equivalence_key_computation + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} equivalence-key-computation-ok + +Create And List Link + [Documentation] Verify create_link and list_links round-trip + ${result}= Run Process ${PYTHON} ${HELPER} create_and_list_link + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} create-and-list-link-ok + +Remove Link + [Documentation] Verify remove_link removes an existing link + ${result}= Run Process ${PYTHON} ${HELPER} remove_link + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} remove-link-ok + +Type Mismatch Guard + [Documentation] Verify linking incompatible types raises ValidationError + ${result}= Run Process ${PYTHON} ${HELPER} type_mismatch_guard + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} type-mismatch-guard-ok + +Divergence Detection + [Documentation] Verify check_divergence removes stale links + ${result}= Run Process ${PYTHON} ${HELPER} divergence_detection + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} divergence-detection-ok + +Reconcile Job + [Documentation] Verify reconcile removes stale content_hash links + ${result}= Run Process ${PYTHON} ${HELPER} reconcile_job + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} reconcile-job-ok + +Merge Virtual Resources + [Documentation] Verify merge moves links from source to target + ${result}= Run Process ${PYTHON} ${HELPER} merge_virtual_resources + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} merge-virtual-resources-ok + +CLI Equivalence List Returns Empty + [Documentation] CLI 'agents resource equivalence list' returns 0 on empty DB + ${result}= Run Process ${PYTHON} ${HELPER} cli_equivalence_list + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-equivalence-list-ok diff --git a/src/cleveragents/application/container.py b/src/cleveragents/application/container.py index 0c494505f..14688d680 100644 --- a/src/cleveragents/application/container.py +++ b/src/cleveragents/application/container.py @@ -62,6 +62,9 @@ from cleveragents.application.services.project_service import ProjectService from cleveragents.application.services.repo_indexing_service import ( RepoIndexingService, ) +from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, +) from cleveragents.application.services.resource_file_watcher import ( ResourceFileWatcher, ) @@ -271,6 +274,18 @@ def _build_resource_registry_service( return ResourceRegistryService(session_factory=factory) +def _build_resource_equivalence_service( + database_url: str, +) -> ResourceEquivalenceService: + """Build a ResourceEquivalenceService with a session factory.""" + 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 ResourceEquivalenceService(session_factory=factory) + + def _build_namespaced_project_repo( database_url: str, ) -> NamespacedProjectRepository: @@ -633,6 +648,12 @@ class Container(containers.DeclarativeContainer): database_url=database_url, ) + # Resource Equivalence Service - virtual-physical link tracking (#334) + resource_equivalence_service = providers.Factory( + _build_resource_equivalence_service, + database_url=database_url, + ) + # Namespaced Project Repository namespaced_project_repo = providers.Factory( _build_namespaced_project_repo, diff --git a/src/cleveragents/application/services/__init__.py b/src/cleveragents/application/services/__init__.py index 9195c7f09..4df459514 100644 --- a/src/cleveragents/application/services/__init__.py +++ b/src/cleveragents/application/services/__init__.py @@ -559,6 +559,14 @@ _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "ValidationPipeline": ("validation_pipeline", "ValidationPipeline"), "ValidationResult": ("validation_pipeline", "ValidationResult"), "ValidationSummary": ("validation_pipeline", "ValidationSummary"), + "ResourceEquivalenceService": ( + "resource_equivalence_service", + "ResourceEquivalenceService", + ), + "compute_equivalence_key": ( + "resource_equivalence_service", + "compute_equivalence_key", + ), } diff --git a/src/cleveragents/application/services/_resource_equivalence_helpers.py b/src/cleveragents/application/services/_resource_equivalence_helpers.py new file mode 100644 index 000000000..7c6f0d4c6 --- /dev/null +++ b/src/cleveragents/application/services/_resource_equivalence_helpers.py @@ -0,0 +1,228 @@ +"""Internal helpers for resource equivalence tracking. + +Extracted from ``resource_equivalence_service.py`` to keep that module +within the 500-line limit. Contains: + +- ``compute_equivalence_key`` — public helper for computing hash/name/identity keys. +- ``ALLOWED_PAIRINGS`` — virtual ↔ physical type compatibility map. +- ``db_link_to_domain`` — ORM row → domain model converter. +- ``validate_link_inputs`` — shared input validation for link creation. + +Based on: + +- Specification: Equivalence linking (lines 24397-24417) +- Issue #334: Virtual resource equivalence tracking +""" + +from __future__ import annotations + +import hashlib +from datetime import datetime +from pathlib import Path + +from cleveragents.core.exceptions import ValidationError +from cleveragents.domain.models.core.virtual_resource_link import ( + LinkType, + VirtualResourceLink, +) +from cleveragents.infrastructure.database.models import ( + VirtualResourceLinkModel, +) + +# --------------------------------------------------------------------------- +# Equivalence key computation +# --------------------------------------------------------------------------- + + +def compute_equivalence_key( + content: str | None = None, + name: str | None = None, + *, + link_type: str = "content_hash", +) -> str: + """Compute an equivalence key for auto-linking during discovery. + + For ``content_hash`` link type, computes SHA-256 of the content. + For ``name`` link type, normalises the name (lowercase, stripped). + For ``identity`` link type, uses the name directly as the identity. + + Args: + content: Content bytes/string for hash-based equivalence. + name: Name or identity string for name/identity equivalence. + link_type: One of ``content_hash``, ``name``, or ``identity``. + + Returns: + The computed equivalence key string. + + Raises: + ValidationError: If required input is missing for the link type. + """ + if link_type == "content_hash": + if content is None: + raise ValidationError( + message="Content is required for content_hash equivalence", + details={"link_type": link_type}, + ) + return hashlib.sha256(content.encode("utf-8")).hexdigest() + elif link_type == "name": + if name is None: + raise ValidationError( + message="Name is required for name equivalence", + details={"link_type": link_type}, + ) + stripped = name.strip() + if not stripped: + raise ValidationError( + message="Name cannot be empty or whitespace-only", + details={"link_type": link_type}, + ) + return stripped.lower() + elif link_type == "identity": + if name is None: + raise ValidationError( + message="Identity value is required for identity equivalence", + details={"link_type": link_type}, + ) + # Identity keys are opaque pass-through values (e.g., commit + # hashes, UUIDs). Only reject truly empty strings; do NOT + # strip whitespace as that would alter the identity value. + if not name or not name.strip(): + raise ValidationError( + message="Identity value cannot be empty or whitespace-only", + details={"link_type": link_type}, + ) + return name + else: + raise ValidationError( + message=f"Unknown link type: {link_type}", + details={"link_type": link_type}, + ) + + +def compute_equivalence_key_from_path( + path: Path, + *, + chunk_size: int = 65536, +) -> str: + """Stream-hash a file to compute a content_hash equivalence key. + + Reads the file in chunks so multi-GB files do not cause OOM. + + Args: + path: Path to the file to hash. + chunk_size: Read buffer size in bytes (default 64 KiB). + + Returns: + SHA-256 hex digest of the file content. + + Raises: + ValidationError: If the file does not exist or is not readable. + """ + if not path.is_file(): + raise ValidationError( + message=f"Path is not a readable file: {path}", + details={"path": str(path)}, + ) + hasher = hashlib.sha256() + with path.open("rb") as fh: + while True: + chunk = fh.read(chunk_size) + if not chunk: + break + hasher.update(chunk) + return hasher.hexdigest() + + +# --------------------------------------------------------------------------- +# Allowed type pairings for virtual-physical linking +# --------------------------------------------------------------------------- + +# Maps virtual type name to allowed physical type names. +# If a virtual type is not in this table, any physical type is allowed +# (relaxed mode for user-defined types). +# +# Virtual type names are unprefixed per spec (line 10223): +# "file", "directory", "commit", etc. — NOT "v-file", "v-directory". +ALLOWED_PAIRINGS: dict[str, tuple[str, ...]] = { + "file": ("fs-file", "git-tree-entry"), + "directory": ("fs-directory", "git-tree"), + "commit": ("git-commit",), + "branch": ("git-branch",), + "tag": ("git-tag",), + "remote": ("git-remote",), + "submodule": ("git-submodule",), + "tree": ("git-tree",), + "symlink": ("fs-symlink", "git-tree-entry"), +} + + +# --------------------------------------------------------------------------- +# ORM → domain conversion +# --------------------------------------------------------------------------- + + +def db_link_to_domain(row: VirtualResourceLinkModel) -> VirtualResourceLink: + """Convert a VirtualResourceLinkModel DB row to domain object.""" + return VirtualResourceLink( + id=str(row.id), + virtual_resource_id=str(row.virtual_resource_id), + physical_resource_id=str(row.physical_resource_id), + equivalence_key=str(row.equivalence_key), + link_type=LinkType(str(row.link_type)), + confidence=float(str(row.confidence)), + created_at=datetime.fromisoformat(str(row.created_at)), + updated_at=datetime.fromisoformat(str(row.updated_at)), + ) + + +# --------------------------------------------------------------------------- +# Input validation +# --------------------------------------------------------------------------- + + +def validate_link_inputs( + equivalence_key: str, + confidence: float, + link_type: str, +) -> str: + """Validate and normalise inputs for link creation. + + Args: + equivalence_key: The computed equivalence key. + confidence: Confidence score (0.0-1.0). + link_type: How equivalence was established. + + Returns: + The stripped equivalence key. + + Raises: + ValidationError: If any input is invalid. + """ + if not equivalence_key or not equivalence_key.strip(): + raise ValidationError( + message="equivalence_key cannot be empty or whitespace-only", + details={"equivalence_key": equivalence_key}, + ) + if confidence < 0.0 or confidence > 1.0: + raise ValidationError( + message="confidence must be between 0.0 and 1.0", + details={"confidence": confidence}, + ) + if link_type not in ("content_hash", "name", "identity"): + raise ValidationError( + message=f"Invalid link_type: {link_type}", + details={"link_type": link_type}, + ) + # Identity keys are opaque pass-through; do NOT strip. + if link_type == "identity": + return equivalence_key + return equivalence_key.strip() + + +__all__ = [ + "ALLOWED_PAIRINGS", + "compute_equivalence_key", + "compute_equivalence_key_from_path", + "db_link_to_domain", + "validate_link_inputs", +] diff --git a/src/cleveragents/application/services/_resource_equivalence_reconciler.py b/src/cleveragents/application/services/_resource_equivalence_reconciler.py new file mode 100644 index 000000000..097902b82 --- /dev/null +++ b/src/cleveragents/application/services/_resource_equivalence_reconciler.py @@ -0,0 +1,128 @@ +"""Batch reconciliation for virtual resource equivalence links. + +Extracted from ``resource_equivalence_service.py`` to keep that module +within the 500-line limit. + +Issue #334 · spec section "Virtual Resource Equivalence". +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import structlog + +from cleveragents.infrastructure.database.models import ( + ResourceModel, + VirtualResourceLinkModel, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + +logger = structlog.get_logger(__name__) + +#: SQLite SQLITE_MAX_VARIABLE_NUMBER safe limit for IN clauses. +_IN_CHUNK_SIZE = 500 + + +def reconcile_links(session: Session) -> dict[str, int]: + """Batch-verify content_hash links; remove stale ones. + + For each ``content_hash`` link, the physical resource's current + ``content_hash`` is compared to the link's ``equivalence_key``. + Links are removed when: + + - The physical resource no longer exists (orphan). + - The physical resource has no ``content_hash`` (cannot verify). + - The hashes do not match (content diverged). + + Non-``content_hash`` links (``name``, ``identity``) are kept + unconditionally. + + Args: + session: Active SQLAlchemy session (caller manages commit/rollback). + + Returns: + Dict with ``checked``, ``removed``, ``kept`` counts. + """ + # Count non-content_hash links (kept unconditionally) + kept = ( + session.query(VirtualResourceLinkModel) + .filter(VirtualResourceLinkModel.link_type != "content_hash") + .count() + ) + + # Load only content_hash links (server-side filter) + hash_links = ( + session.query(VirtualResourceLinkModel) + .filter(VirtualResourceLinkModel.link_type == "content_hash") + .all() + ) + + checked = len(hash_links) + removed = 0 + + if hash_links: + # Batch-load physical resources in chunks to avoid + # exceeding SQLite's SQLITE_MAX_VARIABLE_NUMBER limit. + physical_ids = list({str(link.physical_resource_id) for link in hash_links}) + hash_by_id: dict[str, str | None] = {} + for i in range(0, len(physical_ids), _IN_CHUNK_SIZE): + chunk = physical_ids[i : i + _IN_CHUNK_SIZE] + physical_rows = ( + session.query(ResourceModel) + .filter(ResourceModel.resource_id.in_(chunk)) + .all() + ) + for row in physical_rows: + hash_by_id[str(row.resource_id)] = ( + str(row.content_hash) if row.content_hash is not None else None + ) + + for link in hash_links: + phys_id = str(link.physical_resource_id) + if phys_id not in hash_by_id: + # Physical resource no longer exists — orphan link + session.delete(link) + removed += 1 + logger.warning( + "equivalence_link.orphan_removed", + link_id=str(link.id), + physical_resource_id=phys_id, + ) + elif hash_by_id[phys_id] is None: + # Physical resource has no content_hash — cannot verify + session.delete(link) + removed += 1 + logger.warning( + "equivalence_link.no_hash_removed", + link_id=str(link.id), + physical_resource_id=phys_id, + ) + elif hash_by_id[phys_id] != str(link.equivalence_key): + # Hash mismatch — content diverged + session.delete(link) + removed += 1 + logger.info( + "equivalence_link.hash_mismatch_removed", + link_id=str(link.id), + stored_key=str(link.equivalence_key)[:8], + current_hash=(hash_by_id[phys_id] or "")[:8], + ) + else: + kept += 1 + + session.flush() + + logger.info( + "equivalence_link.reconciliation_complete", + checked=checked, + removed=removed, + kept=kept, + ) + + return {"checked": checked, "removed": removed, "kept": kept} + + +__all__ = ["reconcile_links"] diff --git a/src/cleveragents/application/services/resource_equivalence_service.py b/src/cleveragents/application/services/resource_equivalence_service.py new file mode 100644 index 000000000..841fb4067 --- /dev/null +++ b/src/cleveragents/application/services/resource_equivalence_service.py @@ -0,0 +1,498 @@ +"""Resource Equivalence Service — CRUD, merge, divergence, and reconciliation. + +Manages virtual-to-physical resource equivalence links. Physical resources +take precedence; divergent links are removed automatically. + +See also: ``_resource_equivalence_helpers.py`` for ``compute_equivalence_key``, +type-pairing map, and shared validators. + +Issue #334 · spec section "Virtual Resource Equivalence". +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +import structlog +from sqlalchemy.exc import IntegrityError +from ulid import ULID + +from cleveragents.application.services._resource_equivalence_helpers import ( + ALLOWED_PAIRINGS, + compute_equivalence_key, + db_link_to_domain, + validate_link_inputs, +) +from cleveragents.core.exceptions import NotFoundError, ValidationError +from cleveragents.domain.models.core.virtual_resource_link import ( + LinkType, + VirtualResourceLink, +) +from cleveragents.infrastructure.database.models import ( + ResourceModel, + VirtualResourceLinkModel, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + +logger = structlog.get_logger(__name__) + + +class ResourceEquivalenceService: + """Virtual resource equivalence tracking (session-factory pattern). + + INVARIANTS (must hold after every public method returns): + 1. Every link references valid resource IDs in the resources table. + 2. virtual_resource_id → resource_kind='virtual'. + 3. physical_resource_id → resource_kind='physical'. + 4. (virtual_resource_id, physical_resource_id) is unique. + 5. confidence in [0.0, 1.0]; link_type in {content_hash, name, identity}. + 6. equivalence_key is never empty or whitespace-only. + + Args: + session_factory: Callable returning a new SQLAlchemy ``Session``. + """ + + def __init__(self, session_factory: Callable[[], Session]) -> None: + self._session_factory = session_factory + + def _session(self) -> Session: + return self._session_factory() + + @staticmethod + def _verify_resource( + session: Session, + resource_id: str, + expected_kind: str, + ) -> ResourceModel: + """Load a resource row, raising if missing or wrong kind.""" + row = session.query(ResourceModel).filter_by(resource_id=resource_id).first() + if row is None: + raise NotFoundError(resource_type="resource", resource_id=resource_id) + if str(row.resource_kind) != expected_kind: + raise ValidationError( + message=( + f"Resource '{resource_id}' is not {expected_kind} " + f"(kind={row.resource_kind})" + ), + details={ + "resource_id": resource_id, + "resource_kind": str(row.resource_kind), + }, + ) + return row + + # -- Create Link --------------------------------------------------------- + + def create_link( + self, + virtual_resource_id: str, + physical_resource_id: str, + equivalence_key: str, + *, + link_type: str = LinkType.CONTENT_HASH, + confidence: float = 1.0, + ) -> VirtualResourceLink: + """Create a virtual-to-physical equivalence link. + + Args: + virtual_resource_id: ULID of the virtual resource. + physical_resource_id: ULID of the physical resource. + equivalence_key: Computed equivalence key. + link_type: How equivalence was established. + confidence: Confidence score (0.0-1.0). + + Returns: + The created ``VirtualResourceLink`` domain object. + + Raises: + NotFoundError: If either resource does not exist. + ValidationError: On bad inputs, type mismatch, or duplicate link. + """ + equivalence_key = validate_link_inputs(equivalence_key, confidence, link_type) + + session = self._session() + try: + virtual_row = self._verify_resource(session, virtual_resource_id, "virtual") + physical_row = self._verify_resource( + session, + physical_resource_id, + "physical", + ) + + # Guard: prevent linking incompatible types + virtual_type = str(virtual_row.type_name) + physical_type = str(physical_row.type_name) + if virtual_type in ALLOWED_PAIRINGS: + allowed = ALLOWED_PAIRINGS[virtual_type] + if physical_type not in allowed: + raise ValidationError( + message=( + f"Type mismatch: virtual type '{virtual_type}' " + f"cannot link to physical type '{physical_type}'. " + f"Allowed: {', '.join(allowed)}" + ), + details={ + "virtual_type": virtual_type, + "physical_type": physical_type, + "allowed_physical_types": list(allowed), + }, + ) + + # Check for duplicate link + existing = ( + session.query(VirtualResourceLinkModel) + .filter_by( + virtual_resource_id=virtual_resource_id, + physical_resource_id=physical_resource_id, + ) + .first() + ) + if existing is not None: + raise ValidationError( + message=( + f"Link already exists: " + f"{virtual_resource_id} -> {physical_resource_id}" + ), + details={ + "virtual_resource_id": virtual_resource_id, + "physical_resource_id": physical_resource_id, + }, + ) + + link_id = str(ULID()) + now_iso = datetime.now(tz=UTC).isoformat() + + db_link = VirtualResourceLinkModel( + id=link_id, + virtual_resource_id=virtual_resource_id, + physical_resource_id=physical_resource_id, + equivalence_key=equivalence_key, + link_type=link_type, + confidence=confidence, + created_at=now_iso, + updated_at=now_iso, + ) + session.add(db_link) + try: + session.flush() + session.commit() + except IntegrityError as exc: + session.rollback() + raise ValidationError( + message=( + f"Link already exists: " + f"{virtual_resource_id} -> {physical_resource_id}" + ), + details={ + "virtual_resource_id": virtual_resource_id, + "physical_resource_id": physical_resource_id, + }, + ) from exc + + logger.info( + "equivalence_link.created", + link_id=link_id, + virtual_resource_id=virtual_resource_id, + physical_resource_id=physical_resource_id, + equivalence_key=equivalence_key[:8] + "...", + link_type=link_type, + ) + + return VirtualResourceLink( + id=link_id, + virtual_resource_id=virtual_resource_id, + physical_resource_id=physical_resource_id, + equivalence_key=equivalence_key, + link_type=LinkType(link_type), + confidence=confidence, + created_at=datetime.fromisoformat(now_iso), + updated_at=datetime.fromisoformat(now_iso), + ) + except Exception: + session.rollback() + raise + finally: + session.close() + + # -- List Links ---------------------------------------------------------- + + def list_links( + self, + virtual_resource_id: str | None = None, + physical_resource_id: str | None = None, + *, + limit: int = 200, + offset: int = 0, + ) -> list[VirtualResourceLink]: + """List equivalence links, optionally filtered by virtual/physical ID. + + Args: + virtual_resource_id: Filter by virtual resource ULID. + physical_resource_id: Filter by physical resource ULID. + limit: Maximum number of results (default 200). + offset: Number of results to skip (default 0). + + Returns: + List of ``VirtualResourceLink`` domain objects ordered by created_at. + """ + session = self._session() + try: + query = session.query(VirtualResourceLinkModel) + if virtual_resource_id is not None: + query = query.filter_by(virtual_resource_id=virtual_resource_id) + if physical_resource_id is not None: + query = query.filter_by(physical_resource_id=physical_resource_id) + query = query.order_by(VirtualResourceLinkModel.created_at) + query = query.limit(limit).offset(offset) + rows = query.all() + return [db_link_to_domain(row) for row in rows] + finally: + session.close() + + # -- Remove Link --------------------------------------------------------- + + def remove_link( + self, + virtual_resource_id: str, + physical_resource_id: str, + ) -> None: + """Remove an equivalence link. + + Args: + virtual_resource_id: ULID of the virtual resource. + physical_resource_id: ULID of the physical resource. + + Raises: + NotFoundError: If the link does not exist. + """ + session = self._session() + try: + row = ( + session.query(VirtualResourceLinkModel) + .filter_by( + virtual_resource_id=virtual_resource_id, + physical_resource_id=physical_resource_id, + ) + .first() + ) + if row is None: + raise NotFoundError( + resource_type="virtual_resource_link", + resource_id=(f"{virtual_resource_id} -> {physical_resource_id}"), + ) + session.delete(row) + session.flush() + session.commit() + + logger.info( + "equivalence_link.removed", + virtual_resource_id=virtual_resource_id, + physical_resource_id=physical_resource_id, + ) + except Exception: + session.rollback() + raise + finally: + session.close() + + # -- Merge Virtual Resources --------------------------------------------- + + def merge_virtual_resources( + self, + target_virtual_id: str, + source_virtual_id: str, + ) -> list[VirtualResourceLink]: + """Move all links from *source* virtual resource to *target*. + + Duplicate links (physical already linked to target) are skipped. + Returns all links for the target after the merge. + + Raises: + ValidationError: If source equals target or concurrent conflict. + NotFoundError: If either virtual resource does not exist. + """ + if target_virtual_id == source_virtual_id: + raise ValidationError( + message="Cannot merge a virtual resource into itself", + details={ + "target_virtual_id": target_virtual_id, + "source_virtual_id": source_virtual_id, + }, + ) + + session = self._session() + try: + for res_id in (target_virtual_id, source_virtual_id): + self._verify_resource(session, res_id, "virtual") + + # Get all links from source + source_links = ( + session.query(VirtualResourceLinkModel) + .filter_by(virtual_resource_id=source_virtual_id) + .all() + ) + + # Batch-load existing target links to avoid N+1 queries + target_links = ( + session.query(VirtualResourceLinkModel) + .filter_by(virtual_resource_id=target_virtual_id) + .all() + ) + target_physical_ids = { + str(lnk.physical_resource_id) for lnk in target_links + } + + now_iso = datetime.now(tz=UTC).isoformat() + moved_count = 0 + + for link in source_links: + physical_id = str(link.physical_resource_id) + if physical_id not in target_physical_ids: + # Move the link to the target (setattr for ORM Column compat) + setattr(link, "virtual_resource_id", target_virtual_id) # noqa: B010 + setattr(link, "updated_at", now_iso) # noqa: B010 + target_physical_ids.add(physical_id) + moved_count += 1 + else: + # Skip duplicate — delete the source link + session.delete(link) + + try: + session.flush() + except IntegrityError as exc: + session.rollback() + raise ValidationError( + message=( + "Merge conflict: concurrent modification detected. " + "Retry the merge operation." + ), + details={ + "target_virtual_id": target_virtual_id, + "source_virtual_id": source_virtual_id, + }, + ) from exc + + # Query result within the SAME session to avoid dual-session issues + result_rows = ( + session.query(VirtualResourceLinkModel) + .filter_by(virtual_resource_id=target_virtual_id) + .order_by(VirtualResourceLinkModel.created_at) + .all() + ) + result_links = [db_link_to_domain(r) for r in result_rows] + + session.commit() + + logger.info( + "equivalence_link.merged", + source_virtual_id=source_virtual_id, + target_virtual_id=target_virtual_id, + moved_count=moved_count, + ) + + return result_links + except Exception: + session.rollback() + raise + finally: + session.close() + + # -- Divergence Detection ------------------------------------------------ + + def check_divergence( + self, + physical_resource_id: str, + new_equivalence_key: str, + ) -> list[VirtualResourceLink]: + """Remove *content_hash* links whose key no longer matches. + + Only ``content_hash`` links are compared; ``name``/``identity`` + links are kept unconditionally. Returns surviving links. + + Raises: + ValidationError: If ``new_equivalence_key`` is empty. + """ + if not new_equivalence_key or not new_equivalence_key.strip(): + raise ValidationError( + message="new_equivalence_key cannot be empty", + details={"new_equivalence_key": new_equivalence_key}, + ) + + new_equivalence_key = new_equivalence_key.strip() + session = self._session() + try: + links = ( + session.query(VirtualResourceLinkModel) + .filter_by(physical_resource_id=physical_resource_id) + .all() + ) + + remaining: list[VirtualResourceLink] = [] + now_iso = datetime.now(tz=UTC).isoformat() + + for link in links: + link_type = str(link.link_type) + + if link_type != "content_hash": + # Non-content-hash links are kept unconditionally + remaining.append(db_link_to_domain(link)) + continue + + if str(link.equivalence_key) != new_equivalence_key: + logger.warning( + "equivalence_link.divergence_detected", + physical_resource_id=physical_resource_id, + virtual_resource_id=str(link.virtual_resource_id), + old_key=str(link.equivalence_key)[:8], + new_key=new_equivalence_key[:8], + ) + session.delete(link) + else: + setattr(link, "updated_at", now_iso) # noqa: B010 + remaining.append(db_link_to_domain(link)) + + session.flush() + session.commit() + + return remaining + except Exception: + session.rollback() + raise + finally: + session.close() + + # -- Reconciliation Job -------------------------------------------------- + + def reconcile(self) -> dict[str, int]: + """Batch-verify content_hash links; remove stale ones. + + Delegates to ``_resource_equivalence_reconciler.reconcile_links`` + (extracted for the 500-line module limit). + + Returns: + Dict with ``checked``, ``removed``, ``kept`` counts. + """ + from cleveragents.application.services._resource_equivalence_reconciler import ( + reconcile_links, + ) + + session = self._session() + try: + result = reconcile_links(session) + session.commit() + return result + except Exception: + session.rollback() + raise + finally: + session.close() + + +__all__ = [ + "ResourceEquivalenceService", + "compute_equivalence_key", +] diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index 420d5f864..af1a6b32e 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -67,6 +67,9 @@ from cleveragents.application.container import get_container from cleveragents.application.services.resource_registry_service import ( ResourceRegistryService, ) +from cleveragents.cli.commands.resource_equivalence import ( + app as equivalence_app, +) from cleveragents.cli.formatting import OutputFormat, format_output from cleveragents.cli.renderers import _get_console from cleveragents.core.exceptions import ( @@ -97,6 +100,9 @@ type_app = typer.Typer( ) app.add_typer(type_app, name="type") +# Sub-app for ``agents resource equivalence ...`` +app.add_typer(equivalence_app, name="equivalence") + console = _get_console() _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" diff --git a/src/cleveragents/cli/commands/resource_equivalence.py b/src/cleveragents/cli/commands/resource_equivalence.py new file mode 100644 index 000000000..bf4063eec --- /dev/null +++ b/src/cleveragents/cli/commands/resource_equivalence.py @@ -0,0 +1,307 @@ +"""Resource equivalence CLI commands for CleverAgents. + +The ``agents resource equivalence`` command group manages virtual-to-physical +resource equivalence links. + +## Commands + +| Command | Description | +|--------------------------------------|--------------------------------------| +| ``agents resource equivalence list`` | List equivalence links | +| ``agents resource equivalence add`` | Add an equivalence link | +| ``agents resource equivalence remove``| Remove an equivalence link | + +## Example Usage + +```bash +# List all links for a virtual resource +agents resource equivalence list --virtual 01HXYZ... + +# Add an equivalence link +agents resource equivalence add VIRT_ULID PHYS_ULID --key abc123 + +# Remove an equivalence link +agents resource equivalence remove VIRT_ULID PHYS_ULID +``` + +Based on Issue #334: Virtual resource equivalence tracking. +""" + +from __future__ import annotations + +from typing import Annotated + +import typer +from rich.table import Table + +from cleveragents.application.container import get_container +from cleveragents.application.services.resource_equivalence_service import ( + ResourceEquivalenceService, +) +from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.cli.renderers import _get_console +from cleveragents.core.exceptions import ( + CleverAgentsError, + NotFoundError, + ValidationError, +) +from cleveragents.domain.models.core.virtual_resource_link import ( + VirtualResourceLink, +) + +# Sub-app for ``agents resource equivalence ...`` +app = typer.Typer( + help="Manage virtual resource equivalence sets.", +) + +console = _get_console() + +_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" + + +def _validate_ulid(value: str, name: str) -> None: + """Validate that a string looks like a ULID (26 uppercase alphanumeric).""" + if len(value) != 26 or not value.isalnum(): + console.print( + f"[red]Invalid ULID for {name}:[/red] '{value}'. " + f"Expected 26 alphanumeric characters." + ) + raise typer.Abort() + + +def _get_equivalence_service() -> ResourceEquivalenceService: + """Get the ResourceEquivalenceService from the DI container.""" + container = get_container() + service: ResourceEquivalenceService = container.resource_equivalence_service() + return service + + +def _equiv_link_dict(link: VirtualResourceLink) -> dict[str, object]: + """Convert a VirtualResourceLink to a plain dict for output.""" + return { + "id": link.id, + "virtual_resource_id": link.virtual_resource_id, + "physical_resource_id": link.physical_resource_id, + "equivalence_key": link.equivalence_key, + "link_type": str(link.link_type), + "confidence": link.confidence, + "created_at": link.created_at.isoformat(), + "updated_at": link.updated_at.isoformat(), + } + + +@app.command("list") +def equivalence_list( + virtual_id: Annotated[ + str | None, + typer.Option("--virtual", "-v", help="Filter by virtual resource ULID"), + ] = None, + physical_id: Annotated[ + str | None, + typer.Option("--physical", "-p", help="Filter by physical resource ULID"), + ] = None, + fmt: Annotated[ + str, + typer.Option("--format", "-f", help=_FORMAT_HELP), + ] = "rich", +) -> None: + """List virtual resource equivalence links. + + Examples: + agents resource equivalence list + agents resource equivalence list --virtual 01HXYZ... + agents resource equivalence list --physical 01HABC... + agents resource equivalence list --format json + """ + try: + service = _get_equivalence_service() + links = service.list_links( + virtual_resource_id=virtual_id, + physical_resource_id=physical_id, + ) + + if not links: + console.print("[yellow]No equivalence links found.[/yellow]") + return + + if fmt != OutputFormat.RICH.value: + data = [_equiv_link_dict(lnk) for lnk in links] + console.print(format_output(data, fmt)) + return + + table = Table(title=f"Equivalence Links ({len(links)} total)") + table.add_column("ID", style="dim") + table.add_column("Virtual", style="cyan") + table.add_column("Physical", style="blue") + table.add_column("Key", style="magenta") + table.add_column("Type", style="green") + table.add_column("Confidence") + + for lnk in links: + lid = lnk.id + if len(lid) > 12: + lid = lid[:12] + "..." + vid = lnk.virtual_resource_id + if len(vid) > 12: + vid = vid[:12] + "..." + pid = lnk.physical_resource_id + if len(pid) > 12: + pid = pid[:12] + "..." + key = lnk.equivalence_key + if len(key) > 20: + key = key[:17] + "..." + table.add_row( + lid, + vid, + pid, + key, + str(lnk.link_type), + f"{lnk.confidence:.2f}", + ) + + console.print(table) + + except CleverAgentsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + raise typer.Abort() from exc + except Exception as exc: + if isinstance(exc, (typer.Abort, typer.Exit)): + raise + console.print(f"[red]Unexpected error:[/red] {exc}") + raise typer.Abort() from exc + + +@app.command("add") +def equivalence_add( + virtual_id: Annotated[ + str, + typer.Argument(help="Virtual resource ULID"), + ], + physical_id: Annotated[ + str, + typer.Argument(help="Physical resource ULID"), + ], + equivalence_key: Annotated[ + str, + typer.Option("--key", "-k", help="Equivalence key (hash, name, or identity)"), + ], + link_type: Annotated[ + str, + typer.Option("--type", "-t", help="Link type: content_hash, name, or identity"), + ] = "content_hash", + confidence: Annotated[ + float, + typer.Option("--confidence", "-c", help="Confidence score (0.0-1.0)"), + ] = 1.0, + fmt: Annotated[ + str, + typer.Option("--format", "-f", help=_FORMAT_HELP), + ] = "rich", +) -> None: + """Add a virtual resource equivalence link. + + Examples: + agents resource equivalence add VIRT_ULID PHYS_ULID --key abc123 + agents resource equivalence add VIRT_ULID PHYS_ULID --key main --type name + """ + _validate_ulid(virtual_id, "virtual_id") + _validate_ulid(physical_id, "physical_id") + try: + service = _get_equivalence_service() + link = service.create_link( + virtual_resource_id=virtual_id, + physical_resource_id=physical_id, + equivalence_key=equivalence_key, + link_type=link_type, + confidence=confidence, + ) + + if fmt != OutputFormat.RICH.value: + data = _equiv_link_dict(link) + console.print(format_output(data, fmt)) + return + + console.print( + f"[green]Added equivalence link:[/green] " + f"{virtual_id} -> {physical_id} (key={equivalence_key})" + ) + + except NotFoundError as exc: + console.print(f"[red]Resource not found:[/red] {exc.resource_id}") + raise typer.Abort() from exc + except ValidationError as exc: + console.print(f"[red]Validation error:[/red] {exc.message}") + raise typer.Abort() from exc + except CleverAgentsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + raise typer.Abort() from exc + except Exception as exc: + if isinstance(exc, (typer.Abort, typer.Exit)): + raise + console.print(f"[red]Unexpected error:[/red] {exc}") + raise typer.Abort() from exc + + +@app.command("remove") +def equivalence_remove( + virtual_id: Annotated[ + str, + typer.Argument(help="Virtual resource ULID"), + ], + physical_id: Annotated[ + str, + typer.Argument(help="Physical resource ULID"), + ], + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip confirmation prompt"), + ] = False, + fmt: Annotated[ + str, + typer.Option("--format", "-f", help=_FORMAT_HELP), + ] = "rich", +) -> None: + """Remove a virtual resource equivalence link. + + Examples: + agents resource equivalence remove VIRT_ULID PHYS_ULID + agents resource equivalence remove --yes VIRT_ULID PHYS_ULID + """ + _validate_ulid(virtual_id, "virtual_id") + _validate_ulid(physical_id, "physical_id") + try: + if not yes: + confirm = typer.confirm( + f"Remove equivalence link '{virtual_id}' -> '{physical_id}'?", + ) + if not confirm: + console.print("[yellow]Aborted.[/yellow]") + raise typer.Abort() + + service = _get_equivalence_service() + service.remove_link(virtual_id, physical_id) + + if fmt != OutputFormat.RICH.value: + data = { + "status": "removed", + "virtual_resource_id": virtual_id, + "physical_resource_id": physical_id, + } + console.print(format_output(data, fmt)) + return + + console.print( + f"[green]Removed equivalence link:[/green] {virtual_id} -> {physical_id}" + ) + + except NotFoundError as exc: + console.print(f"[red]Equivalence link not found:[/red] {exc.resource_id}") + raise typer.Abort() from exc + except CleverAgentsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + raise typer.Abort() from exc + except Exception as exc: + if isinstance(exc, (typer.Abort, typer.Exit)): + raise + console.print(f"[red]Unexpected error:[/red] {exc}") + raise typer.Abort() from exc diff --git a/src/cleveragents/domain/models/core/__init__.py b/src/cleveragents/domain/models/core/__init__.py index 1bdc61ec1..b93f9fd34 100644 --- a/src/cleveragents/domain/models/core/__init__.py +++ b/src/cleveragents/domain/models/core/__init__.py @@ -314,6 +314,12 @@ from cleveragents.domain.models.core.uko import ( UKOVersion, ) +# Virtual resource equivalence link models +from cleveragents.domain.models.core.virtual_resource_link import ( + LinkType, + VirtualResourceLink, +) + __all__ = [ "ASYNC_TERMINAL_STATUSES", "BUILTIN_PROFILES", @@ -424,6 +430,7 @@ __all__ = [ "InvocationTracker", "LegacyChangeSet", "LifecyclePlan", + "LinkType", "LinkedResource", "MaxContextCount", "MessageRole", @@ -531,6 +538,7 @@ __all__ = [ "User", "Validation", "ValidationMode", + "VirtualResourceLink", "build_provenance_map", "can_transition", "can_transition_job", diff --git a/src/cleveragents/domain/models/core/virtual_resource_link.py b/src/cleveragents/domain/models/core/virtual_resource_link.py new file mode 100644 index 000000000..405fc6569 --- /dev/null +++ b/src/cleveragents/domain/models/core/virtual_resource_link.py @@ -0,0 +1,136 @@ +"""Virtual resource equivalence link domain models. + +A **VirtualResourceLink** maps a virtual resource to a physical resource, +recording the equivalence relationship between them. Equivalence is +determined by a computed key (content hash, name, or identity) and a +link type that describes how the equivalence was established. + +## Link Types + +| Value | Description | +|----------------|--------------------------------------------------| +| `content_hash` | Equivalence by content hash (SHA-256, Merkle) | +| `name` | Equivalence by name (branch name, tag name, URL) | +| `identity` | Identity equivalence (commit hash, tree hash) | + +## Conflict Policy + +Physical resources take precedence over virtual resources. When a +physical resource diverges from its virtual parent, the link is removed +and a divergence event is logged. Virtual resources are never mutated +directly — they serve as read-only identity anchors. + +Based on: + +- Specification: Physical vs Virtual Resources (lines 24383-24435) +- Specification: Lazy Virtual Node Materialization (lines 24437-24468) +- Specification: Equivalence linking (lines 24397-24417) +- Issue #334: Virtual resource equivalence tracking +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# ULID: 26 characters, Crockford's base32 +ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$" + + +class LinkType(StrEnum): + """How equivalence was established between virtual and physical. + + | Value | Description | + |----------------|--------------------------------------------------| + | `content_hash` | Equivalence by content hash (SHA-256, Merkle) | + | `name` | Equivalence by name (branch name, tag name, URL) | + | `identity` | Identity equivalence (commit hash, tree hash) | + """ + + CONTENT_HASH = "content_hash" + NAME = "name" + IDENTITY = "identity" + + +class VirtualResourceLink(BaseModel): + """Domain model for a virtual-to-physical resource equivalence link. + + Each link records that a physical resource is a manifestation of a + virtual resource's abstract identity. The ``equivalence_key`` is the + computed value that caused the link (e.g., a SHA-256 hash, a branch + name, a commit hash). + + **Constraints**: + + - ``id`` must match ULID format + - ``virtual_resource_id`` must match ULID format + - ``physical_resource_id`` must match ULID format + - ``equivalence_key`` must not be empty or whitespace-only + - ``confidence`` must be between 0.0 and 1.0 inclusive + + Based on specification.md section "Virtual Resource Equivalence". + """ + + id: str = Field( + ..., + description="Unique ULID identifier for this link", + pattern=ULID_PATTERN, + ) + virtual_resource_id: str = Field( + ..., + description="ULID of the virtual resource (abstract identity)", + pattern=ULID_PATTERN, + ) + physical_resource_id: str = Field( + ..., + description="ULID of the physical resource (concrete manifestation)", + pattern=ULID_PATTERN, + ) + equivalence_key: str = Field( + ..., + min_length=1, + max_length=1024, + description="Computed equivalence key (hash, name, or identity value)", + ) + link_type: LinkType = Field( + default=LinkType.CONTENT_HASH, + description="How equivalence was established", + ) + confidence: float = Field( + default=1.0, + ge=0.0, + le=1.0, + description="Confidence score for this equivalence (0.0-1.0)", + ) + created_at: datetime = Field( + default_factory=lambda: datetime.now(tz=UTC), + description="When this link was created (UTC)", + ) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(tz=UTC), + description="When this link was last updated (UTC)", + ) + + model_config = ConfigDict( + frozen=True, + str_strip_whitespace=True, + use_enum_values=True, + ) + + # -- Validators ---------------------------------------------------------- + + @field_validator("equivalence_key") + @classmethod + def validate_equivalence_key(cls: type[VirtualResourceLink], v: str) -> str: + """Validate equivalence key is non-empty.""" + if not v.strip(): + raise ValueError("equivalence_key cannot be empty or whitespace-only") + return v + + +__all__ = [ + "LinkType", + "VirtualResourceLink", +] diff --git a/src/cleveragents/infrastructure/database/models.py b/src/cleveragents/infrastructure/database/models.py index d9b4e8ca6..c85bba2c5 100644 --- a/src/cleveragents/infrastructure/database/models.py +++ b/src/cleveragents/infrastructure/database/models.py @@ -25,6 +25,7 @@ Alembic migrations. | ``repo_indexes`` | ``RepoIndexModel`` | Repo index metadata| | ``indexed_files`` | ``IndexedFileModel`` | Per-file records | | ``correction_attempts`` | ``CorrectionAttemptModel`` | Correction records | +| ``virtual_resource_links`` | ``VirtualResourceLinkModel`` | Equivalence links | Based on ADR-007 (Repository Pattern) and Phase 0 discovery. Includes spec-aligned lifecycle models per Stage A5 @@ -3279,6 +3280,76 @@ class CorrectionAttemptModel(Base): # type: ignore[misc] ) +# --------------------------------------------------------------------------- +# Virtual Resource Equivalence Link Models (M7 - issue #334) +# --------------------------------------------------------------------------- + + +class VirtualResourceLinkModel(Base): # type: ignore[misc] + """Database model for virtual-to-physical resource equivalence links. + + Maps virtual resource ULIDs to physical resource ULIDs with + uniqueness constraints. Used by ``ResourceEquivalenceService`` + to track which physical resources are manifestations of the same + virtual identity. + + Table: ``virtual_resource_links`` + """ + + __allow_unmapped__ = True + __tablename__ = "virtual_resource_links" + + # PK: ULID (26-char string) + id = Column(String(26), primary_key=True) + + # FK to resources (virtual) + virtual_resource_id = Column( + String(26), + ForeignKey("resources.resource_id", ondelete="CASCADE"), + nullable=False, + ) + + # FK to resources (physical) + physical_resource_id = Column( + String(26), + ForeignKey("resources.resource_id", ondelete="CASCADE"), + nullable=False, + ) + + # Computed equivalence key (hash, name, or identity value) + equivalence_key = Column(String(1024), nullable=False) + + # How equivalence was established + link_type = Column(String(30), nullable=False, default="content_hash") + + # Confidence score (0.0 - 1.0) + confidence = Column(Float, nullable=False, default=1.0) + + # Timestamps (ISO-8601 strings, up to 40 chars with tz+microseconds) + created_at = Column(String(40), nullable=False) + updated_at = Column(String(40), nullable=False) + + __table_args__ = ( + UniqueConstraint( + "virtual_resource_id", + "physical_resource_id", + name="uq_virtual_resource_links_virtual_physical", + ), + CheckConstraint( + "link_type IN ('content_hash', 'name', 'identity')", + name="ck_virtual_resource_links_link_type", + ), + CheckConstraint( + "confidence >= 0.0 AND confidence <= 1.0", + name="ck_virtual_resource_links_confidence", + ), + Index("ix_virtual_resource_links_virtual", "virtual_resource_id"), + Index("ix_virtual_resource_links_physical", "physical_resource_id"), + Index("ix_virtual_resource_links_equiv_key", "equivalence_key"), + Index("ix_virtual_resource_links_link_type", "link_type"), + ) + + # Database initialization functions def init_database(database_url: str = "sqlite:///.cleveragents/db.sqlite") -> Any: """Initialize the database. -- 2.52.0