diff --git a/CHANGELOG.md b/CHANGELOG.md index b90b0ee90..db571c15f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Added `fs-mount` and `fs-file` bootstrap registration. Both types were + defined in `BUILTIN_NAMES` and had YAML configs but were never registered + in `BUILTIN_TYPES`. `fs-mount` is a top-level mount point (user-addable, + `copy_on_write` sandbox). `fs-file` is a leaf file node (auto-discovered, + child of `fs-directory`). Updated `fs-directory` parent_types to include + `fs-mount`. Includes Behave BDD tests and Robot integration tests. (#833) - 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 diff --git a/features/resource_type_fs_mount_file.feature b/features/resource_type_fs_mount_file.feature new file mode 100644 index 000000000..498c7c23b --- /dev/null +++ b/features/resource_type_fs_mount_file.feature @@ -0,0 +1,78 @@ +@fs-mount-file +Feature: fs-mount and fs-file resource types + Verifies that fs-mount and fs-file are correctly defined, + registered in BUILTIN_TYPES, and have proper parent/child + relationships within the filesystem resource hierarchy. + + # ── YAML schema loading ───────────────────────────────── + + Scenario Outline: fs types YAML validates for fs_mount_file + Given the built-in "" YAML for fs_mount_file + When I load the YAML via from_yaml_file for fs_mount_file + Then the fs_mount_file schema should be valid + And the fs_mount_file schema name should be "" + And the fs_mount_file schema resource_kind should be "physical" + + Examples: + | type_name | + | fs-mount | + | fs-file | + + # ── Domain model creation ─────────────────────────────── + + Scenario Outline: fs types load as domain model for fs_mount_file + Given the built-in "" YAML for fs_mount_file + When I load the YAML as a ResourceTypeSpec for fs_mount_file + Then the fs_mount_file domain model should be valid + And the fs_mount_file domain model name should be "" + + Examples: + | type_name | + | fs-mount | + | fs-file | + + # ── Parent/child relationships ────────────────────────── + + Scenario: fs-mount is top-level with fs-directory child for fs_mount_file + Given the built-in "fs-mount" YAML for fs_mount_file + When I load the YAML as a ResourceTypeSpec for fs_mount_file + Then the fs_mount_file spec should have empty parent_types + And the fs_mount_file child_types should contain "fs-directory" + + Scenario: fs-file parent is fs-directory for fs_mount_file + Given the built-in "fs-file" YAML for fs_mount_file + When I load the YAML as a ResourceTypeSpec for fs_mount_file + Then the fs_mount_file parent_types should contain "fs-directory" + And the fs_mount_file spec should have empty child_types + + Scenario: fs-directory lists fs-mount as parent for fs_mount_file + Given the bootstrap builtin type definitions for fs_mount_file + When I find the type "fs-directory" in builtin definitions for fs_mount_file + Then the found builtin parent_types should contain "fs-mount" for fs_mount_file + + # ── User-addable and sandbox ──────────────────────────── + + Scenario: fs-mount is user-addable for fs_mount_file + Given the built-in "fs-mount" YAML for fs_mount_file + When I load the YAML as a ResourceTypeSpec for fs_mount_file + Then the fs_mount_file user_addable should be true + + Scenario: fs-file is not user-addable for fs_mount_file + Given the built-in "fs-file" YAML for fs_mount_file + When I load the YAML as a ResourceTypeSpec for fs_mount_file + Then the fs_mount_file user_addable should be false + + # ── BUILTIN_NAMES and BUILTIN_TYPES ───────────────────── + + Scenario Outline: fs types in BUILTIN_NAMES for fs_mount_file + Then BUILTIN_NAMES should contain "" for fs_mount_file + + Examples: + | type_name | + | fs-mount | + | fs-file | + + Scenario: Both fs types in BUILTIN_TYPES for fs_mount_file + Given the bootstrap builtin type definitions for fs_mount_file + Then BUILTIN_TYPES should contain "fs-mount" for fs_mount_file + And BUILTIN_TYPES should contain "fs-file" for fs_mount_file diff --git a/features/steps/resource_type_fs_mount_file_steps.py b/features/steps/resource_type_fs_mount_file_steps.py new file mode 100644 index 000000000..826f5edef --- /dev/null +++ b/features/steps/resource_type_fs_mount_file_steps.py @@ -0,0 +1,126 @@ +"""Step definitions for resource_type_fs_mount_file.feature.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml +from behave import given, then, when + +from cleveragents.application.services._resource_registry_data import ( + BUILTIN_TYPES, +) +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('the built-in "{type_name}" YAML for fs_mount_file') +def step_builtin_yaml(context: object, type_name: str) -> None: + path = _EXAMPLES_DIR / f"{type_name}.yaml" + assert path.exists(), f"YAML not found: {path}" + context.fsm_yaml_path = path + + +@given("the bootstrap builtin type definitions for fs_mount_file") +def step_bootstrap_defs(context: object) -> None: + context.fsm_builtin_defs = list(BUILTIN_TYPES) + + +@when("I load the YAML via from_yaml_file for fs_mount_file") +def step_load_yaml(context: object) -> None: + path: Path = context.fsm_yaml_path + context.fsm_schema = ResourceTypeConfigSchema.from_yaml_file(path) + + +@when("I load the YAML as a ResourceTypeSpec for fs_mount_file") +def step_load_domain(context: object) -> None: + path: Path = context.fsm_yaml_path + raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) + context.fsm_spec = ResourceTypeSpec.from_config(raw) + + +@when('I find the type "{name}" in builtin definitions for fs_mount_file') +def step_find_in_builtins(context: object, name: str) -> None: + defs: list[dict[str, Any]] = context.fsm_builtin_defs + found = [d for d in defs if d["name"] == name] + assert len(found) == 1, f"Expected 1 entry for '{name}', found {len(found)}" + context.fsm_found_builtin = found[0] + + +@then("the fs_mount_file schema should be valid") +def step_schema_valid(context: object) -> None: + schema = context.fsm_schema + assert isinstance(schema, ResourceTypeConfigSchema) + + +@then('the fs_mount_file schema name should be "{name}"') +def step_schema_name(context: object, name: str) -> None: + assert context.fsm_schema.name == name + + +@then('the fs_mount_file schema resource_kind should be "{kind}"') +def step_schema_kind(context: object, kind: str) -> None: + assert context.fsm_schema.resource_kind == kind + + +@then("the fs_mount_file domain model should be valid") +def step_domain_valid(context: object) -> None: + assert isinstance(context.fsm_spec, ResourceTypeSpec) + + +@then('the fs_mount_file domain model name should be "{name}"') +def step_domain_name(context: object, name: str) -> None: + assert context.fsm_spec.name == name + + +@then("the fs_mount_file spec should have empty parent_types") +def step_empty_parents(context: object) -> None: + assert context.fsm_spec.parent_types == [] + + +@then("the fs_mount_file spec should have empty child_types") +def step_empty_children(context: object) -> None: + assert context.fsm_spec.child_types == [] + + +@then('the fs_mount_file parent_types should contain "{name}"') +def step_parent_contains(context: object, name: str) -> None: + spec = context.fsm_spec + assert name in spec.parent_types, f"'{name}' not in {spec.parent_types}" + + +@then('the fs_mount_file child_types should contain "{name}"') +def step_child_contains(context: object, name: str) -> None: + spec = context.fsm_spec + assert name in spec.child_types, f"'{name}' not in {spec.child_types}" + + +@then('the found builtin parent_types should contain "{name}" for fs_mount_file') +def step_found_parent(context: object, name: str) -> None: + found: dict[str, Any] = context.fsm_found_builtin + assert name in found["parent_types"], f"'{name}' not in {found['parent_types']}" + + +@then("the fs_mount_file user_addable should be {addable}") +def step_user_addable(context: object, addable: str) -> None: + expected = addable.lower() == "true" + assert context.fsm_spec.user_addable is expected + + +@then('BUILTIN_NAMES should contain "{name}" for fs_mount_file') +def step_builtin_names(context: object, name: str) -> None: + assert name in ResourceTypeSpec.BUILTIN_NAMES + + +@then('BUILTIN_TYPES should contain "{name}" for fs_mount_file') +def step_builtin_types(context: object, name: str) -> None: + defs: list[dict[str, Any]] = context.fsm_builtin_defs + names = [d["name"] for d in defs] + assert name in names, f"'{name}' not in BUILTIN_TYPES" diff --git a/robot/helper_resource_type_fs_mount_file.py b/robot/helper_resource_type_fs_mount_file.py new file mode 100644 index 000000000..85c9d8690 --- /dev/null +++ b/robot/helper_resource_type_fs_mount_file.py @@ -0,0 +1,83 @@ +"""Helper script for fs-mount and fs-file resource type Robot tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.application.services._resource_registry_data import ( # noqa: E402 + BUILTIN_TYPES, +) +from cleveragents.domain.models.core.resource_type import ( # noqa: E402 + ResourceTypeSpec, +) +from cleveragents.resource.schema import ( # noqa: E402 + ResourceTypeConfigSchema, +) + +_EXAMPLES = Path(__file__).resolve().parents[1] / "examples" / "resource-types" + + +def cmd_check_registration() -> None: + """Verify fs-mount and fs-file in BUILTIN_TYPES.""" + names = [d["name"] for d in BUILTIN_TYPES] + for n in ("fs-mount", "fs-file"): + assert n in names, f"{n} not in BUILTIN_TYPES" + assert n in ResourceTypeSpec.BUILTIN_NAMES, f"{n} not in BUILTIN_NAMES" + # Verify basic properties + mount = next(d for d in BUILTIN_TYPES if d["name"] == "fs-mount") + assert mount["user_addable"] is True + assert mount["sandbox_strategy"] == "copy_on_write" + ffile = next(d for d in BUILTIN_TYPES if d["name"] == "fs-file") + assert ffile["user_addable"] is False + assert ffile["sandbox_strategy"] == "copy_on_write" + print("check-registration-ok") + + +def cmd_check_hierarchy() -> None: + """Verify fs-mount -> fs-directory -> fs-file DAG.""" + by_name = {d["name"]: d for d in BUILTIN_TYPES} + mount = by_name["fs-mount"] + assert "fs-directory" in mount["child_types"] + assert mount["parent_types"] == [] + fdir = by_name["fs-directory"] + assert "fs-mount" in fdir["parent_types"] + assert "fs-file" in fdir["child_types"] + ffile = by_name["fs-file"] + assert "fs-directory" in ffile["parent_types"] + assert ffile["child_types"] == [] + print("check-hierarchy-ok") + + +def cmd_check_yaml() -> None: + """Load both YAML configs.""" + for n in ("fs-mount", "fs-file"): + path = _EXAMPLES / f"{n}.yaml" + assert path.exists(), f"Missing: {path}" + schema = ResourceTypeConfigSchema.from_yaml_file(path) + assert schema.name == n + spec = ResourceTypeSpec.from_config(schema.model_dump()) + assert spec.name == n + print("check-yaml-ok") + + +_COMMANDS = { + "check-registration": cmd_check_registration, + "check-hierarchy": cmd_check_hierarchy, + "check-yaml": cmd_check_yaml, +} + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(1) + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/robot/resource_type_fs_mount_file.robot b/robot/resource_type_fs_mount_file.robot new file mode 100644 index 000000000..ca0f06dfc --- /dev/null +++ b/robot/resource_type_fs_mount_file.robot @@ -0,0 +1,36 @@ +*** Settings *** +Documentation Integration tests for fs-mount and fs-file resource types. +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_resource_type_fs_mount_file.py + +*** Test Cases *** +Both fs Types In BUILTIN_TYPES + [Documentation] Verify fs-mount and fs-file are registered in BUILTIN_TYPES + [Tags] fs registration smoke + ${result}= Run Process ${PYTHON} ${HELPER} check-registration + ... timeout=60s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + ... Registration check failed: ${result.stderr} + Should Contain ${result.stdout} check-registration-ok + +Filesystem Hierarchy + [Documentation] Verify fs-mount -> fs-directory -> fs-file DAG + [Tags] fs hierarchy + ${result}= Run Process ${PYTHON} ${HELPER} check-hierarchy + ... timeout=60s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + ... Hierarchy check failed: ${result.stderr} + Should Contain ${result.stdout} check-hierarchy-ok + +YAML Schema Loading + [Documentation] Load both YAML configs and verify schema + [Tags] fs yaml + ${result}= Run Process ${PYTHON} ${HELPER} check-yaml + ... timeout=60s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + ... YAML loading failed: ${result.stderr} + Should Contain ${result.stdout} check-yaml-ok diff --git a/src/cleveragents/application/services/_resource_registry_data.py b/src/cleveragents/application/services/_resource_registry_data.py index 9d2e715a7..8f6b2c972 100644 --- a/src/cleveragents/application/services/_resource_registry_data.py +++ b/src/cleveragents/application/services/_resource_registry_data.py @@ -177,7 +177,7 @@ _GIT_FS_CONTAINER_TYPES: list[dict[str, Any]] = [ "description": "Path to the directory.", }, ], - "parent_types": ["git-checkout", "fs-directory"], + "parent_types": ["git-checkout", "fs-mount", "fs-directory"], "child_types": [ "fs-directory", "fs-file", @@ -192,6 +192,59 @@ _GIT_FS_CONTAINER_TYPES: list[dict[str, Any]] = [ "checkpoint": False, }, }, + { + "name": "fs-mount", + "description": "A filesystem mount point (bind mount, NFS, tmpfs, etc.).", + "resource_kind": "physical", + "sandbox_strategy": "copy_on_write", + "user_addable": True, + "built_in": True, + "cli_args": [ + { + "name": "path", + "type": "path", + "required": True, + "description": "Path to the mount point.", + }, + ], + "parent_types": [], + "child_types": ["fs-directory"], + "auto_discovery": { + "enabled": True, + "scan_depth": 1, + "rules": [ + {"type": "fs-directory", "pattern": "/"}, + ], + }, + "handler": "cleveragents.resource.handlers.fs_mount:FsMountHandler", + "capabilities": { + "read": True, + "write": True, + "sandbox": True, + "checkpoint": False, + }, + }, + { + "name": "fs-file", + "description": ( + "An individual file on the local filesystem with " + "content hash metadata for equivalence tracking." + ), + "resource_kind": "physical", + "sandbox_strategy": "copy_on_write", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": ["fs-directory"], + "child_types": [], + "handler": "cleveragents.resource.handlers.fs_file:FsFileHandler", + "capabilities": { + "read": True, + "write": True, + "sandbox": False, + "checkpoint": False, + }, + }, { "name": "container-instance", "description": "A container execution environment instance.", diff --git a/src/cleveragents/application/services/resource_registry_service.py b/src/cleveragents/application/services/resource_registry_service.py index 5a0939586..d39249ea8 100644 --- a/src/cleveragents/application/services/resource_registry_service.py +++ b/src/cleveragents/application/services/resource_registry_service.py @@ -16,6 +16,8 @@ development workflows. Built-in types are **unnamespaced** (no |-------------------|----------|------------------|--------------------------------| | ``git-checkout`` | physical | ``git_worktree`` | Git repository checkout | | ``fs-directory`` | physical | ``copy_on_write``| Filesystem directory | +| ``fs-mount`` | physical | ``copy_on_write``| Filesystem mount point | +| ``fs-file`` | physical | ``copy_on_write``| Individual file (with hash) | | ``file`` | virtual | ``none`` | Cross-layer file identity | | ``directory`` | virtual | ``none`` | Cross-layer directory identity | | ``commit`` | virtual | ``none`` | Cross-repo commit identity |