diff --git a/CHANGELOG.md b/CHANGELOG.md index 38453ca23..92c5dff80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -369,6 +369,15 @@ configurable debouncing and optional `RESOURCE_MODIFIED` EventBus emission. Includes 166 Behave BDD scenarios, 9 Robot Framework integration tests, ASV benchmarks, and reference documentation. (#578) +- Added built-in virtual core resource types (`file`, `directory`, `commit`, + `branch`, `tag`, `tree`) under `examples/resource-types/`. All use + `resource_kind: virtual`, `sandbox_strategy: none`, `user_addable: false`. + Equivalence metadata defines identity criteria per type (content hash for + files, git object identity for commits/branches/tags/trees). Extended + bootstrap registration in `ResourceRegistryService` to include virtual + types and updated `ResourceTypeSpec.BUILTIN_NAMES`. Added reference + documentation in `docs/reference/resource_types_builtin.md`. Includes + Behave BDD tests, Robot integration tests, and ASV benchmarks. (#329) - Added general-purpose domain event system under `cleveragents.infrastructure.events`. `EventType` StrEnum defines 38 typed event identifiers across 9 domains (plan lifecycle, decision, invariant, actor, diff --git a/benchmarks/resource_type_virtual_core_bench.py b/benchmarks/resource_type_virtual_core_bench.py new file mode 100644 index 000000000..b8f83f3ae --- /dev/null +++ b/benchmarks/resource_type_virtual_core_bench.py @@ -0,0 +1,81 @@ +"""ASV benchmarks for virtual core resource type YAML loading.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from cleveragents.domain.models.core.resource_type import ResourceTypeSpec +from cleveragents.resource.schema import ResourceTypeConfigSchema + +_EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples" / "resource-types" + +_VIRTUAL_CORE_NAMES = ("file", "directory", "commit", "branch", "tag", "tree") +_VIRTUAL_YAML_PATHS = { + name: _EXAMPLES_DIR / f"{name}.yaml" for name in _VIRTUAL_CORE_NAMES +} + + +class TimeVirtualCoreSchemaLoad: + """Benchmark virtual core resource type YAML schema loading.""" + + timeout = 60 + + def setup(self) -> None: + self.schema_cls = ResourceTypeConfigSchema + self.paths = dict(_VIRTUAL_YAML_PATHS) + + def time_load_file_yaml(self) -> None: + self.schema_cls.from_yaml_file(self.paths["file"]) + + def time_load_directory_yaml(self) -> None: + self.schema_cls.from_yaml_file(self.paths["directory"]) + + def time_load_all_virtual_core(self) -> None: + for path in self.paths.values(): + self.schema_cls.from_yaml_file(path) + + +class TimeVirtualCoreDomainModelLoad: + """Benchmark virtual core resource type domain model creation.""" + + timeout = 60 + + def setup(self) -> None: + self.spec_cls = ResourceTypeSpec + self.configs: dict[str, dict[str, Any]] = {} + for name, path in _VIRTUAL_YAML_PATHS.items(): + with open(path, encoding="utf-8") as f: + self.configs[name] = yaml.safe_load(f) + + def time_file_from_config(self) -> None: + self.spec_cls.from_config(self.configs["file"]) + + def time_commit_from_config(self) -> None: + self.spec_cls.from_config(self.configs["commit"]) + + def time_all_virtual_core_from_config(self) -> None: + for config in self.configs.values(): + self.spec_cls.from_config(config) + + +class TimeVirtualCoreCliDict: + """Benchmark as_cli_dict rendering for virtual core types.""" + + timeout = 60 + + def setup(self) -> None: + self.specs: dict[str, ResourceTypeSpec] = {} + for name, path in _VIRTUAL_YAML_PATHS.items(): + with open(path, encoding="utf-8") as f: + config: dict[str, Any] = yaml.safe_load(f) + self.specs[name] = ResourceTypeSpec.from_config(config) + + def time_file_cli_dict(self) -> None: + self.specs["file"].as_cli_dict() + + def time_all_virtual_core_cli_dict(self) -> None: + for spec in self.specs.values(): + spec.as_cli_dict() diff --git a/docs/reference/resource_types_builtin.md b/docs/reference/resource_types_builtin.md index e9e1de964..2c8d5b620 100644 --- a/docs/reference/resource_types_builtin.md +++ b/docs/reference/resource_types_builtin.md @@ -4,6 +4,19 @@ CleverAgents ships with several built-in resource types that cover common filesystem and version-control workflows. Built-in types use simple unnamespaced names and are registered automatically at startup. +Built-in types are organized into two groups: + +- **Physical types** represent tangible assets (files, directories, repos) + with direct read/write access and sandbox strategies. +- **Virtual types** represent abstract identities that link equivalent + physical resources across layers and repositories. Virtual types have + no handler, no sandbox strategy, and are never user-addable. They are + created automatically by the system when equivalent physical resources + are detected. + +See `docs/specification.md` section "Physical vs Virtual Resources" +(~line 24548) for the full design rationale. + ## fs-mount A physical mount point on the local system. Use this type to represent @@ -156,3 +169,130 @@ for version-controlled projects. | write | yes | | sandbox | yes | | checkpoint | yes | + +--- + +## Virtual Core Resource Types + +Virtual types use simple unnamespaced names that mirror their physical +counterparts. A virtual resource answers the question: "where else does +this same thing exist?" Two physical resources share a virtual parent +when they are equivalent by the virtual type's criteria. + +All virtual types share these properties: + +| Field | Value | +|---|---| +| **resource_kind** | virtual | +| **sandbox_strategy** | none | +| **user_addable** | false | +| **handler** | none | + +All capabilities are disabled (`read: false`, `write: false`, +`sandbox: false`, `checkpoint: false`) because virtual types represent +abstract identities, not concrete locations. Tools always operate on +their physical children. + +--- + +### file + +Cross-layer file identity. Links physical `fs-file` and `git-tree-entry` +resources that represent the same file -- identical content bytes, +filename, and permissions. + +- **Child types**: `fs-file`, `git-tree-entry` +- **Parent types**: none (top-level virtual) + +#### Equivalence + +| Criterion | Description | +|---|---| +| `content_hash` | SHA-256 of file content bytes | +| `filename` | File name component | +| `permissions` | File permission mode | + +--- + +### directory + +Cross-layer directory identity. Links physical `fs-directory` and +`git-tree` resources whose recursive contents are equivalent. + +- **Child types**: `fs-directory`, `git-tree` +- **Parent types**: none (top-level virtual) + +#### Equivalence + +| Criterion | Description | +|---|---| +| `merkle_hash` | Merkle hash of recursive child content hashes | +| `name` | Directory name | +| `permissions` | Directory permission mode | + +--- + +### commit + +Cross-repo commit identity. Links `git-commit` resources across +different repos that share the same commit hash (e.g., fork and +upstream). + +- **Child types**: `git-commit` +- **Parent types**: none (top-level virtual) + +#### Equivalence + +| Criterion | Description | +|---|---| +| `commit_hash` | Git commit SHA | + +--- + +### branch + +Cross-repo branch identity. Links `git-branch` resources across +different repos with the same branch name and HEAD commit hash. + +- **Child types**: `git-branch` +- **Parent types**: none (top-level virtual) + +#### Equivalence + +| Criterion | Description | +|---|---| +| `name` | Branch name | +| `head_commit_hash` | HEAD commit SHA | + +--- + +### tag + +Cross-repo tag identity. Links `git-tag` resources across different +repos with the same tag name and target object. + +- **Child types**: `git-tag` +- **Parent types**: none (top-level virtual) + +#### Equivalence + +| Criterion | Description | +|---|---| +| `name` | Tag name | +| `target_sha` | Target object SHA | + +--- + +### tree + +Cross-repo/cross-commit tree identity. Links `git-tree` resources +across different commits or repos that have the same tree hash. + +- **Child types**: `git-tree` +- **Parent types**: none (top-level virtual) + +#### Equivalence + +| Criterion | Description | +|---|---| +| `tree_hash` | Git tree object SHA | diff --git a/examples/resource-types/branch.yaml b/examples/resource-types/branch.yaml new file mode 100644 index 000000000..f33bfaee9 --- /dev/null +++ b/examples/resource-types/branch.yaml @@ -0,0 +1,22 @@ +# Spec reference: docs/specification.md ~lines 10133-10143, 24548-24632 +schema_version: "1.0" +name: branch +description: "Cross-repo branch identity linking branches with the same name and HEAD" +resource_kind: virtual +sandbox_strategy: none +user_addable: false +built_in: true +cli_args: [] +parent_types: [] +child_types: ["git-branch"] +capabilities: + read: false + write: false + sandbox: false + checkpoint: false +equivalence: + criteria: + - name + - head_commit_hash + description: "Two physical branches are equivalent when they share the same branch name and HEAD commit hash" +handler: null diff --git a/examples/resource-types/commit.yaml b/examples/resource-types/commit.yaml new file mode 100644 index 000000000..8f98f2279 --- /dev/null +++ b/examples/resource-types/commit.yaml @@ -0,0 +1,21 @@ +# Spec reference: docs/specification.md ~lines 10133-10143, 24548-24632 +schema_version: "1.0" +name: commit +description: "Cross-repo commit identity linking commits with the same hash" +resource_kind: virtual +sandbox_strategy: none +user_addable: false +built_in: true +cli_args: [] +parent_types: [] +child_types: ["git-commit"] +capabilities: + read: false + write: false + sandbox: false + checkpoint: false +equivalence: + criteria: + - commit_hash + description: "Two physical commits are equivalent when they share the same commit hash" +handler: null diff --git a/examples/resource-types/directory.yaml b/examples/resource-types/directory.yaml new file mode 100644 index 000000000..af2b1a903 --- /dev/null +++ b/examples/resource-types/directory.yaml @@ -0,0 +1,23 @@ +# Spec reference: docs/specification.md ~lines 10133-10143, 24548-24632 +schema_version: "1.0" +name: directory +description: "Cross-layer directory identity linking equivalent physical directories" +resource_kind: virtual +sandbox_strategy: none +user_addable: false +built_in: true +cli_args: [] +parent_types: [] +child_types: ["fs-directory", "git-tree"] +capabilities: + read: false + write: false + sandbox: false + checkpoint: false +equivalence: + criteria: + - merkle_hash + - name + - permissions + description: "Two physical resources are equivalent when they share the same Merkle hash, name, and permissions" +handler: null diff --git a/examples/resource-types/file.yaml b/examples/resource-types/file.yaml new file mode 100644 index 000000000..c44269050 --- /dev/null +++ b/examples/resource-types/file.yaml @@ -0,0 +1,23 @@ +# Spec reference: docs/specification.md ~lines 10133-10143, 24548-24632 +schema_version: "1.0" +name: file +description: "Cross-layer file identity linking equivalent physical files" +resource_kind: virtual +sandbox_strategy: none +user_addable: false +built_in: true +cli_args: [] +parent_types: [] +child_types: ["fs-file", "git-tree-entry"] +capabilities: + read: false + write: false + sandbox: false + checkpoint: false +equivalence: + criteria: + - content_hash + - filename + - permissions + description: "Two physical resources are equivalent when they share the same content bytes (SHA-256), filename, and permissions" +handler: null diff --git a/examples/resource-types/tag.yaml b/examples/resource-types/tag.yaml new file mode 100644 index 000000000..0f6fb033a --- /dev/null +++ b/examples/resource-types/tag.yaml @@ -0,0 +1,22 @@ +# Spec reference: docs/specification.md ~lines 10133-10143, 24548-24632 +schema_version: "1.0" +name: tag +description: "Cross-repo tag identity linking tags with the same name and target" +resource_kind: virtual +sandbox_strategy: none +user_addable: false +built_in: true +cli_args: [] +parent_types: [] +child_types: ["git-tag"] +capabilities: + read: false + write: false + sandbox: false + checkpoint: false +equivalence: + criteria: + - name + - target_sha + description: "Two physical tags are equivalent when they share the same tag name and target object" +handler: null diff --git a/examples/resource-types/tree.yaml b/examples/resource-types/tree.yaml new file mode 100644 index 000000000..c3cc5f122 --- /dev/null +++ b/examples/resource-types/tree.yaml @@ -0,0 +1,21 @@ +# Spec reference: docs/specification.md ~lines 10133-10143, 24548-24632 +schema_version: "1.0" +name: tree +description: "Cross-repo/cross-commit tree identity linking trees with the same hash" +resource_kind: virtual +sandbox_strategy: none +user_addable: false +built_in: true +cli_args: [] +parent_types: [] +child_types: ["git-tree"] +capabilities: + read: false + write: false + sandbox: false + checkpoint: false +equivalence: + criteria: + - tree_hash + description: "Two physical tree objects are equivalent when they share the same tree hash" +handler: null diff --git a/features/resource_type_virtual_core.feature b/features/resource_type_virtual_core.feature new file mode 100644 index 000000000..2354ee098 --- /dev/null +++ b/features/resource_type_virtual_core.feature @@ -0,0 +1,354 @@ +Feature: Virtual Core Resource Types + As a CleverAgents developer + I want built-in virtual resource type YAMLs (file, directory, commit, branch, tag, tree) + So that cross-layer and cross-repo identity linking is available out of the box + + # ── YAML schema validation ──────────────────────────────── + + Scenario Outline: Virtual core YAML validates against schema for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema should be valid + And the vcore schema name should be "" + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── Resource kind is virtual ────────────────────────────── + + Scenario Outline: Virtual core types have resource_kind virtual for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema resource_kind should be "virtual" + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── Sandbox strategy is none ────────────────────────────── + + Scenario Outline: Virtual core types have sandbox_strategy none for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema sandbox_strategy should be "none" + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── Not user-addable ────────────────────────────────────── + + Scenario Outline: Virtual core types are not user-addable for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema user_addable should be false + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── Built-in and unnamespaced ───────────────────────────── + + Scenario Outline: Virtual core types are built-in and unnamespaced for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema built_in should be true + And the vcore schema name should not contain "/" + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── All capabilities disabled ───────────────────────────── + + Scenario Outline: Virtual core types have all capabilities disabled for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore capabilities should have read false + And the vcore capabilities should have write false + And the vcore capabilities should have sandbox false + And the vcore capabilities should have checkpoint false + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── No CLI args ─────────────────────────────────────────── + + Scenario Outline: Virtual core types have no cli_args for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema should have 0 cli_args + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── No parent types (top-level virtual) ─────────────────── + + Scenario Outline: Virtual core types have no parent types for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema should have empty parent_types + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── Child types per spec ────────────────────────────────── + + Scenario: file virtual type has correct child types for vcore + Given the built-in file virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema child_types should contain "fs-file" + And the vcore schema child_types should contain "git-tree-entry" + And the vcore schema child_types should have exactly 2 entries + + Scenario: directory virtual type has correct child types for vcore + Given the built-in directory virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema child_types should contain "fs-directory" + And the vcore schema child_types should contain "git-tree" + And the vcore schema child_types should have exactly 2 entries + + Scenario: commit virtual type has correct child types for vcore + Given the built-in commit virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema child_types should contain "git-commit" + And the vcore schema child_types should have exactly 1 entries + + Scenario: branch virtual type has correct child types for vcore + Given the built-in branch virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema child_types should contain "git-branch" + And the vcore schema child_types should have exactly 1 entries + + Scenario: tag virtual type has correct child types for vcore + Given the built-in tag virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema child_types should contain "git-tag" + And the vcore schema child_types should have exactly 1 entries + + Scenario: tree virtual type has correct child types for vcore + Given the built-in tree virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema child_types should contain "git-tree" + And the vcore schema child_types should have exactly 1 entries + + # ── Equivalence metadata present ────────────────────────── + + Scenario: file virtual type has equivalence with content_hash for vcore + Given the built-in file virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema equivalence should not be none + And the vcore equivalence criteria should contain "content_hash" + And the vcore equivalence criteria should contain "filename" + And the vcore equivalence criteria should contain "permissions" + And the vcore equivalence criteria count should be 3 + + Scenario: directory virtual type has equivalence with merkle_hash for vcore + Given the built-in directory virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema equivalence should not be none + And the vcore equivalence criteria should contain "merkle_hash" + And the vcore equivalence criteria should contain "name" + And the vcore equivalence criteria should contain "permissions" + And the vcore equivalence criteria count should be 3 + + Scenario: commit virtual type has equivalence with commit_hash for vcore + Given the built-in commit virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema equivalence should not be none + And the vcore equivalence criteria should contain "commit_hash" + And the vcore equivalence criteria count should be 1 + + Scenario: branch virtual type has equivalence with name and head for vcore + Given the built-in branch virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema equivalence should not be none + And the vcore equivalence criteria should contain "name" + And the vcore equivalence criteria should contain "head_commit_hash" + And the vcore equivalence criteria count should be 2 + + Scenario: tag virtual type has equivalence with name and target for vcore + Given the built-in tag virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema equivalence should not be none + And the vcore equivalence criteria should contain "name" + And the vcore equivalence criteria should contain "target_sha" + And the vcore equivalence criteria count should be 2 + + Scenario: tree virtual type has equivalence with tree_hash for vcore + Given the built-in tree virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema equivalence should not be none + And the vcore equivalence criteria count should be 1 + And the vcore equivalence criteria should contain "tree_hash" + + # ── Domain model loading ────────────────────────────────── + + Scenario Outline: Virtual core YAMLs load as domain model for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML as a ResourceTypeSpec for vcore + Then the vcore domain model should be valid + And the vcore domain model name should be "" + And the vcore domain model resource_kind should be virtual + And the vcore domain model should have equivalence + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── Bootstrap registration includes virtual types ───────── + + Scenario: Bootstrap builtin types list includes virtual core types for vcore + Given the bootstrap builtin type definitions for vcore + Then the builtin definitions should include "file" for vcore + And the builtin definitions should include "directory" for vcore + And the builtin definitions should include "commit" for vcore + And the builtin definitions should include "branch" for vcore + And the builtin definitions should include "tag" for vcore + And the builtin definitions should include "tree" for vcore + + Scenario Outline: Bootstrap virtual types have correct properties for vcore + Given the bootstrap builtin type definitions for vcore + When I find the virtual type "" in builtin definitions for vcore + Then the found builtin type user_addable should be false for vcore + And the found builtin type resource_kind should be "virtual" for vcore + And the found builtin type should have equivalence for vcore + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── BUILTIN_NAMES includes virtual types ────────────────── + + Scenario: ResourceTypeSpec BUILTIN_NAMES includes virtual core types for vcore + Then the ResourceTypeSpec BUILTIN_NAMES should contain "file" for vcore + And the ResourceTypeSpec BUILTIN_NAMES should contain "directory" for vcore + And the ResourceTypeSpec BUILTIN_NAMES should contain "commit" for vcore + And the ResourceTypeSpec BUILTIN_NAMES should contain "branch" for vcore + And the ResourceTypeSpec BUILTIN_NAMES should contain "tag" for vcore + And the ResourceTypeSpec BUILTIN_NAMES should contain "tree" for vcore + + # ── Auto-discovery is none for virtual types ────────────── + + Scenario Outline: Virtual core types have no auto-discovery for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema auto_discovery should be none + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── Handler is none for virtual types ───────────────────── + + Scenario Outline: Virtual core types have no handler for vcore + Given the built-in virtual YAML file for vcore + When I load the virtual YAML via from_yaml_file for vcore + Then the vcore schema handler should be none + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── Negative / validation tests ────────────────────────────── + + Scenario: Virtual type without equivalence is rejected for vcore + Given a virtual type config missing equivalence for vcore + When I attempt to create a ResourceTypeSpec from it for vcore + Then the vcore creation should fail with a validation error + And the vcore error message should mention "equivalence" + + Scenario: Virtual type with empty criteria list is rejected for vcore + Given a virtual type config with empty equivalence criteria for vcore + When I attempt to create a ResourceTypeSpec from it for vcore + Then the vcore creation should fail with a validation error + And the vcore error message should mention "criteria" + + # ── YAML ↔ registry data consistency ───────────────────── + + Scenario Outline: YAML and registry data match for vcore + Given the built-in virtual YAML file for vcore + And the bootstrap builtin type definitions for vcore + When I load the virtual YAML as a ResourceTypeSpec for vcore + And I find the virtual type "" in builtin definitions for vcore + Then the YAML and registry should have matching fields for vcore + + Examples: + | type_name | + | file | + | directory | + | commit | + | branch | + | tag | + | tree | + + # ── Virtual types cannot be user-added ─────────────────── + + Scenario: Attempting to register a virtual resource instance is rejected for vcore + Given a bootstrapped resource registry for vcore + When I attempt to register a resource of virtual type "file" for vcore + Then the vcore resource registration should fail with "not user-addable" diff --git a/features/steps/resource_registry_service_coverage_steps.py b/features/steps/resource_registry_service_coverage_steps.py index ed6aa63c6..c51d928de 100644 --- a/features/steps/resource_registry_service_coverage_steps.py +++ b/features/steps/resource_registry_service_coverage_steps.py @@ -253,8 +253,8 @@ def step_verify_ad_json(context: Any) -> None: @given("a ResourceTypeSpec with equivalence set") def step_spec_with_equivalence(context: Any) -> None: context.equivalence_data = { - "fields": ["content_hash", "location"], - "strategy": "hash_compare", + "criteria": ["content_hash", "location"], + "description": "Hash-based equivalence comparison", } # Virtual types require equivalence, so this is consistent context.cov_spec_eq = ResourceTypeSpec( @@ -346,7 +346,7 @@ def step_db_row_with_equivalence(context: Any) -> None: context.cov_eq_engine = engine context.cov_eq_factory = factory - context.eq_original = {"fields": ["hash"], "strategy": "exact"} + context.eq_original = {"criteria": ["hash"], "description": "Exact match"} session = factory() from datetime import UTC, datetime @@ -415,9 +415,9 @@ def step_yaml_with_ad_and_eq(context: Any) -> None: " glob_pattern: '*.md'\n" " max_depth: 5\n" "equivalence:\n" - " fields:\n" + " criteria:\n" " - content_hash\n" - " strategy: hash_compare\n" + " description: Hash-based equivalence\n" ) with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp: tmp.write(yaml_content) @@ -447,5 +447,5 @@ def step_verify_rt_ad(context: Any) -> None: def step_verify_rt_eq(context: Any) -> None: eq = context.cov_rt_shown.equivalence assert eq is not None, "equivalence should be preserved" - assert eq["fields"] == ["content_hash"] - assert eq["strategy"] == "hash_compare" + assert eq["criteria"] == ["content_hash"] + assert eq["description"] == "Hash-based equivalence" diff --git a/features/steps/resource_type_model_steps.py b/features/steps/resource_type_model_steps.py index 60994e197..944ea217f 100644 --- a/features/steps/resource_type_model_steps.py +++ b/features/steps/resource_type_model_steps.py @@ -45,7 +45,10 @@ 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) - config["equivalence"] = {"strategy": "content_hash", "fields": ["hash"]} + config["equivalence"] = { + "criteria": ["content_hash"], + "description": "Test equivalence", + } context.rt_config = config diff --git a/features/steps/resource_type_virtual_core_steps.py b/features/steps/resource_type_virtual_core_steps.py new file mode 100644 index 000000000..ba21c78af --- /dev/null +++ b/features/steps/resource_type_virtual_core_steps.py @@ -0,0 +1,581 @@ +"""Step definitions for resource_type_virtual_core.feature. + +Tests virtual core resource types: file, directory, commit, branch, tag, tree. +All step suffixes use 'for vcore' to avoid AmbiguousStep conflicts. + +Context attributes are accessed via ``_ctx()`` typed wrapper to satisfy +Pyright without inline ``# type: ignore`` suppressions. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml +from behave import given, then, when +from pydantic import ValidationError + +from cleveragents.application.services._resource_registry_data import ( + BUILTIN_TYPES, +) +from cleveragents.domain.models.core.resource_type import ( + ResourceKind, + ResourceTypeSpec, +) +from cleveragents.resource.schema import ResourceTypeConfigSchema + +_EXAMPLES_DIR = ( + Path(__file__).resolve().parent.parent.parent / "examples" / "resource-types" +) + + +# --------------------------------------------------------------------------- +# Typed context accessor — eliminates inline type: ignore suppressions +# --------------------------------------------------------------------------- + + +_SENTINEL = object() + +# Attribute name mapping: property name -> context attribute name. +_ATTR_MAP: dict[str, str] = { + "yaml_path": "vcore_yaml_path", + "schema": "vcore_schema", + "domain": "vcore_domain", + "builtin_defs": "vcore_builtin_defs", + "found_builtin": "vcore_found_builtin", + "test_config": "vcore_test_config", + "created_spec": "vcore_created_spec", + "creation_error": "vcore_creation_error", + "registry_service": "vcore_registry_service", + "register_error": "vcore_register_error", +} + + +class _Ctx: + """Typed proxy for Behave's dynamically-typed ``context`` object. + + Centralises all dynamic attribute access behind typed properties so + step functions never need inline ``# type: ignore`` comments. + Uses a dict-based lookup to satisfy both Pyright (typed properties) + and ruff B009/B010 (no constant-string getattr/setattr). + """ + + __slots__ = ("_ctx",) + + def __init__(self, ctx: object) -> None: + object.__setattr__(self, "_ctx", ctx) + + def _get(self, key: str, default: Any = _SENTINEL) -> Any: + attr = _ATTR_MAP[key] + if default is _SENTINEL: + return getattr(self._ctx, attr) + return getattr(self._ctx, attr, default) + + def _set(self, key: str, value: Any) -> None: + setattr(self._ctx, _ATTR_MAP[key], value) + + @property + def yaml_path(self) -> Path: + return self._get("yaml_path") + + @yaml_path.setter + def yaml_path(self, value: Path) -> None: + self._set("yaml_path", value) + + @property + def schema(self) -> ResourceTypeConfigSchema: + return self._get("schema") + + @schema.setter + def schema(self, value: ResourceTypeConfigSchema) -> None: + self._set("schema", value) + + @property + def domain(self) -> ResourceTypeSpec: + return self._get("domain") + + @domain.setter + def domain(self, value: ResourceTypeSpec) -> None: + self._set("domain", value) + + @property + def builtin_defs(self) -> list[dict[str, Any]]: + return self._get("builtin_defs") + + @builtin_defs.setter + def builtin_defs(self, value: list[dict[str, Any]]) -> None: + self._set("builtin_defs", value) + + @property + def found_builtin(self) -> dict[str, Any]: + return self._get("found_builtin") + + @found_builtin.setter + def found_builtin(self, value: dict[str, Any]) -> None: + self._set("found_builtin", value) + + @property + def test_config(self) -> dict[str, Any]: + return self._get("test_config") + + @test_config.setter + def test_config(self, value: dict[str, Any]) -> None: + self._set("test_config", value) + + @property + def created_spec(self) -> ResourceTypeSpec | None: + return self._get("created_spec", None) + + @created_spec.setter + def created_spec(self, value: ResourceTypeSpec | None) -> None: + self._set("created_spec", value) + + @property + def creation_error(self) -> Exception | None: + return self._get("creation_error", None) + + @creation_error.setter + def creation_error(self, value: Exception | None) -> None: + self._set("creation_error", value) + + @property + def registry_service(self) -> Any: + return self._get("registry_service") + + @registry_service.setter + def registry_service(self, value: Any) -> None: + self._set("registry_service", value) + + @property + def register_error(self) -> Exception | None: + return self._get("register_error", None) + + @register_error.setter + def register_error(self, value: Exception | None) -> None: + self._set("register_error", value) + + +def _ctx(context: object) -> _Ctx: + """Wrap Behave context with typed accessors.""" + return _Ctx(context) + + +# ── Given steps ────────────────────────────────────────────── + + +@given("the built-in {type_name} virtual YAML file for vcore") +def step_builtin_virtual_yaml(context: object, type_name: str) -> None: + """Load path to a virtual core type YAML file.""" + path = _EXAMPLES_DIR / f"{type_name}.yaml" + assert path.exists(), f"YAML file not found: {path}" + _ctx(context).yaml_path = path + + +@given("the bootstrap builtin type definitions for vcore") +def step_bootstrap_builtin_defs(context: object) -> None: + """Access the BUILTIN_TYPES list from the registry data module.""" + _ctx(context).builtin_defs = list(BUILTIN_TYPES) + + +# ── When steps ─────────────────────────────────────────────── + + +@when("I load the virtual YAML via from_yaml_file for vcore") +def step_load_virtual_yaml(context: object) -> None: + """Load virtual core YAML using the schema loader.""" + c = _ctx(context) + c.schema = ResourceTypeConfigSchema.from_yaml_file(c.yaml_path) + + +@when("I load the virtual YAML as a ResourceTypeSpec for vcore") +def step_load_virtual_as_domain(context: object) -> None: + """Load virtual core YAML as a domain model.""" + c = _ctx(context) + raw: dict[str, Any] = yaml.safe_load(c.yaml_path.read_text(encoding="utf-8")) + c.domain = ResourceTypeSpec.from_config(raw) + + +@when('I find the virtual type "{type_name}" in builtin definitions for vcore') +def step_find_virtual_in_builtins(context: object, type_name: str) -> None: + """Find a specific virtual type entry in BUILTIN_TYPES.""" + c = _ctx(context) + found = [d for d in c.builtin_defs if d["name"] == type_name] + assert len(found) == 1, ( + f"Expected exactly 1 entry for '{type_name}', found {len(found)}" + ) + c.found_builtin = found[0] + + +# ── Then steps: schema validation ──────────────────────────── + + +@then("the vcore schema should be valid") +def step_vcore_schema_valid(context: object) -> None: + schema = _ctx(context).schema + assert schema is not None + assert isinstance(schema, ResourceTypeConfigSchema) + + +@then('the vcore schema name should be "{name}"') +def step_vcore_schema_name(context: object, name: str) -> None: + assert _ctx(context).schema.name == name + + +@then('the vcore schema resource_kind should be "{kind}"') +def step_vcore_schema_kind(context: object, kind: str) -> None: + assert _ctx(context).schema.resource_kind == kind + + +@then('the vcore schema sandbox_strategy should be "{strategy}"') +def step_vcore_schema_sandbox(context: object, strategy: str) -> None: + assert _ctx(context).schema.sandbox_strategy == strategy + + +@then("the vcore schema user_addable should be false") +def step_vcore_schema_not_addable(context: object) -> None: + assert _ctx(context).schema.user_addable is False + + +@then("the vcore schema built_in should be true") +def step_vcore_schema_builtin(context: object) -> None: + assert _ctx(context).schema.built_in is True + + +@then('the vcore schema name should not contain "/"') +def step_vcore_schema_no_slash(context: object) -> None: + assert "/" not in _ctx(context).schema.name + + +@then("the vcore schema should have {count:d} cli_args") +def step_vcore_schema_cli_args_count(context: object, count: int) -> None: + actual = len(_ctx(context).schema.cli_args) + assert actual == count, f"Expected {count} cli_args, got {actual}" + + +@then("the vcore schema should have empty parent_types") +def step_vcore_schema_no_parents(context: object) -> None: + parent_types = _ctx(context).schema.parent_types + assert len(parent_types) == 0, f"Expected empty parent_types, got {parent_types}" + + +@then('the vcore schema child_types should contain "{child}"') +def step_vcore_schema_child(context: object, child: str) -> None: + children = _ctx(context).schema.child_types + assert child in children, f"'{child}' not in child_types: {children}" + + +@then("the vcore schema child_types should have exactly {count:d} entries") +def step_vcore_schema_child_count(context: object, count: int) -> None: + actual = len(_ctx(context).schema.child_types) + assert actual == count, f"Expected {count} child_types, got {actual}" + + +# ── Then steps: capabilities ───────────────────────────────── + + +@then("the vcore capabilities should have read {value}") +def step_vcore_caps_read(context: object, value: str) -> None: + expected = value.lower() == "true" + actual = _ctx(context).schema.capabilities["read"] + assert actual is expected, f"read: expected {expected}, got {actual}" + + +@then("the vcore capabilities should have write {value}") +def step_vcore_caps_write(context: object, value: str) -> None: + expected = value.lower() == "true" + actual = _ctx(context).schema.capabilities["write"] + assert actual is expected, f"write: expected {expected}, got {actual}" + + +@then("the vcore capabilities should have sandbox {value}") +def step_vcore_caps_sandbox(context: object, value: str) -> None: + expected = value.lower() == "true" + actual = _ctx(context).schema.capabilities["sandbox"] + assert actual is expected, f"sandbox: expected {expected}, got {actual}" + + +@then("the vcore capabilities should have checkpoint {value}") +def step_vcore_caps_checkpoint(context: object, value: str) -> None: + expected = value.lower() == "true" + actual = _ctx(context).schema.capabilities["checkpoint"] + assert actual is expected, f"checkpoint: expected {expected}, got {actual}" + + +# ── Then steps: equivalence ────────────────────────────────── + + +@then("the vcore schema equivalence should not be none") +def step_vcore_equivalence_not_none(context: object) -> None: + equiv = _ctx(context).schema.equivalence + assert equiv is not None, "equivalence should not be None for virtual types" + + +@then('the vcore equivalence criteria should contain "{criterion}"') +def step_vcore_equivalence_criterion(context: object, criterion: str) -> None: + equiv = _ctx(context).schema.equivalence + assert equiv is not None, "equivalence is None" + criteria = equiv.get("criteria", []) + assert criterion in criteria, f"'{criterion}' not in criteria: {criteria}" + + +# ── Then steps: auto-discovery and handler ─────────────────── + + +@then("the vcore schema auto_discovery should be none") +def step_vcore_no_autodiscovery(context: object) -> None: + auto = _ctx(context).schema.auto_discovery + assert auto is None, f"Expected auto_discovery=None, got {auto}" + + +@then("the vcore schema handler should be none") +def step_vcore_no_handler(context: object) -> None: + handler = _ctx(context).schema.handler + assert handler is None, f"Expected handler=None, got {handler}" + + +# ── Then steps: domain model ──────────────────────────────── + + +@then("the vcore domain model should be valid") +def step_vcore_domain_valid(context: object) -> None: + domain = _ctx(context).domain + assert domain is not None + assert isinstance(domain, ResourceTypeSpec) + + +@then('the vcore domain model name should be "{name}"') +def step_vcore_domain_name(context: object, name: str) -> None: + assert _ctx(context).domain.name == name + + +@then("the vcore domain model resource_kind should be virtual") +def step_vcore_domain_kind_virtual(context: object) -> None: + domain = _ctx(context).domain + assert domain.resource_kind == ResourceKind.VIRTUAL, ( + f"Expected VIRTUAL, got {domain.resource_kind}" + ) + + +# ── Then steps: bootstrap registration ────────────────────── + + +@then('the builtin definitions should include "{type_name}" for vcore') +def step_builtin_includes_type(context: object, type_name: str) -> None: + defs: list[dict[str, Any]] = _ctx(context).builtin_defs + names = [d["name"] for d in defs] + assert type_name in names, f"'{type_name}' not found in builtin defs: {names}" + + +@then("the found builtin type user_addable should be false for vcore") +def step_found_builtin_not_addable(context: object) -> None: + entry: dict[str, Any] = _ctx(context).found_builtin + assert entry["user_addable"] is False, ( + f"Expected user_addable=False, got {entry['user_addable']}" + ) + + +@then('the found builtin type resource_kind should be "{kind}" for vcore') +def step_found_builtin_kind(context: object, kind: str) -> None: + entry: dict[str, Any] = _ctx(context).found_builtin + assert entry["resource_kind"] == kind, ( + f"Expected resource_kind='{kind}', got {entry['resource_kind']}" + ) + + +@then("the found builtin type should have equivalence for vcore") +def step_found_builtin_has_equivalence(context: object) -> None: + entry: dict[str, Any] = _ctx(context).found_builtin + assert "equivalence" in entry, f"No 'equivalence' key in entry: {entry.keys()}" + assert entry["equivalence"] is not None, "equivalence is None" + assert "criteria" in entry["equivalence"], ( + f"No 'criteria' in equivalence: {entry['equivalence']}" + ) + + +# ── Then steps: BUILTIN_NAMES ────────────────────────────── + + +@then('the ResourceTypeSpec BUILTIN_NAMES should contain "{name}" for vcore') +def step_builtin_names_contains(context: object, name: str) -> None: + assert name in ResourceTypeSpec.BUILTIN_NAMES, ( + f"'{name}' not in BUILTIN_NAMES: {ResourceTypeSpec.BUILTIN_NAMES}" + ) + + +# ── Given/When/Then steps: negative validation tests ──────── + + +@given("a virtual type config missing equivalence for vcore") +def step_virtual_config_missing_equiv(context: object) -> None: + """Create a virtual type config dict without equivalence.""" + _ctx(context).test_config = { + "name": "test-virtual-no-equiv", + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": [], + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + } + + +@given("a virtual type config with empty equivalence criteria for vcore") +def step_virtual_config_empty_criteria(context: object) -> None: + """Create a virtual type config with equivalence but empty criteria.""" + _ctx(context).test_config = { + "name": "test-virtual-empty-criteria", + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": [], + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + "equivalence": { + "criteria": [], + "description": "Empty criteria test", + }, + } + + +@when("I attempt to create a ResourceTypeSpec from it for vcore") +def step_attempt_create_spec(context: object) -> None: + """Try to create a ResourceTypeSpec and capture any error.""" + c = _ctx(context) + try: + c.created_spec = ResourceTypeSpec.from_config(c.test_config) + c.creation_error = None + except (ValueError, ValidationError) as exc: + c.created_spec = None + c.creation_error = exc + + +@then("the vcore creation should fail with a validation error") +def step_creation_should_fail(context: object) -> None: + assert _ctx(context).creation_error is not None, ( + "Expected a validation error but creation succeeded" + ) + + +@then('the vcore error message should mention "{keyword}"') +def step_error_mentions(context: object, keyword: str) -> None: + err = _ctx(context).creation_error + assert err is not None, "No error captured" + assert keyword.lower() in str(err).lower(), ( + f"Error message does not mention '{keyword}': {err}" + ) + + +@then("the vcore creation should succeed") +def step_creation_should_succeed(context: object) -> None: + c = _ctx(context) + assert c.creation_error is None, ( + f"Expected success but got error: {c.creation_error}" + ) + assert c.created_spec is not None + + +@then("the vcore created spec equivalence criteria should be empty") +def step_created_spec_empty_criteria(context: object) -> None: + spec = _ctx(context).created_spec + assert spec is not None + assert spec.equivalence is not None + assert spec.equivalence["criteria"] == [], ( + f"Expected empty criteria, got {spec.equivalence['criteria']}" + ) + + +@then("the vcore equivalence criteria count should be {count:d}") +def step_equiv_criteria_count(context: object, count: int) -> None: + schema = _ctx(context).schema + actual = len(schema.equivalence["criteria"]) + assert actual == count, f"Expected {count} criteria, got {actual}" + + +@then("the vcore domain model should have equivalence") +def step_domain_has_equivalence(context: object) -> None: + domain = _ctx(context).domain + assert domain.equivalence is not None, ( + f"Domain model {domain.name} has no equivalence" + ) + + +@then("the YAML and registry should have matching fields for vcore") +def step_yaml_registry_match(context: object) -> None: + """Assert the YAML-loaded domain model matches the registry dict.""" + domain = _ctx(context).domain + found: dict[str, Any] = _ctx(context).found_builtin + assert domain.name == found["name"], ( + f"name mismatch: {domain.name} != {found['name']}" + ) + assert domain.resource_kind.value == found["resource_kind"], ( + f"resource_kind mismatch: {domain.resource_kind} != {found['resource_kind']}" + ) + assert domain.sandbox_strategy.value == found["sandbox_strategy"], ( + "sandbox_strategy mismatch" + ) + assert domain.user_addable == found["user_addable"], "user_addable mismatch" + assert sorted(domain.child_types) == sorted(found["child_types"]), ( + f"child_types mismatch: {domain.child_types} != {found['child_types']}" + ) + yaml_criteria = domain.equivalence["criteria"] if domain.equivalence else [] + reg_criteria = found.get("equivalence", {}).get("criteria", []) + assert yaml_criteria == reg_criteria, ( + f"equivalence criteria mismatch: {yaml_criteria} != {reg_criteria}" + ) + + +# ── Given/When/Then: user-add rejection ────────────────── + + +@given("a bootstrapped resource registry for vcore") +def step_bootstrapped_registry(context: object) -> None: + """Create a ResourceRegistryService and bootstrap 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:") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + service = ResourceRegistryService(session_factory=factory) + service.bootstrap_builtin_types() + _ctx(context).registry_service = service + + +@when('I attempt to register a resource of virtual type "{type_name}" for vcore') +def step_attempt_register_virtual(context: object, type_name: str) -> None: + """Try to register a resource instance of a virtual type.""" + c = _ctx(context) + try: + c.registry_service.register_resource(type_name, name=f"test-{type_name}") + c.register_error = None + except Exception as exc: + c.register_error = exc + + +@then('the vcore resource registration should fail with "{message}"') +def step_register_should_fail(context: object, message: str) -> None: + err = _ctx(context).register_error + assert err is not None, "Expected registration error but succeeded" + assert message.lower() in str(err).lower(), ( + f"Error does not mention '{message}': {err}" + ) diff --git a/robot/resource_type_virtual_core.robot b/robot/resource_type_virtual_core.robot new file mode 100644 index 000000000..a2bfafb85 --- /dev/null +++ b/robot/resource_type_virtual_core.robot @@ -0,0 +1,145 @@ +*** Settings *** +Documentation Virtual Core Resource Type Integration Tests +Library Process +Library OperatingSystem + +*** Variables *** +${PYTHON} python +${FILE_YAML} ${CURDIR}/../examples/resource-types/file.yaml +${DIR_YAML} ${CURDIR}/../examples/resource-types/directory.yaml +${COMMIT_YAML} ${CURDIR}/../examples/resource-types/commit.yaml +${BRANCH_YAML} ${CURDIR}/../examples/resource-types/branch.yaml +${TAG_YAML} ${CURDIR}/../examples/resource-types/tag.yaml +${TREE_YAML} ${CURDIR}/../examples/resource-types/tree.yaml + +*** Test Cases *** +Load All Virtual Core YAMLs And Validate Schema + [Documentation] Load all 6 virtual core YAMLs via schema loader and assert key fields + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.resource.schema import ResourceTypeConfigSchema + ... VIRTUAL_TYPES = { + ... ${SPACE}${SPACE}${SPACE}${SPACE}"file": "${FILE_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"directory": "${DIR_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"commit": "${COMMIT_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"branch": "${BRANCH_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"tag": "${TAG_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"tree": "${TREE_YAML}", + ... } + ... for name, path in VIRTUAL_TYPES.items(): + ... ${SPACE}${SPACE}${SPACE}${SPACE}schema = ResourceTypeConfigSchema.from_yaml_file(path) + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert schema.name == name, f"name mismatch: {schema.name} != {name}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert schema.resource_kind == "virtual", f"kind: {schema.resource_kind}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert schema.sandbox_strategy == "none", f"strategy: {schema.sandbox_strategy}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert schema.user_addable is False, f"addable: {schema.user_addable}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert schema.built_in is True, f"built_in: {schema.built_in}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert schema.equivalence is not None, f"equivalence missing for {name}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert len(schema.cli_args) == 0, f"cli_args not empty for {name}" + ... print("All 6 virtual core YAMLs validated successfully") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 Virtual core YAML load failed: ${result.stderr} + Should Contain ${result.stdout} All 6 virtual core YAMLs validated successfully + +Load Virtual Core YAMLs As Domain Models + [Documentation] Load all virtual core YAMLs as ResourceTypeSpec domain models + ${script}= Catenate SEPARATOR=\n + ... import yaml + ... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec, ResourceKind + ... PATHS = { + ... ${SPACE}${SPACE}${SPACE}${SPACE}"file": "${FILE_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"directory": "${DIR_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"commit": "${COMMIT_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"branch": "${BRANCH_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"tag": "${TAG_YAML}", + ... ${SPACE}${SPACE}${SPACE}${SPACE}"tree": "${TREE_YAML}", + ... } + ... for name, path in PATHS.items(): + ... ${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 == name, f"name: {spec.name}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec.resource_kind == ResourceKind.VIRTUAL + ... ${SPACE}${SPACE}${SPACE}${SPACE}cli_dict = spec.as_cli_dict() + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert "equivalence" in cli_dict, f"no equivalence in cli_dict for {name}" + ... print("All 6 virtual core 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 6 virtual core domain models validated successfully + +Verify Virtual Types In Bootstrap Registration + [Documentation] Verify all 6 virtual core types are in BUILTIN_TYPES + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.application.services._resource_registry_data import BUILTIN_TYPES + ... names = [d["name"] for d in BUILTIN_TYPES] + ... expected = ["file", "directory", "commit", "branch", "tag", "tree"] + ... for vt in expected: + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert vt in names, f"{vt} not in builtin types: {names}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}entry = next(d for d in BUILTIN_TYPES if d["name"] == vt) + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert entry["resource_kind"] == "virtual", f"{vt} kind: {entry['resource_kind']}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert entry["user_addable"] is False, f"{vt} addable: {entry['user_addable']}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert entry["sandbox_strategy"] == "none", f"{vt} strategy: {entry['sandbox_strategy']}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert entry.get("equivalence") is not None, f"{vt} missing equivalence" + ... print("All 6 virtual core types present in bootstrap registration") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 Bootstrap verification failed: ${result.stderr} + Should Contain ${result.stdout} All 6 virtual core types present in bootstrap registration + +Verify BUILTIN_NAMES Includes Virtual Types + [Documentation] Verify ResourceTypeSpec.BUILTIN_NAMES includes virtual core types + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec + ... expected = {"file", "directory", "commit", "branch", "tag", "tree"} + ... for name in expected: + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert name in ResourceTypeSpec.BUILTIN_NAMES, f"{name} not in BUILTIN_NAMES" + ... print("All virtual core types in BUILTIN_NAMES") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 BUILTIN_NAMES check failed: ${result.stderr} + Should Contain ${result.stdout} All virtual core types in BUILTIN_NAMES + +Virtual Types Appear In CLI Resource Type List + [Documentation] Verify virtual core types are listed via the CLI resource type list path + ${script}= Catenate SEPARATOR=\n + ... import json + ... 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:") + ... Base.metadata.create_all(engine) + ... service = ResourceRegistryService(session_factory=sessionmaker(bind=engine)) + ... service.bootstrap_builtin_types() + ... types = service.list_types() + ... type_names = [t.name for t in types] + ... expected = ["file", "directory", "commit", "branch", "tag", "tree"] + ... for vt in expected: + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert vt in type_names, f"{vt} not in listed types: {type_names}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}spec = next(t for t in types if t.name == vt) + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert str(spec.resource_kind) == "virtual", f"{vt} kind: {spec.resource_kind}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec.user_addable is False, f"{vt} user_addable: {spec.user_addable}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert spec.equivalence is not None, f"{vt} missing equivalence" + ... print(f"CLI list path: all 6 virtual types present and correctly typed") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 CLI list path verification failed: ${result.stderr} + Should Contain ${result.stdout} all 6 virtual types present + +Virtual Types Are Rejected By Register Resource + [Documentation] Verify virtual types cannot be user-added via register_resource + ${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:") + ... Base.metadata.create_all(engine) + ... service = ResourceRegistryService(session_factory=sessionmaker(bind=engine)) + ... service.bootstrap_builtin_types() + ... try: + ... ${SPACE}${SPACE}${SPACE}${SPACE}service.register_resource("file", name="test-file") + ... ${SPACE}${SPACE}${SPACE}${SPACE}raise AssertionError("Expected ValidationError but register_resource succeeded") + ... except Exception as e: + ... ${SPACE}${SPACE}${SPACE}${SPACE}assert "not user-addable" in str(e).lower(), f"Wrong error: {e}" + ... ${SPACE}${SPACE}${SPACE}${SPACE}print(f"Correctly rejected: {e}") + ${result}= Run Process ${PYTHON} -c ${script} + Should Be Equal As Integers ${result.rc} 0 Register rejection test failed: ${result.stderr} + Should Contain ${result.stdout} Correctly rejected + +*** Keywords *** diff --git a/src/cleveragents/application/services/_resource_registry_data.py b/src/cleveragents/application/services/_resource_registry_data.py index a141c68d3..9dd20ea5b 100644 --- a/src/cleveragents/application/services/_resource_registry_data.py +++ b/src/cleveragents/application/services/_resource_registry_data.py @@ -17,6 +17,9 @@ from collections import deque from datetime import UTC, datetime from typing import Any +from cleveragents.application.services._resource_registry_virtual import ( + VIRTUAL_CORE_TYPES, +) from cleveragents.domain.models.core.resource import PhysVirt, Resource from cleveragents.domain.models.core.resource_type import ( ResourceKind, @@ -269,6 +272,8 @@ _GIT_FS_CONTAINER_TYPES: list[dict[str, Any]] = [ }, }, *DATABASE_TYPE_DEFS, + # Virtual core resource types -- see _resource_registry_virtual.py (#329) + *VIRTUAL_CORE_TYPES, ] diff --git a/src/cleveragents/application/services/_resource_registry_ops.py b/src/cleveragents/application/services/_resource_registry_ops.py index 795775236..3e8b04c21 100644 --- a/src/cleveragents/application/services/_resource_registry_ops.py +++ b/src/cleveragents/application/services/_resource_registry_ops.py @@ -95,7 +95,8 @@ class ResourceInstanceMixin: Raises: NotFoundError: If the resource type does not exist. - ValidationError: If the resource name already exists. + ValidationError: If the resource type is not user-addable, + or if the resource name already exists. """ session = self._session() try: @@ -108,6 +109,12 @@ class ResourceInstanceMixin: resource_id=type_name, ) + if not type_row.user_addable: + raise ValidationError( + f"Resource type '{type_name}' is not user-addable. " + "Only types with user_addable=true can be instantiated.", + ) + resource_kind_str: str = str(type_row.resource_kind) resource_id = str(ULID()) diff --git a/src/cleveragents/application/services/_resource_registry_virtual.py b/src/cleveragents/application/services/_resource_registry_virtual.py new file mode 100644 index 000000000..25c1c3942 --- /dev/null +++ b/src/cleveragents/application/services/_resource_registry_virtual.py @@ -0,0 +1,205 @@ +"""Virtual core resource type definitions for the Resource Registry. + +Contains the built-in virtual type entries (file, directory, commit, +branch, tag, tree) that are appended to ``BUILTIN_TYPES`` in +``_resource_registry_data``. Extracted to keep each module under the +500-line CONTRIBUTING limit. + +.. 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_CORE_TYPES"] + +# === Virtual Core Resource Types (#329) === +# +# Virtual types represent abstract identities that link equivalent +# physical resources across layers and repositories. They have no +# handler, no sandbox strategy, and are never user-addable. +# +# NOTE: Some ``child_types`` entries (e.g. ``git-tree-entry``, +# ``git-tree``, ``git-commit``, ``git-branch``, ``git-tag``) are +# forward references to deferred physical types introduced by PR #662 +# (issue #330). This is safe because the registry stores child_types +# as opaque strings with no referential-integrity check at bootstrap +# time -- see ``bootstrap_builtin_types()`` and ``spec_to_db()``. + +VIRTUAL_CORE_TYPES: list[dict[str, Any]] = [ + { + "name": "file", + "description": ( + "Cross-layer file identity linking equivalent physical files " + "by content hash, filename, and permissions." + ), + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": ["fs-file", "git-tree-entry"], + "handler": None, + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + "equivalence": { + "criteria": ["content_hash", "filename", "permissions"], + "description": ( + "Two physical resources are equivalent when they share " + "the same content bytes (SHA-256), filename, and permissions" + ), + }, + }, + { + "name": "directory", + "description": ( + "Cross-layer directory identity linking equivalent physical " + "directories by recursive content." + ), + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": ["fs-directory", "git-tree"], + "handler": None, + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + # Spec identity table lists only merkle_hash, but name and permissions + # are added for stricter matching (same recursive content in a + # differently-named or differently-permissioned directory is not + # considered equivalent). See specification.md ~line 24623. + "equivalence": { + "criteria": ["merkle_hash", "name", "permissions"], + "description": ( + "Two physical resources are equivalent when their " + "recursive contents produce the same Merkle hash" + ), + }, + }, + { + "name": "commit", + "description": ( + "Cross-repo commit identity linking commits with " + "the same hash across repositories." + ), + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": ["git-commit"], + "handler": None, + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + "equivalence": { + "criteria": ["commit_hash"], + "description": ( + "Two physical commits are equivalent when they " + "share the same commit hash" + ), + }, + }, + { + "name": "branch", + "description": ( + "Cross-repo branch identity linking branches with " + "the same name and HEAD commit hash." + ), + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": ["git-branch"], + "handler": None, + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + "equivalence": { + "criteria": ["name", "head_commit_hash"], + "description": ( + "Two physical branches are equivalent when they share " + "the same branch name and HEAD commit hash" + ), + }, + }, + { + "name": "tag", + "description": ( + "Cross-repo tag identity linking tags with the same name and target object." + ), + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": ["git-tag"], + "handler": None, + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + "equivalence": { + "criteria": ["name", "target_sha"], + "description": ( + "Two physical tags are equivalent when they share " + "the same tag name and target object" + ), + }, + }, + { + "name": "tree", + "description": ( + "Cross-repo/cross-commit tree identity linking trees with the same hash." + ), + "resource_kind": "virtual", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": ["git-tree"], + "handler": None, + "capabilities": { + "read": False, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + "equivalence": { + "criteria": ["tree_hash"], + "description": ( + "Two physical tree objects are equivalent when they " + "share the same tree hash" + ), + }, + }, +] diff --git a/src/cleveragents/application/services/resource_registry_service.py b/src/cleveragents/application/services/resource_registry_service.py index 68d719c88..21b688e2b 100644 --- a/src/cleveragents/application/services/resource_registry_service.py +++ b/src/cleveragents/application/services/resource_registry_service.py @@ -16,6 +16,12 @@ development workflows. Built-in types are **unnamespaced** (no |-------------------|----------|------------------|--------------------------------| | ``git-checkout`` | physical | ``git_worktree`` | Git repository checkout | | ``fs-directory`` | physical | ``copy_on_write``| Filesystem directory | +| ``file`` | virtual | ``none`` | Cross-layer file identity | +| ``directory`` | virtual | ``none`` | Cross-layer directory identity | +| ``commit`` | virtual | ``none`` | Cross-repo commit identity | +| ``branch`` | virtual | ``none`` | Cross-repo branch identity | +| ``tag`` | virtual | ``none`` | Cross-repo tag identity | +| ``tree`` | virtual | ``none`` | Cross-repo tree identity | Custom types must follow the ``namespace/name`` pattern and are registered via YAML configuration files. diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index 8efcc91d3..f6975a142 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -138,6 +138,7 @@ def _resource_type_dict(spec: Any) -> dict[str, object]: "child_types": spec.child_types, "handler": spec.handler, "capabilities": spec.capabilities, + "equivalence": getattr(spec, "equivalence", None), } return result @@ -434,6 +435,15 @@ def _print_type_panel(spec: Any) -> None: f"[bold]Child Types:[/bold] {', '.join(spec.child_types) or '(none)'}" ) + equivalence = getattr(spec, "equivalence", None) + if equivalence is not None: + criteria = equivalence.get("criteria", []) + equiv_desc = equivalence.get("description", "") + details += ( + f"\n[bold]Equivalence Criteria:[/bold] {', '.join(criteria)}" + f"\n[bold]Equivalence Description:[/bold] {equiv_desc}" + ) + console.print(Panel(details, title="Resource Type", expand=False)) diff --git a/src/cleveragents/domain/models/core/resource_type.py b/src/cleveragents/domain/models/core/resource_type.py index f0e0b2565..93499ae4c 100644 --- a/src/cleveragents/domain/models/core/resource_type.py +++ b/src/cleveragents/domain/models/core/resource_type.py @@ -4,11 +4,16 @@ Resource types define schema-level constraints for resource categories: CLI arguments, classification (physical/virtual), DAG constraints, sandbox strategy, capabilities, auto-discovery, and handler references. -Built-in types (``git-checkout``, ``fs-directory``, etc.) use simple -unnamespaced names. Custom types follow ``namespace/name`` format. +Built-in types use simple unnamespaced names (e.g. ``git-checkout``). +Custom types follow ``namespace/name`` (e.g. ``myorg/custom-db``). +Virtual types **must** include an ``equivalence`` configuration. Based on ``docs/specification.md``, ``implementation_plan.md`` Task B0.type-model, and ADR-004: Pydantic v2 Validation. + +See Also: + - ``docs/reference/resource_types_builtin.md`` -- full built-in type catalogue + - [Resource][cleveragents.domain.models.core.resource.Resource] """ from __future__ import annotations @@ -230,6 +235,13 @@ class ResourceTypeSpec(BaseModel): # -- GCP / Azure (flat, hierarchy pending) -- "gcp", "azure", + # Virtual core resource types (#329) + "branch", + "commit", + "directory", + "file", + "tag", + "tree", } ) @@ -368,12 +380,24 @@ class ResourceTypeSpec(BaseModel): "Only built-in types can be unnamespaced." ) - # Virtual types require equivalence rules - if self.resource_kind == ResourceKind.VIRTUAL and self.equivalence is None: - raise ValueError( - f"Virtual resource type '{self.name}' requires an " - "'equivalence' configuration for deduplication." - ) + # Virtual types require equivalence rules with non-empty criteria + if self.resource_kind == ResourceKind.VIRTUAL: + if self.equivalence is None: + raise ValueError( + f"Virtual resource type '{self.name}' requires an " + "'equivalence' configuration for deduplication." + ) + if "criteria" not in self.equivalence: + raise ValueError( + f"Virtual resource type '{self.name}' requires " + "'equivalence.criteria' key for identity matching." + ) + 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." + ) # ADR-042 rule 3: no cycles (self-inheritance) if self.inherits is not None and self.inherits == self.name: @@ -486,7 +510,10 @@ class ResourceTypeSpec(BaseModel): if self.auto_discovery is not None: result["auto_discovery"] = dict(self.auto_discovery) if self.equivalence is not None: - result["equivalence"] = dict(self.equivalence) + result["equivalence"] = { + k: list(v) if isinstance(v, list) else v + for k, v in self.equivalence.items() + } if self.handler is not None: result["handler"] = self.handler