diff --git a/CHANGELOG.md b/CHANGELOG.md index 92c5dff80..38269c415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Added built-in deferred virtual resource types: `remote`, `submodule`, and + `symlink` with equivalence metadata rules for cross-repo and cross-layer + identity tracking. Registry bootstrap includes deferred virtual types but + hides them from `resource add` scaffolding (user_addable: false). Includes + YAML configurations, Behave BDD tests (47 scenarios), Robot Framework + integration tests, ASV benchmarks, and reference documentation. (#331) - Fixed `plan execute` CLI failing with "Plan is not in an executable state (current: strategize/queued)" after strategize completed successfully. Root cause: `_get_plan_executor()` created a second `PlanLifecycleService` @@ -174,7 +180,7 @@ insertions. Includes OWL/Turtle ontology files, ProvenanceInfo model with 2 required fields (source_resource, source_path) and 3 defaulted fields (source_range, valid_from, is_current), build_detail_level_map/resolve_detail_level - utilities, and full Behave BDD tests (78 scenarios, 200 steps). (#576) + utilities, and full Behave BDD tests (78 scenarios, 200 steps). (#576) - Added `RepoIndexingService` for repository file indexing with incremental refresh, extension-based language detection, SHA-256 content hashing, and token estimation. Supports policy enforcement via include/exclude globs, diff --git a/benchmarks/resource_type_deferred_virtual_bench.py b/benchmarks/resource_type_deferred_virtual_bench.py new file mode 100644 index 000000000..288196fa9 --- /dev/null +++ b/benchmarks/resource_type_deferred_virtual_bench.py @@ -0,0 +1,116 @@ +"""ASV benchmarks for deferred virtual resource type loading (#331). + +Benchmarks cover YAML schema loading, domain model creation via +``ResourceTypeSpec.from_config``, and ``as_cli_dict`` rendering for the +three deferred virtual types: remote, submodule, symlink. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +_EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples" / "resource-types" +_REMOTE_YAML = _EXAMPLES_DIR / "remote.yaml" +_SUBMODULE_YAML = _EXAMPLES_DIR / "submodule.yaml" +_SYMLINK_YAML = _EXAMPLES_DIR / "symlink.yaml" + + +class TimeDeferredVirtualSchemaLoad: + """Benchmark deferred virtual resource type YAML schema loading.""" + + def setup(self) -> None: + """Set up schema class and file paths.""" + from cleveragents.resource.schema import ResourceTypeConfigSchema + + self.schema_cls = ResourceTypeConfigSchema + self.remote_path = _REMOTE_YAML + self.submodule_path = _SUBMODULE_YAML + self.symlink_path = _SYMLINK_YAML + + def time_load_remote_schema(self) -> None: + """Benchmark loading remote.yaml via schema loader.""" + self.schema_cls.from_yaml_file(self.remote_path) + + def time_load_submodule_schema(self) -> None: + """Benchmark loading submodule.yaml via schema loader.""" + self.schema_cls.from_yaml_file(self.submodule_path) + + def time_load_symlink_schema(self) -> None: + """Benchmark loading symlink.yaml via schema loader.""" + self.schema_cls.from_yaml_file(self.symlink_path) + + def time_load_all_deferred_virtual_schemas(self) -> None: + """Benchmark loading all three deferred virtual type schemas.""" + self.schema_cls.from_yaml_file(self.remote_path) + self.schema_cls.from_yaml_file(self.submodule_path) + self.schema_cls.from_yaml_file(self.symlink_path) + + +class TimeDeferredVirtualDomainModelLoad: + """Benchmark deferred virtual resource type domain model creation.""" + + def setup(self) -> None: + """Set up ResourceTypeSpec and parsed configs.""" + import yaml + + from cleveragents.domain.models.core.resource_type import ResourceTypeSpec + + self.spec_cls = ResourceTypeSpec + with open(_REMOTE_YAML) as f: + self.remote_config: dict[str, Any] = yaml.safe_load(f) + with open(_SUBMODULE_YAML) as f: + self.submodule_config: dict[str, Any] = yaml.safe_load(f) + with open(_SYMLINK_YAML) as f: + self.symlink_config: dict[str, Any] = yaml.safe_load(f) + + def time_remote_from_config(self) -> None: + """Benchmark creating remote ResourceTypeSpec from config.""" + self.spec_cls.from_config(self.remote_config) + + def time_submodule_from_config(self) -> None: + """Benchmark creating submodule ResourceTypeSpec from config.""" + self.spec_cls.from_config(self.submodule_config) + + def time_symlink_from_config(self) -> None: + """Benchmark creating symlink ResourceTypeSpec from config.""" + self.spec_cls.from_config(self.symlink_config) + + def time_all_deferred_virtual_from_config(self) -> None: + """Benchmark creating all three deferred virtual specs.""" + self.spec_cls.from_config(self.remote_config) + self.spec_cls.from_config(self.submodule_config) + self.spec_cls.from_config(self.symlink_config) + + +class TimeDeferredVirtualCliDict: + """Benchmark as_cli_dict rendering for deferred virtual types.""" + + def setup(self) -> None: + """Set up pre-built ResourceTypeSpec instances.""" + import yaml + + from cleveragents.domain.models.core.resource_type import ResourceTypeSpec + + with open(_REMOTE_YAML) as f: + remote_cfg: dict[str, Any] = yaml.safe_load(f) + with open(_SUBMODULE_YAML) as f: + submodule_cfg: dict[str, Any] = yaml.safe_load(f) + with open(_SYMLINK_YAML) as f: + symlink_cfg: dict[str, Any] = yaml.safe_load(f) + + self.remote_spec = ResourceTypeSpec.from_config(remote_cfg) + self.submodule_spec = ResourceTypeSpec.from_config(submodule_cfg) + self.symlink_spec = ResourceTypeSpec.from_config(symlink_cfg) + + def time_remote_cli_dict(self) -> None: + """Benchmark remote as_cli_dict rendering.""" + self.remote_spec.as_cli_dict() + + def time_submodule_cli_dict(self) -> None: + """Benchmark submodule as_cli_dict rendering.""" + self.submodule_spec.as_cli_dict() + + def time_symlink_cli_dict(self) -> None: + """Benchmark symlink as_cli_dict rendering.""" + self.symlink_spec.as_cli_dict() diff --git a/docs/reference/resource_types_builtin.md b/docs/reference/resource_types_builtin.md index 2c8d5b620..40d897b80 100644 --- a/docs/reference/resource_types_builtin.md +++ b/docs/reference/resource_types_builtin.md @@ -296,3 +296,128 @@ across different commits or repos that have the same tree hash. | Criterion | Description | |---|---| | `tree_hash` | Git tree object SHA | +## Deferred Virtual Resource Types + +Virtual resource types represent abstract identities that link equivalent +physical resources across repositories and layers. Virtual types are never +user-addable, have no sandbox strategy, and are created automatically by +the system when equivalent physical resources are detected. + +Based on `docs/specification.md` ~line 23705. + +--- + +### remote + +Cross-repo remote identity. Links `git-remote` resources across different +repos that point to the same URL. + +| Field | Value | +|---|---| +| **resource_kind** | virtual | +| **sandbox_strategy** | none | +| **user_addable** | false | +| **handler** | none | + +#### CLI Arguments + +None (system-managed only). + +#### Parent / Child Types + +- **Parent types**: none +- **Child types**: `git-remote` + +#### Equivalence Metadata + +| Field | Value | +|---|---| +| **criteria** | `url` | +| **description** | Two git-remote resources share a remote parent when they point to the same normalized URL. | + +#### Capabilities + +| Capability | Enabled | +|---|---| +| read | no | +| write | no | +| sandbox | no | +| checkpoint | no | + +--- + +### submodule + +Cross-repo submodule identity. Links `git-submodule` resources across +different repos with the same submodule URL and path. + +| Field | Value | +|---|---| +| **resource_kind** | virtual | +| **sandbox_strategy** | none | +| **user_addable** | false | +| **handler** | none | + +#### CLI Arguments + +None (system-managed only). + +#### Parent / Child Types + +- **Parent types**: none +- **Child types**: `git-submodule` + +#### Equivalence Metadata + +| Field | Value | +|---|---| +| **criteria** | `url`, `path` | +| **description** | Two git-submodule resources share a submodule parent when they have the same submodule URL and path. | + +#### Capabilities + +| Capability | Enabled | +|---|---| +| read | no | +| write | no | +| sandbox | no | +| checkpoint | no | + +--- + +### symlink + +Cross-layer symlink identity. Links `fs-symlink` and `git-tree-entry` +(mode 120000) resources with the same name and target. + +| Field | Value | +|---|---| +| **resource_kind** | virtual | +| **sandbox_strategy** | none | +| **user_addable** | false | +| **handler** | none | + +#### CLI Arguments + +None (system-managed only). + +#### Parent / Child Types + +- **Parent types**: none +- **Child types**: `fs-symlink`, `git-tree-entry` + +#### Equivalence Metadata + +| Field | Value | +|---|---| +| **criteria** | `name`, `target_path` | +| **description** | Two physical symlink resources share a symlink parent when they have the same name and target path. | + +#### Capabilities + +| Capability | Enabled | +|---|---| +| read | no | +| write | no | +| sandbox | no | +| checkpoint | no | diff --git a/examples/resource-types/remote.yaml b/examples/resource-types/remote.yaml new file mode 100644 index 000000000..401cd7c56 --- /dev/null +++ b/examples/resource-types/remote.yaml @@ -0,0 +1,20 @@ +# Spec reference: docs/specification.md ~lines 23705-23719, 24619-24631 +schema_version: "1.0" +name: remote +description: "Cross-repo remote identity. Links git-remote resources across different repos that point to the same URL." +resource_kind: virtual +sandbox_strategy: none +user_addable: false +built_in: true +cli_args: [] +parent_types: [] +child_types: ["git-remote"] +capabilities: + read: false + write: false + sandbox: false + checkpoint: false +equivalence: + criteria: ["url"] + description: "Two git-remote resources share a remote parent when they point to the same normalized URL." +handler: null diff --git a/examples/resource-types/submodule.yaml b/examples/resource-types/submodule.yaml new file mode 100644 index 000000000..eed059c07 --- /dev/null +++ b/examples/resource-types/submodule.yaml @@ -0,0 +1,20 @@ +# Spec reference: docs/specification.md ~lines 23705-23719, 24619-24631 +schema_version: "1.0" +name: submodule +description: "Cross-repo submodule identity. Links git-submodule resources across different repos with the same submodule URL and path." +resource_kind: virtual +sandbox_strategy: none +user_addable: false +built_in: true +cli_args: [] +parent_types: [] +child_types: ["git-submodule"] +capabilities: + read: false + write: false + sandbox: false + checkpoint: false +equivalence: + criteria: ["url", "path"] + description: "Two git-submodule resources share a submodule parent when they have the same submodule URL and path." +handler: null diff --git a/examples/resource-types/symlink.yaml b/examples/resource-types/symlink.yaml new file mode 100644 index 000000000..eb7ad9c13 --- /dev/null +++ b/examples/resource-types/symlink.yaml @@ -0,0 +1,20 @@ +# Spec reference: docs/specification.md ~lines 23705-23719, 24619-24631 +schema_version: "1.0" +name: symlink +description: "Cross-layer symlink identity. Links fs-symlink and git-tree-entry (mode 120000) resources with the same name and target." +resource_kind: virtual +sandbox_strategy: none +user_addable: false +built_in: true +cli_args: [] +parent_types: [] +child_types: ["fs-symlink", "git-tree-entry"] +capabilities: + read: false + write: false + sandbox: false + checkpoint: false +equivalence: + criteria: ["name", "target_path"] + description: "Two physical symlink resources share a symlink parent when they have the same name and target path." +handler: null diff --git a/features/resource_type_deferred_virtual.feature b/features/resource_type_deferred_virtual.feature new file mode 100644 index 000000000..515664427 --- /dev/null +++ b/features/resource_type_deferred_virtual.feature @@ -0,0 +1,344 @@ +Feature: Deferred Virtual Resource Types + As a CleverAgents developer + I want built-in virtual resource types (remote, submodule, symlink) + So that equivalence tracking can link physical resources across repos and layers + + # ── Existence and loading ───────────────────────────────── + + Scenario: remote YAML validates for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema should be valid + And the virt_deferred schema name should be "remote" + + Scenario: submodule YAML validates for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema should be valid + And the virt_deferred schema name should be "submodule" + + Scenario: symlink YAML validates for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema should be valid + And the virt_deferred schema name should be "symlink" + + # ── Resource kind ───────────────────────────────────────── + + Scenario: remote is a virtual resource type for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema resource_kind should be "virtual" + + Scenario: submodule is a virtual resource type for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema resource_kind should be "virtual" + + Scenario: symlink is a virtual resource type for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema resource_kind should be "virtual" + + # ── Not user-addable ────────────────────────────────────── + + Scenario: remote is not user-addable for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema user_addable should be false + + Scenario: submodule is not user-addable for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema user_addable should be false + + Scenario: symlink is not user-addable for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema user_addable should be false + + # ── Built-in and unnamespaced ───────────────────────────── + + Scenario: remote is built-in and unnamespaced for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema built_in should be true + And the virt_deferred schema name should not contain "/" + + Scenario: submodule is built-in and unnamespaced for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema built_in should be true + And the virt_deferred schema name should not contain "/" + + Scenario: symlink is built-in and unnamespaced for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema built_in should be true + And the virt_deferred schema name should not contain "/" + + # ── Sandbox strategy ────────────────────────────────────── + + Scenario: remote has no sandbox strategy for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema sandbox_strategy should be "none" + + Scenario: submodule has no sandbox strategy for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema sandbox_strategy should be "none" + + Scenario: symlink has no sandbox strategy for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema sandbox_strategy should be "none" + + # ── Capabilities (all false for virtual) ────────────────── + + Scenario: remote has all capabilities false for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred capabilities should have read false + And the virt_deferred capabilities should have write false + And the virt_deferred capabilities should have sandbox false + And the virt_deferred capabilities should have checkpoint false + + Scenario: submodule has all capabilities false for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred capabilities should have read false + And the virt_deferred capabilities should have write false + And the virt_deferred capabilities should have sandbox false + And the virt_deferred capabilities should have checkpoint false + + Scenario: symlink has all capabilities false for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred capabilities should have read false + And the virt_deferred capabilities should have write false + And the virt_deferred capabilities should have sandbox false + And the virt_deferred capabilities should have checkpoint false + + # ── Equivalence metadata ────────────────────────────────── + + Scenario: remote has equivalence metadata for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema equivalence should be present + And the virt_deferred equivalence criteria should contain "url" + + Scenario: submodule has equivalence metadata for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema equivalence should be present + And the virt_deferred equivalence criteria should contain "url" + And the virt_deferred equivalence criteria should contain "path" + + Scenario: symlink has equivalence metadata for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema equivalence should be present + And the virt_deferred equivalence criteria should contain "name" + And the virt_deferred equivalence criteria should contain "target_path" + + # ── Domain model loading ────────────────────────────────── + + Scenario: remote YAML loads as domain model for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML as a ResourceTypeSpec for virt_deferred + Then the virt_deferred domain model should be valid + And the virt_deferred domain model name should be "remote" + And the virt_deferred domain model resource_kind should be "virtual" + + Scenario: submodule YAML loads as domain model for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML as a ResourceTypeSpec for virt_deferred + Then the virt_deferred domain model should be valid + And the virt_deferred domain model name should be "submodule" + And the virt_deferred domain model resource_kind should be "virtual" + + Scenario: symlink YAML loads as domain model for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML as a ResourceTypeSpec for virt_deferred + Then the virt_deferred domain model should be valid + And the virt_deferred domain model name should be "symlink" + And the virt_deferred domain model resource_kind should be "virtual" + + # ── Child types ─────────────────────────────────────────── + + Scenario: remote allows git-remote children for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema child_types should contain "git-remote" + + Scenario: submodule allows git-submodule children for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema child_types should contain "git-submodule" + + Scenario: symlink allows fs-symlink children for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema child_types should contain "fs-symlink" + + Scenario: symlink allows git-tree-entry children for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema child_types should contain "git-tree-entry" + + # ── No parent types ─────────────────────────────────────── + + Scenario: remote has no parent types for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema should have empty parent_types + + Scenario: submodule has no parent types for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema should have empty parent_types + + Scenario: symlink has no parent types for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema should have empty parent_types + + # ── No CLI args ─────────────────────────────────────────── + + Scenario: remote has no cli_args for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema should have 0 cli_args + + Scenario: submodule has no cli_args for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema should have 0 cli_args + + Scenario: symlink has no cli_args for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema should have 0 cli_args + + # ── No handler ──────────────────────────────────────────── + + Scenario: remote has no handler for virt_deferred + Given the built-in remote YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema handler should be none + + Scenario: submodule has no handler for virt_deferred + Given the built-in submodule YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema handler should be none + + Scenario: symlink has no handler for virt_deferred + Given the built-in symlink YAML file for virt_deferred + When I load the YAML via schema for virt_deferred + Then the virt_deferred schema handler should be none + + # ── Registry bootstrap includes virtual types ───────────── + + Scenario: builtin types list includes deferred virtual types for virt_deferred + Given the registry builtin types list for virt_deferred + Then the registry should contain type "remote" for virt_deferred + And the registry should contain type "submodule" for virt_deferred + And the registry should contain type "symlink" for virt_deferred + + Scenario: deferred virtual types in registry are not user-addable for virt_deferred + Given the registry builtin types list for virt_deferred + Then the registry type "remote" should not be user_addable for virt_deferred + And the registry type "submodule" should not be user_addable for virt_deferred + And the registry type "symlink" should not be user_addable for virt_deferred + + Scenario: deferred virtual types in registry are virtual for virt_deferred + Given the registry builtin types list for virt_deferred + Then the registry type "remote" should be virtual for virt_deferred + And the registry type "submodule" should be virtual for virt_deferred + And the registry type "symlink" should be virtual for virt_deferred + + Scenario: deferred virtual types in registry have equivalence for virt_deferred + Given the registry builtin types list for virt_deferred + Then the registry type "remote" should have equivalence for virt_deferred + And the registry type "submodule" should have equivalence for virt_deferred + And the registry type "symlink" should have equivalence for virt_deferred + + # ── BUILTIN_NAMES includes virtual types ────────────────── + + Scenario: BUILTIN_NAMES includes deferred virtual type names for virt_deferred + Given the ResourceTypeSpec BUILTIN_NAMES set for virt_deferred + Then BUILTIN_NAMES should contain "remote" for virt_deferred + And BUILTIN_NAMES should contain "submodule" for virt_deferred + And BUILTIN_NAMES should contain "symlink" for virt_deferred + + # ── DB roundtrip via bootstrap ───────────────────────────── + + Scenario: virtual types survive DB roundtrip after bootstrap for virt_deferred + Given a virt_deferred fresh in-memory resource registry with bootstrap + When I query the virt_deferred registry for type "remote" + Then the virt_deferred db type "remote" should exist + And the virt_deferred db type "remote" should have kind "virtual" + And the virt_deferred db type "remote" should not be user_addable + And the virt_deferred db type "remote" should have equivalence with criteria "url" + When I query the virt_deferred registry for type "submodule" + Then the virt_deferred db type "submodule" should exist + And the virt_deferred db type "submodule" should have kind "virtual" + And the virt_deferred db type "submodule" should not be user_addable + And the virt_deferred db type "submodule" should have equivalence with criteria "url,path" + When I query the virt_deferred registry for type "symlink" + Then the virt_deferred db type "symlink" should exist + And the virt_deferred db type "symlink" should have kind "virtual" + And the virt_deferred db type "symlink" should not be user_addable + And the virt_deferred db type "symlink" should have equivalence with criteria "name,target_path" + + Scenario: virtual types appear in list_types after bootstrap for virt_deferred + Given a virt_deferred fresh in-memory resource registry with bootstrap + When I list all virt_deferred registry types + Then the virt_deferred type list should include "remote" + And the virt_deferred type list should include "submodule" + And the virt_deferred type list should include "symlink" + + # ── Negative tests ───────────────────────────────────────── + + Scenario: virtual type without equivalence fails validation for virt_deferred + When I create a virt_deferred virtual type config without equivalence + Then the virt_deferred config creation should raise a validation error + + Scenario: from_config rejects missing name for virt_deferred + When I create a virt_deferred config without name + Then the virt_deferred config creation should raise a ValueError + + Scenario: from_config rejects missing resource_kind for virt_deferred + When I create a virt_deferred config without resource_kind + Then the virt_deferred config creation should raise a ValueError + + # ── Manual add rejection (F-001 / F-003) ─────────────────── + + Scenario: manual register_resource rejects remote as not user-addable + Given a virt_deferred fresh in-memory resource registry with bootstrap + When I attempt to manually register a resource of type "remote" for virt_deferred + Then the virt_deferred manual registration should be rejected + And the virt_deferred rejection message should mention "not user-addable" + + Scenario: manual register_resource rejects submodule as not user-addable + Given a virt_deferred fresh in-memory resource registry with bootstrap + When I attempt to manually register a resource of type "submodule" for virt_deferred + Then the virt_deferred manual registration should be rejected + And the virt_deferred rejection message should mention "not user-addable" + + Scenario: manual register_resource rejects symlink as not user-addable + Given a virt_deferred fresh in-memory resource registry with bootstrap + When I attempt to manually register a resource of type "symlink" for virt_deferred + Then the virt_deferred manual registration should be rejected + And the virt_deferred rejection message should mention "not user-addable" + + # ── Equivalence criteria validation (F-004) ──────────────── + + Scenario: equivalence with non-string criteria element fails validation + When I create a virt_deferred virtual type config with criteria containing 123 + Then the virt_deferred config creation should raise a validation error + + Scenario: equivalence with empty-string criteria element fails validation + When I create a virt_deferred virtual type config with criteria containing "" + Then the virt_deferred config creation should raise a validation error diff --git a/features/steps/resource_registry_service_coverage_steps.py b/features/steps/resource_registry_service_coverage_steps.py index c51d928de..0b53e57cf 100644 --- a/features/steps/resource_registry_service_coverage_steps.py +++ b/features/steps/resource_registry_service_coverage_steps.py @@ -256,13 +256,20 @@ def step_spec_with_equivalence(context: Any) -> None: "criteria": ["content_hash", "location"], "description": "Hash-based equivalence comparison", } - # Virtual types require equivalence, so this is consistent + # Virtual types require equivalence and all-false capabilities context.cov_spec_eq = ResourceTypeSpec( name="testns/virt-type", description="Virtual type with equivalence", resource_kind=ResourceKind.VIRTUAL, sandbox_strategy=SandboxStrategy.NONE, user_addable=False, + handler=None, + capabilities={ + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, equivalence=context.equivalence_data, built_in=False, ) @@ -364,7 +371,7 @@ def step_db_row_with_equivalence(context: Any) -> None: allowed_child_types_json=None, auto_discover_json=None, capabilities_json=json.dumps( - {"read": True, "write": True, "sandbox": True, "checkpoint": False} + {"read": False, "write": False, "sandbox": False, "checkpoint": False} ), equivalence_json=json.dumps(context.eq_original), source="test", diff --git a/features/steps/resource_type_deferred_virtual_steps.py b/features/steps/resource_type_deferred_virtual_steps.py new file mode 100644 index 000000000..5cd7341ba --- /dev/null +++ b/features/steps/resource_type_deferred_virtual_steps.py @@ -0,0 +1,543 @@ +"""Step definitions for resource_type_deferred_virtual.feature. + +Tests for deferred virtual resource types: remote, submodule, symlink (#331). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml +from behave import given, then, when + +from cleveragents.domain.models.core.resource_type import ResourceTypeSpec +from cleveragents.resource.schema import ResourceTypeConfigSchema + +_EXAMPLES_DIR = ( + Path(__file__).resolve().parent.parent.parent / "examples" / "resource-types" +) + + +# ── Given steps ────────────────────────────────────────────── + + +@given("the built-in remote YAML file for virt_deferred") +def step_builtin_remote_yaml_virt_deferred(context: object) -> None: + """Load the remote YAML file path.""" + context.virt_deferred_yaml_path = _EXAMPLES_DIR / "remote.yaml" # type: ignore[attr-defined] + + +@given("the built-in submodule YAML file for virt_deferred") +def step_builtin_submodule_yaml_virt_deferred(context: object) -> None: + """Load the submodule YAML file path.""" + context.virt_deferred_yaml_path = _EXAMPLES_DIR / "submodule.yaml" # type: ignore[attr-defined] + + +@given("the built-in symlink YAML file for virt_deferred") +def step_builtin_symlink_yaml_virt_deferred(context: object) -> None: + """Load the symlink YAML file path.""" + context.virt_deferred_yaml_path = _EXAMPLES_DIR / "symlink.yaml" # type: ignore[attr-defined] + + +@given("the registry builtin types list for virt_deferred") +def step_registry_builtin_types_virt_deferred(context: object) -> None: + """Load the BUILTIN_TYPES list from the registry data module.""" + from cleveragents.application.services._resource_registry_data import ( + BUILTIN_TYPES, + ) + + context.virt_deferred_builtin_types = { # type: ignore[attr-defined] + t["name"]: t for t in BUILTIN_TYPES + } + + +@given("the ResourceTypeSpec BUILTIN_NAMES set for virt_deferred") +def step_builtin_names_set_virt_deferred(context: object) -> None: + """Load the BUILTIN_NAMES frozenset.""" + context.virt_deferred_builtin_names = ResourceTypeSpec.BUILTIN_NAMES # type: ignore[attr-defined] + + +# ── When steps ─────────────────────────────────────────────── + + +@when("I load the YAML via schema for virt_deferred") +def step_load_yaml_schema_virt_deferred(context: object) -> None: + """Load the YAML file via the schema loader.""" + path: Path = context.virt_deferred_yaml_path # type: ignore[attr-defined] + context.virt_deferred_schema = ResourceTypeConfigSchema.from_yaml_file(path) # type: ignore[attr-defined] + + +@when("I load the YAML as a ResourceTypeSpec for virt_deferred") +def step_load_yaml_domain_virt_deferred(context: object) -> None: + """Load the YAML file as a domain model.""" + path: Path = context.virt_deferred_yaml_path # type: ignore[attr-defined] + raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) + context.virt_deferred_domain = ResourceTypeSpec.from_config(raw) # type: ignore[attr-defined] + + +# ── Then steps: schema validation ──────────────────────────── + + +@then("the virt_deferred schema should be valid") +def step_schema_valid_virt_deferred(context: object) -> None: + """Assert the schema loaded successfully with required fields populated.""" + schema = context.virt_deferred_schema # type: ignore[attr-defined] + assert schema is not None, "Schema should not be None" + assert hasattr(schema, "name") and schema.name, "Schema must have a non-empty name" + assert hasattr(schema, "resource_kind") and schema.resource_kind, ( + "Schema must have a resource_kind" + ) + assert hasattr(schema, "sandbox_strategy"), "Schema must have sandbox_strategy" + + +@then('the virt_deferred schema name should be "{name}"') +def step_schema_name_virt_deferred(context: object, name: str) -> None: + """Assert the schema name matches.""" + actual = context.virt_deferred_schema.name # type: ignore[attr-defined] + assert actual == name, f"Expected schema name '{name}', got '{actual}'" + + +@then('the virt_deferred schema resource_kind should be "{kind}"') +def step_schema_kind_virt_deferred(context: object, kind: str) -> None: + """Assert the resource kind matches.""" + actual = context.virt_deferred_schema.resource_kind # type: ignore[attr-defined] + assert actual == kind, f"Expected resource_kind '{kind}', got '{actual}'" + + +@then("the virt_deferred schema user_addable should be false") +def step_schema_not_user_addable_virt_deferred(context: object) -> None: + """Assert the type is not user-addable.""" + actual = context.virt_deferred_schema.user_addable # type: ignore[attr-defined] + assert actual is False, f"Expected user_addable False, got {actual}" + + +@then("the virt_deferred schema built_in should be true") +def step_schema_builtin_virt_deferred(context: object) -> None: + """Assert the type is built-in.""" + actual = context.virt_deferred_schema.built_in # type: ignore[attr-defined] + assert actual is True, f"Expected built_in True, got {actual}" + + +@then('the virt_deferred schema name should not contain "/"') +def step_schema_no_slash_virt_deferred(context: object) -> None: + """Assert the name is unnamespaced.""" + name = context.virt_deferred_schema.name # type: ignore[attr-defined] + assert "/" not in name, f"Built-in name '{name}' should not contain '/'" + + +@then('the virt_deferred schema sandbox_strategy should be "{strategy}"') +def step_schema_sandbox_virt_deferred(context: object, strategy: str) -> None: + """Assert the sandbox strategy matches.""" + actual = context.virt_deferred_schema.sandbox_strategy # type: ignore[attr-defined] + assert actual == strategy, f"Expected sandbox_strategy '{strategy}', got '{actual}'" + + +# ── Then steps: capabilities ───────────────────────────────── + + +@then("the virt_deferred capabilities should have read {value}") +def step_caps_read_virt_deferred(context: object, value: str) -> None: + """Assert the read capability.""" + expected = value.lower() == "true" + actual = context.virt_deferred_schema.capabilities["read"] # type: ignore[attr-defined] + assert actual == expected, f"Expected read={expected}, got {actual}" + + +@then("the virt_deferred capabilities should have write {value}") +def step_caps_write_virt_deferred(context: object, value: str) -> None: + """Assert the write capability.""" + expected = value.lower() == "true" + actual = context.virt_deferred_schema.capabilities["write"] # type: ignore[attr-defined] + assert actual == expected, f"Expected write={expected}, got {actual}" + + +@then("the virt_deferred capabilities should have sandbox {value}") +def step_caps_sandbox_virt_deferred(context: object, value: str) -> None: + """Assert the sandbox capability.""" + expected = value.lower() == "true" + actual = context.virt_deferred_schema.capabilities["sandbox"] # type: ignore[attr-defined] + assert actual == expected, f"Expected sandbox={expected}, got {actual}" + + +@then("the virt_deferred capabilities should have checkpoint {value}") +def step_caps_checkpoint_virt_deferred(context: object, value: str) -> None: + """Assert the checkpoint capability.""" + expected = value.lower() == "true" + actual = context.virt_deferred_schema.capabilities["checkpoint"] # type: ignore[attr-defined] + assert actual == expected, f"Expected checkpoint={expected}, got {actual}" + + +# ── Then steps: equivalence metadata ───────────────────────── + + +@then("the virt_deferred schema equivalence should be present") +def step_equivalence_present_virt_deferred(context: object) -> None: + """Assert equivalence metadata is present.""" + equiv = context.virt_deferred_schema.equivalence # type: ignore[attr-defined] + assert equiv is not None, "Equivalence metadata should not be None" + assert "criteria" in equiv, ( + f"Equivalence must contain 'criteria', got keys: {list(equiv)}" + ) + + +@then('the virt_deferred equivalence criteria should contain "{field}"') +def step_equivalence_criteria_virt_deferred(context: object, field: str) -> None: + """Assert the equivalence criteria contains the given field.""" + equiv = context.virt_deferred_schema.equivalence # type: ignore[attr-defined] + assert field in equiv["criteria"], ( + f"Expected '{field}' in criteria {equiv['criteria']}" + ) + + +# ── Then steps: domain model ──────────────────────────────── + + +@then("the virt_deferred domain model should be valid") +def step_domain_valid_virt_deferred(context: object) -> None: + """Assert the domain model loaded successfully with required fields.""" + domain = context.virt_deferred_domain # type: ignore[attr-defined] + assert domain is not None, "Domain model should not be None" + assert domain.name, "Domain model must have a non-empty name" + assert domain.resource_kind is not None, "Domain model must have a resource_kind" + assert domain.equivalence is not None, ( + f"Virtual domain model '{domain.name}' must have equivalence" + ) + + +@then('the virt_deferred domain model name should be "{name}"') +def step_domain_name_virt_deferred(context: object, name: str) -> None: + """Assert the domain model name matches.""" + actual = context.virt_deferred_domain.name # type: ignore[attr-defined] + assert actual == name, f"Expected domain name '{name}', got '{actual}'" + + +@then('the virt_deferred domain model resource_kind should be "{kind}"') +def step_domain_kind_virt_deferred(context: object, kind: str) -> None: + """Assert the domain model resource kind matches.""" + actual = context.virt_deferred_domain.resource_kind # type: ignore[attr-defined] + assert str(actual) == kind or actual.value == kind, ( + f"Expected resource_kind '{kind}', got '{actual}'" + ) + + +# ── Then steps: child/parent types ────────────────────────── + + +@then('the virt_deferred schema child_types should contain "{child}"') +def step_schema_child_virt_deferred(context: object, child: str) -> None: + """Assert the schema child_types contains the given type.""" + types = context.virt_deferred_schema.child_types # type: ignore[attr-defined] + assert child in types, f"Expected '{child}' in child_types {types}" + + +@then("the virt_deferred schema should have empty parent_types") +def step_schema_no_parents_virt_deferred(context: object) -> None: + """Assert the schema has no parent types.""" + parents = context.virt_deferred_schema.parent_types # type: ignore[attr-defined] + assert len(parents) == 0, f"Expected empty parent_types, got {parents}" + + +@then("the virt_deferred schema should have {count:d} cli_args") +def step_schema_cli_args_count_virt_deferred(context: object, count: int) -> None: + """Assert the schema has the expected number of CLI args.""" + actual = len(context.virt_deferred_schema.cli_args) # type: ignore[attr-defined] + assert actual == count, f"Expected {count} cli_args, got {actual}" + + +@then("the virt_deferred schema handler should be none") +def step_schema_handler_none_virt_deferred(context: object) -> None: + """Assert the schema has no handler.""" + handler = context.virt_deferred_schema.handler # type: ignore[attr-defined] + assert handler is None, f"Expected handler None, got '{handler}'" + + +# ── Then steps: registry ───────────────────────────────────── + + +@then('the registry should contain type "{name}" for virt_deferred') +def step_registry_contains_virt_deferred(context: object, name: str) -> None: + """Assert the registry contains the named type.""" + types: dict[str, Any] = context.virt_deferred_builtin_types # type: ignore[attr-defined] + assert name in types, f"Type '{name}' not found in _BUILTIN_TYPES" + + +@then('the registry type "{name}" should not be user_addable for virt_deferred') +def step_registry_not_user_addable_virt_deferred(context: object, name: str) -> None: + """Assert the named type in the registry is not user-addable.""" + types: dict[str, Any] = context.virt_deferred_builtin_types # type: ignore[attr-defined] + assert types[name]["user_addable"] is False, ( + f"Type '{name}' should not be user_addable" + ) + + +@then('the registry type "{name}" should be virtual for virt_deferred') +def step_registry_virtual_virt_deferred(context: object, name: str) -> None: + """Assert the named type in the registry is virtual.""" + types: dict[str, Any] = context.virt_deferred_builtin_types # type: ignore[attr-defined] + assert types[name]["resource_kind"] == "virtual", f"Type '{name}' should be virtual" + + +@then('the registry type "{name}" should have equivalence for virt_deferred') +def step_registry_equivalence_virt_deferred(context: object, name: str) -> None: + """Assert the named type in the registry has equivalence metadata.""" + types: dict[str, Any] = context.virt_deferred_builtin_types # type: ignore[attr-defined] + assert types[name].get("equivalence") is not None, ( + f"Type '{name}' should have equivalence metadata" + ) + + +# ── Then steps: BUILTIN_NAMES ─────────────────────────────── + + +@then('BUILTIN_NAMES should contain "{name}" for virt_deferred') +def step_builtin_names_contains_virt_deferred(context: object, name: str) -> None: + """Assert BUILTIN_NAMES contains the given name.""" + names: frozenset[str] = context.virt_deferred_builtin_names # type: ignore[attr-defined] + assert name in names, f"'{name}' not in BUILTIN_NAMES: {sorted(names)}" + + +# ── Given/When/Then steps: DB roundtrip via bootstrap ──────── + + +@given("a virt_deferred fresh in-memory resource registry with bootstrap") +def step_fresh_registry_virt_deferred(context: object) -> None: + """Create an in-memory SQLite DB, bootstrap all built-in types.""" + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + from cleveragents.application.services.resource_registry_service import ( + ResourceRegistryService, + ) + from cleveragents.infrastructure.database.models import Base + + engine = create_engine("sqlite:///:memory:", echo=False) + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + context.virt_deferred_service = ResourceRegistryService( # type: ignore[attr-defined] + session_factory=factory, + ) + + +@when('I query the virt_deferred registry for type "{name}"') +def step_query_registry_virt_deferred(context: object, name: str) -> None: + """Query the bootstrapped registry for a specific type.""" + from cleveragents.core.exceptions import NotFoundError + + service = context.virt_deferred_service # type: ignore[attr-defined] + try: + context.virt_deferred_db_spec = service.show_type(name) # type: ignore[attr-defined] + context.virt_deferred_db_found = True # type: ignore[attr-defined] + except NotFoundError: + context.virt_deferred_db_spec = None # type: ignore[attr-defined] + context.virt_deferred_db_found = False # type: ignore[attr-defined] + + +@when("I list all virt_deferred registry types") +def step_list_types_virt_deferred(context: object) -> None: + """List all types from the bootstrapped registry.""" + service = context.virt_deferred_service # type: ignore[attr-defined] + context.virt_deferred_type_list = [ # type: ignore[attr-defined] + t.name for t in service.list_types() + ] + + +@then('the virt_deferred db type "{name}" should exist') +def step_db_type_exists_virt_deferred(context: object, name: str) -> None: + """Assert the type was found in the DB.""" + assert context.virt_deferred_db_found is True, ( # type: ignore[attr-defined] + f"Type '{name}' not found after bootstrap" + ) + + +@then('the virt_deferred db type "{name}" should have kind "{kind}"') +def step_db_type_kind_virt_deferred(context: object, name: str, kind: str) -> None: + """Assert the DB type has the expected resource kind.""" + spec = context.virt_deferred_db_spec # type: ignore[attr-defined] + actual = ( + str(spec.resource_kind.value) + if hasattr(spec.resource_kind, "value") + else str(spec.resource_kind) + ) + assert actual == kind, f"Expected '{name}' kind '{kind}', got '{actual}'" + + +@then('the virt_deferred db type "{name}" should not be user_addable') +def step_db_type_not_user_addable_virt_deferred(context: object, name: str) -> None: + """Assert the DB type is not user-addable.""" + spec = context.virt_deferred_db_spec # type: ignore[attr-defined] + assert spec.user_addable is False, ( + f"Type '{name}' should not be user_addable after roundtrip" + ) + + +@then( + 'the virt_deferred db type "{name}" should have equivalence with criteria "{criteria_csv}"' +) +def step_db_type_equivalence_virt_deferred( + context: object, + name: str, + criteria_csv: str, +) -> None: + """Assert the DB type has equivalence with expected criteria after roundtrip.""" + spec = context.virt_deferred_db_spec # type: ignore[attr-defined] + assert spec.equivalence is not None, ( + f"Type '{name}' should have equivalence after roundtrip" + ) + expected = [c.strip() for c in criteria_csv.split(",")] + actual = spec.equivalence.get("criteria", []) + assert actual == expected, ( + f"Type '{name}' equivalence criteria: expected {expected}, got {actual}" + ) + + +@then('the virt_deferred type list should include "{name}"') +def step_type_list_includes_virt_deferred(context: object, name: str) -> None: + """Assert the type name appears in the list_types result.""" + names: list[str] = context.virt_deferred_type_list # type: ignore[attr-defined] + assert name in names, f"'{name}' not in list_types result: {names}" + + +# ── When/Then steps: negative tests ───────────────────────── + + +@when("I create a virt_deferred virtual type config without equivalence") +def step_create_virtual_no_equiv_virt_deferred(context: object) -> None: + """Attempt to create a virtual ResourceTypeSpec without equivalence.""" + from pydantic import ValidationError + + context.virt_deferred_error = None # type: ignore[attr-defined] + try: + ResourceTypeSpec.from_config( + { + "name": "test-virtual-no-equiv", + "resource_kind": "virtual", + "sandbox_strategy": "none", + "built_in": True, + } + ) + except (ValueError, ValidationError) as exc: + context.virt_deferred_error = exc # type: ignore[attr-defined] + + +@when("I create a virt_deferred config without name") +def step_create_no_name_virt_deferred(context: object) -> None: + """Attempt to create a ResourceTypeSpec without name.""" + context.virt_deferred_error = None # type: ignore[attr-defined] + try: + ResourceTypeSpec.from_config( + { + "resource_kind": "virtual", + "sandbox_strategy": "none", + } + ) + except ValueError as exc: + context.virt_deferred_error = exc # type: ignore[attr-defined] + + +@when("I create a virt_deferred config without resource_kind") +def step_create_no_kind_virt_deferred(context: object) -> None: + """Attempt to create a ResourceTypeSpec without resource_kind.""" + context.virt_deferred_error = None # type: ignore[attr-defined] + try: + ResourceTypeSpec.from_config( + { + "name": "test-missing-kind", + "sandbox_strategy": "none", + } + ) + except ValueError as exc: + context.virt_deferred_error = exc # type: ignore[attr-defined] + + +@then("the virt_deferred config creation should raise a validation error") +def step_should_raise_validation_virt_deferred(context: object) -> None: + """Assert that a validation error was raised.""" + err = context.virt_deferred_error # type: ignore[attr-defined] + assert err is not None, "Expected a validation error but none was raised" + + +@then("the virt_deferred config creation should raise a ValueError") +def step_should_raise_value_error_virt_deferred(context: object) -> None: + """Assert that a ValueError was raised.""" + err = context.virt_deferred_error # type: ignore[attr-defined] + assert err is not None, "Expected a ValueError but none was raised" + assert isinstance(err, ValueError), ( + f"Expected ValueError, got {type(err).__name__}: {err}" + ) + + +# ── When/Then steps: manual add rejection (F-001 / F-003) ─── + + +@when( + 'I attempt to manually register a resource of type "{type_name}" for virt_deferred' +) +def step_attempt_manual_register_virt_deferred(context: object, type_name: str) -> None: + """Attempt to register a resource instance of a non-user-addable type.""" + service = context.virt_deferred_service # type: ignore[attr-defined] + context.virt_deferred_register_error = None # type: ignore[attr-defined] + try: + service.register_resource( + type_name=type_name, + name=f"test-manual-{type_name}", + ) + except Exception as exc: + context.virt_deferred_register_error = exc # type: ignore[attr-defined] + + +@then("the virt_deferred manual registration should be rejected") +def step_manual_registration_rejected_virt_deferred(context: object) -> None: + """Assert that manual registration was rejected.""" + err = context.virt_deferred_register_error # type: ignore[attr-defined] + assert err is not None, "Expected registration to be rejected but it succeeded" + + +@then('the virt_deferred rejection message should mention "{text}"') +def step_rejection_message_mentions_virt_deferred(context: object, text: str) -> None: + """Assert the rejection error message contains expected text.""" + err = context.virt_deferred_register_error # type: ignore[attr-defined] + assert text in str(err), f"Expected '{text}' in error message, got: {err}" + + +# ── When steps: criteria validation (F-004) ────────────────── + + +@when("I create a virt_deferred virtual type config with criteria containing 123") +def step_create_criteria_non_string_virt_deferred(context: object) -> None: + """Attempt to create a virtual type with non-string criteria element.""" + from pydantic import ValidationError + + context.virt_deferred_error = None # type: ignore[attr-defined] + try: + ResourceTypeSpec.from_config( + { + "name": "test-bad-criteria-int", + "resource_kind": "virtual", + "sandbox_strategy": "none", + "built_in": True, + "equivalence": {"criteria": [123], "description": "bad"}, + } + ) + except (ValueError, ValidationError) as exc: + context.virt_deferred_error = exc # type: ignore[attr-defined] + + +@when('I create a virt_deferred virtual type config with criteria containing ""') +def step_create_criteria_empty_string_virt_deferred(context: object) -> None: + """Attempt to create a virtual type with empty-string criteria element.""" + from pydantic import ValidationError + + context.virt_deferred_error = None # type: ignore[attr-defined] + try: + ResourceTypeSpec.from_config( + { + "name": "test-bad-criteria-empty", + "resource_kind": "virtual", + "sandbox_strategy": "none", + "built_in": True, + "equivalence": {"criteria": [""], "description": "bad"}, + } + ) + except (ValueError, ValidationError) as exc: + context.virt_deferred_error = exc # type: ignore[attr-defined] diff --git a/features/steps/resource_type_model_steps.py b/features/steps/resource_type_model_steps.py index 944ea217f..9c8e64efc 100644 --- a/features/steps/resource_type_model_steps.py +++ b/features/steps/resource_type_model_steps.py @@ -44,7 +44,21 @@ def step_config_with_kind(context, kind): @given('a resource type config with resource_kind "{kind}" and equivalence set') def step_config_with_kind_and_equiv(context, kind): - config = _base_config(resource_kind=kind) + if kind == "virtual": + config = _base_config( + resource_kind=kind, + sandbox_strategy="none", + ) + config["user_addable"] = False + config["handler"] = None + config["capabilities"] = { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + } + else: + config = _base_config(resource_kind=kind) config["equivalence"] = { "criteria": ["content_hash"], "description": "Test equivalence", diff --git a/robot/resource_type_deferred_virtual.robot b/robot/resource_type_deferred_virtual.robot new file mode 100644 index 000000000..db4035ebd --- /dev/null +++ b/robot/resource_type_deferred_virtual.robot @@ -0,0 +1,134 @@ +*** Settings *** +Documentation Deferred Virtual Resource Type Tests (#331) +Library Process +Library OperatingSystem + +*** Variables *** +${PYTHON} python +${REMOTE_YAML} ${CURDIR}/../examples/resource-types/remote.yaml +${SUBMODULE_YAML} ${CURDIR}/../examples/resource-types/submodule.yaml +${SYMLINK_YAML} ${CURDIR}/../examples/resource-types/symlink.yaml + +*** Test Cases *** +Load Remote Virtual Type YAML And Validate + [Documentation] Load remote.yaml via schema loader and assert key fields + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.resource.schema import ResourceTypeConfigSchema + ... schema = ResourceTypeConfigSchema.from_yaml_file("${REMOTE_YAML}") + ... assert schema.name == "remote", f"name: {schema.name}" + ... assert schema.resource_kind == "virtual", f"kind: {schema.resource_kind}" + ... assert schema.sandbox_strategy == "none", f"strategy: {schema.sandbox_strategy}" + ... assert schema.built_in is True, f"built_in: {schema.built_in}" + ... assert schema.user_addable is False, f"user_addable: {schema.user_addable}" + ... assert len(schema.cli_args) == 0, f"cli_args: {len(schema.cli_args)}" + ... assert schema.equivalence is not None, "equivalence missing" + ... assert "url" in schema.equivalence["criteria"] + ... assert "git-remote" in schema.child_types + ... print("remote deferred virtual YAML validated successfully") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 remote load failed: ${result.stderr} + Should Contain ${result.stdout} remote deferred virtual YAML validated successfully + +Load Submodule Virtual Type YAML And Validate + [Documentation] Load submodule.yaml via schema loader and assert key fields + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.resource.schema import ResourceTypeConfigSchema + ... schema = ResourceTypeConfigSchema.from_yaml_file("${SUBMODULE_YAML}") + ... assert schema.name == "submodule", f"name: {schema.name}" + ... assert schema.resource_kind == "virtual", f"kind: {schema.resource_kind}" + ... assert schema.sandbox_strategy == "none", f"strategy: {schema.sandbox_strategy}" + ... assert schema.built_in is True, f"built_in: {schema.built_in}" + ... assert schema.user_addable is False, f"user_addable: {schema.user_addable}" + ... assert len(schema.cli_args) == 0, f"cli_args: {len(schema.cli_args)}" + ... assert schema.equivalence is not None, "equivalence missing" + ... assert "url" in schema.equivalence["criteria"] + ... assert "path" in schema.equivalence["criteria"] + ... assert "git-submodule" in schema.child_types + ... print("submodule deferred virtual YAML validated successfully") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 submodule load failed: ${result.stderr} + Should Contain ${result.stdout} submodule deferred virtual YAML validated successfully + +Load Symlink Virtual Type YAML And Validate + [Documentation] Load symlink.yaml via schema loader and assert key fields + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.resource.schema import ResourceTypeConfigSchema + ... schema = ResourceTypeConfigSchema.from_yaml_file("${SYMLINK_YAML}") + ... assert schema.name == "symlink", f"name: {schema.name}" + ... assert schema.resource_kind == "virtual", f"kind: {schema.resource_kind}" + ... assert schema.sandbox_strategy == "none", f"strategy: {schema.sandbox_strategy}" + ... assert schema.built_in is True, f"built_in: {schema.built_in}" + ... assert schema.user_addable is False, f"user_addable: {schema.user_addable}" + ... assert len(schema.cli_args) == 0, f"cli_args: {len(schema.cli_args)}" + ... assert schema.equivalence is not None, "equivalence missing" + ... assert "name" in schema.equivalence["criteria"] + ... assert "target_path" in schema.equivalence["criteria"] + ... assert "fs-symlink" in schema.child_types + ... assert "git-tree-entry" in schema.child_types + ... print("symlink deferred virtual YAML validated successfully") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 symlink load failed: ${result.stderr} + Should Contain ${result.stdout} symlink deferred virtual YAML validated successfully + +Load All Deferred Virtual YAMLs As Domain Models + [Documentation] Load all deferred virtual YAMLs as ResourceTypeSpec domain models + ${script}= Catenate SEPARATOR=\n + ... import yaml + ... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec + ... def check(path, expected_name, expected_criteria): + ... ${SPACE}${SPACE}${SPACE}${SPACE}with open(path) as f: + ... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}config = yaml.safe_load(f) + ... ${SPACE}${SPACE}${SPACE}${SPACE}spec = ResourceTypeSpec.from_config(config) + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec.name == expected_name, f"name mismatch: {spec.name}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert str(spec.resource_kind) == "virtual", f"kind: {spec.resource_kind}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec.equivalence is not None, "equivalence missing" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec.equivalence["criteria"] == expected_criteria + ... ${SPACE}${SPACE}${SPACE}${SPACE}cli_dict = spec.as_cli_dict() + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert "equivalence" in cli_dict, "missing equivalence in cli_dict" + ... check("${REMOTE_YAML}", "remote", ["url"]) + ... check("${SUBMODULE_YAML}", "submodule", ["url", "path"]) + ... check("${SYMLINK_YAML}", "symlink", ["name", "target_path"]) + ... print("All deferred virtual domain models validated successfully") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 Domain model load failed: ${result.stderr} + Should Contain ${result.stdout} All deferred virtual domain models validated successfully + +Virtual Types Survive DB Bootstrap Roundtrip + [Documentation] Bootstrap in-memory DB and verify virtual types are retrievable + ${script}= Catenate SEPARATOR=\n + ... from sqlalchemy import create_engine + ... from sqlalchemy.orm import sessionmaker + ... from cleveragents.infrastructure.database.models import Base + ... from cleveragents.application.services.resource_registry_service import ResourceRegistryService + ... engine = create_engine("sqlite:///:memory:", echo=False) + ... Base.metadata.create_all(engine) + ... factory = sessionmaker(bind=engine, expire_on_commit=False) + ... svc = ResourceRegistryService(session_factory=factory) + ... for name, expected_criteria in [("remote", ["url"]), ("submodule", ["url", "path"]), ("symlink", ["name", "target_path"])]: + ... ${SPACE}${SPACE}${SPACE}${SPACE}spec = svc.show_type(name) + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec is not None, f"{name} not found after bootstrap" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert str(spec.resource_kind.value) == "virtual", f"{name} kind: {spec.resource_kind}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec.user_addable is False, f"{name} user_addable: {spec.user_addable}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec.equivalence is not None, f"{name} missing equivalence" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec.equivalence["criteria"] == expected_criteria, f"{name} criteria: {spec.equivalence}" + ... names = [t.name for t in svc.list_types()] + ... for vt in ["remote", "submodule", "symlink"]: + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert vt in names, f"{vt} not in list_types: {names}" + ... print("All virtual types survived DB bootstrap roundtrip") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 DB roundtrip failed: ${result.stderr} + Should Contain ${result.stdout} All virtual types survived DB bootstrap roundtrip + +Virtual Type Without Equivalence Fails Validation + [Documentation] Verify that creating a virtual type without equivalence raises an error + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec + ... try: + ... ${SPACE}${SPACE}${SPACE}${SPACE}ResourceTypeSpec.from_config({"name": "bad-virtual", "resource_kind": "virtual", "sandbox_strategy": "none", "built_in": True}) + ... ${SPACE}${SPACE}${SPACE}${SPACE}print("ERROR: no exception raised") + ... except (ValueError, Exception) as e: + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert "equivalence" in str(e).lower(), f"Unexpected error: {e}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}print("Correctly rejected virtual type without equivalence") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 Validation test failed: ${result.stderr} + Should Contain ${result.stdout} Correctly rejected virtual type without equivalence diff --git a/src/cleveragents/application/services/_resource_registry_data.py b/src/cleveragents/application/services/_resource_registry_data.py index 9dd20ea5b..9d2e715a7 100644 --- a/src/cleveragents/application/services/_resource_registry_data.py +++ b/src/cleveragents/application/services/_resource_registry_data.py @@ -20,6 +20,9 @@ from typing import Any from cleveragents.application.services._resource_registry_virtual import ( VIRTUAL_CORE_TYPES, ) +from cleveragents.application.services._resource_registry_virtual_deferred import ( + VIRTUAL_DEFERRED_TYPES, +) from cleveragents.domain.models.core.resource import PhysVirt, Resource from cleveragents.domain.models.core.resource_type import ( ResourceKind, @@ -782,6 +785,8 @@ BUILTIN_TYPES: list[dict[str, Any]] = [ *_AWS_TYPES, *_GCP_AZURE_TYPES, *DATABASE_TYPE_DEFS, + # Deferred virtual types -- _resource_registry_virtual_deferred (#331) + *VIRTUAL_DEFERRED_TYPES, ] diff --git a/src/cleveragents/application/services/_resource_registry_virtual_deferred.py b/src/cleveragents/application/services/_resource_registry_virtual_deferred.py new file mode 100644 index 000000000..67128ce83 --- /dev/null +++ b/src/cleveragents/application/services/_resource_registry_virtual_deferred.py @@ -0,0 +1,115 @@ +"""Deferred virtual resource type definitions for the Resource Registry. + +Contains the built-in deferred virtual type entries (remote, submodule, +symlink) that are appended to ``BUILTIN_TYPES`` in +``_resource_registry_data``. Extracted to keep each module under the +500-line CONTRIBUTING limit and for consistency with +``_resource_registry_virtual`` (#329). + +.. note:: + + This is an **internal** module (``_``-prefixed). External code + should import ``BUILTIN_TYPES`` from ``_resource_registry_data`` + instead. +""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["VIRTUAL_DEFERRED_TYPES"] + +# === Deferred Virtual Resource Types (#331) === +# +# These types represent abstract identities for concepts that have +# physical counterparts introduced by #662 (deferred physical types). +# Each defines equivalence criteria used by the equivalence tracking +# service (#334) to auto-link physical resources. + +VIRTUAL_DEFERRED_TYPES: list[dict[str, Any]] = [ + { + "name": "remote", + "description": ( + "Cross-repo remote identity. Links git-remote resources " + "across different repos that point to the same URL." + ), + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": ["git-remote"], + "handler": None, + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + "equivalence": { + "criteria": ["url"], + "description": ( + "Two git-remote resources share a remote parent " + "when they point to the same normalized URL." + ), + }, + }, + { + "name": "submodule", + "description": ( + "Cross-repo submodule identity. Links git-submodule resources " + "across different repos with the same submodule URL and path." + ), + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": ["git-submodule"], + "handler": None, + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + "equivalence": { + "criteria": ["url", "path"], + "description": ( + "Two git-submodule resources share a submodule parent " + "when they have the same submodule URL and path." + ), + }, + }, + { + "name": "symlink", + "description": ( + "Cross-layer symlink identity. Links fs-symlink and " + "git-tree-entry (mode 120000) resources with the same " + "name and target." + ), + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": ["fs-symlink", "git-tree-entry"], + "handler": None, + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + "equivalence": { + "criteria": ["name", "target_path"], + "description": ( + "Two physical symlink resources share a symlink parent " + "when they have the same name and target path." + ), + }, + }, +] diff --git a/src/cleveragents/application/services/resource_registry_service.py b/src/cleveragents/application/services/resource_registry_service.py index 21b688e2b..5a0939586 100644 --- a/src/cleveragents/application/services/resource_registry_service.py +++ b/src/cleveragents/application/services/resource_registry_service.py @@ -22,6 +22,9 @@ development workflows. Built-in types are **unnamespaced** (no | ``branch`` | virtual | ``none`` | Cross-repo branch identity | | ``tag`` | virtual | ``none`` | Cross-repo tag identity | | ``tree`` | virtual | ``none`` | Cross-repo tree identity | +| ``remote`` | virtual | ``none`` | Cross-repo remote identity | +| ``submodule`` | virtual | ``none`` | Cross-repo submodule identity | +| ``symlink`` | virtual | ``none`` | Cross-layer symlink identity | Custom types must follow the ``namespace/name`` pattern and are registered via YAML configuration files. @@ -29,7 +32,6 @@ via YAML configuration files. Based on: - ``docs/specification.md`` -- Resource Registry -- ``implementation_plan.md`` -- Task B0.services - ADR-003: Dependency Injection - ADR-007: Repository Pattern @@ -96,10 +98,10 @@ class ResourceRegistryService(ResourceInstanceMixin, ResourceDagMixin): """Initialize the resource registry service. Automatically seeds built-in resource types (``fs-directory``, - ``git-checkout``, etc.) on construction so they are available - immediately after ``agents init``. The bootstrap is idempotent - and gracefully skipped when the database tables do not yet exist - (e.g. before migrations have run). + ``git-checkout``, virtual deferred types, etc.) on construction + so they are available immediately after ``agents init``. The + bootstrap is idempotent and gracefully skipped when the database + tables do not yet exist (e.g. before migrations have run). Args: session_factory: Callable returning a SQLAlchemy Session. diff --git a/src/cleveragents/domain/models/core/resource_type.py b/src/cleveragents/domain/models/core/resource_type.py index 93499ae4c..3f8ccbbac 100644 --- a/src/cleveragents/domain/models/core/resource_type.py +++ b/src/cleveragents/domain/models/core/resource_type.py @@ -242,6 +242,10 @@ class ResourceTypeSpec(BaseModel): "file", "tag", "tree", + # Deferred virtual resource types (#331) + "remote", + "submodule", + "symlink", } ) @@ -380,7 +384,9 @@ class ResourceTypeSpec(BaseModel): "Only built-in types can be unnamespaced." ) - # Virtual types require equivalence rules with non-empty criteria + # Virtual types require equivalence rules with criteria and have + # strict constraints: no sandbox, not user-addable, no handler, + # all capabilities false. if self.resource_kind == ResourceKind.VIRTUAL: if self.equivalence is None: raise ValueError( @@ -389,14 +395,48 @@ class ResourceTypeSpec(BaseModel): ) if "criteria" not in self.equivalence: raise ValueError( - f"Virtual resource type '{self.name}' requires " - "'equivalence.criteria' key for identity matching." + f"Virtual resource type '{self.name}' equivalence must " + "contain a 'criteria' list per spec." ) criteria = self.equivalence["criteria"] if not isinstance(criteria, list) or len(criteria) == 0: raise ValueError( - f"Virtual resource type '{self.name}' requires non-empty " - "'equivalence.criteria' list for identity matching." + f"Virtual resource type '{self.name}' equivalence " + "'criteria' must be a non-empty list." + ) + for i, item in enumerate(criteria): + if not isinstance(item, str): + raise ValueError( + f"Virtual resource type '{self.name}' equivalence " + f"criteria[{i}] must be a string, got {type(item).__name__}." + ) + if not item.strip(): + raise ValueError( + f"Virtual resource type '{self.name}' equivalence " + f"criteria[{i}] must be a non-empty string." + ) + if self.user_addable: + raise ValueError( + f"Virtual resource type '{self.name}' must not be " + "user_addable (virtual types are system-managed)." + ) + if self.sandbox_strategy != SandboxStrategy.NONE: + raise ValueError( + f"Virtual resource type '{self.name}' must have " + "sandbox_strategy 'none'." + ) + if self.handler is not None: + raise ValueError( + f"Virtual resource type '{self.name}' must not have " + "a handler (virtual types have no physical backing)." + ) + caps = self.capabilities + if caps and any( + caps.get(k) for k in ("read", "write", "sandbox", "checkpoint") + ): + raise ValueError( + f"Virtual resource type '{self.name}' must have all " + "capabilities set to false." ) # ADR-042 rule 3: no cycles (self-inheritance) @@ -457,6 +497,13 @@ class ResourceTypeSpec(BaseModel): capabilities=config.get( "capabilities", { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + } + if config.get("resource_kind") == "virtual" + else { "read": True, "write": True, "sandbox": True, diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 1f2184ad6..2a2411be8 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -72,10 +72,10 @@ ResourceRegistryService # noqa: B018, F821 _build_resource_registry_service # noqa: B018, F821 _build_namespaced_project_repo # noqa: B018, F821 _build_project_resource_link_repo # noqa: B018, F821 -_BUILTIN_TYPES # noqa: B018, F821 +BUILTIN_TYPES # noqa: B018, F821 _spec_to_db # noqa: B018, F821 _db_to_spec # noqa: B018, F821 -_db_resource_to_domain # noqa: B018, F821 +db_resource_to_domain # noqa: B018, F821 resource_registry_service # noqa: B018, F821 namespaced_project_repo # noqa: B018, F821 project_resource_link_repo # noqa: B018, F821