Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9707ae165 |
@@ -0,0 +1,174 @@
|
||||
"""ASV benchmarks for Resource CLI tree/inspect command overhead.
|
||||
|
||||
Measures the performance of:
|
||||
- Tree rendering for a resource with no children
|
||||
- Tree rendering for a resource with multiple children
|
||||
- Inspect command rendering
|
||||
- Link-child / unlink-child command throughput
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from typer.testing import CliRunner # noqa: E402
|
||||
|
||||
from cleveragents.cli.commands.resource import app as resource_app # noqa: E402
|
||||
from cleveragents.domain.models.core.resource import ( # noqa: E402
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
|
||||
_runner = CliRunner()
|
||||
|
||||
|
||||
def _mock_resource(
|
||||
resource_id: str = "01HBENCH0000000000RESOURCE",
|
||||
name: str = "local/bench-res",
|
||||
type_name: str = "git-checkout",
|
||||
) -> Resource:
|
||||
return Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
resource_type_name=type_name,
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description="Benchmark resource",
|
||||
properties={"path": "/tmp/bench"},
|
||||
location="/tmp/bench",
|
||||
content_hash=None,
|
||||
sandbox_strategy=None,
|
||||
capabilities=ResourceCapabilities(),
|
||||
created_at=datetime.now(tz=UTC),
|
||||
updated_at=datetime.now(tz=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _mock_tree_node(
|
||||
resource: Resource,
|
||||
children: list[dict[str, object]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"resource": resource,
|
||||
"children": children or [],
|
||||
}
|
||||
|
||||
|
||||
class ResourceTreeSuite:
|
||||
"""Benchmark resource tree command throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up mock service."""
|
||||
root = _mock_resource()
|
||||
child1 = _mock_resource(
|
||||
"01HBENCH0000000000CHILD001", "local/child-1", "fs-directory"
|
||||
)
|
||||
child2 = _mock_resource(
|
||||
"01HBENCH0000000000CHILD002", "local/child-2", "fs-directory"
|
||||
)
|
||||
tree = [
|
||||
_mock_tree_node(
|
||||
root,
|
||||
[
|
||||
_mock_tree_node(child1),
|
||||
_mock_tree_node(child2),
|
||||
],
|
||||
)
|
||||
]
|
||||
self._mock_service = MagicMock()
|
||||
self._mock_service.get_resource_tree.return_value = tree
|
||||
self._mock_service.show_resource.return_value = root
|
||||
self._patcher = patch(
|
||||
"cleveragents.cli.commands.resource._get_registry_service",
|
||||
return_value=self._mock_service,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
def teardown(self) -> None:
|
||||
self._patcher.stop()
|
||||
|
||||
def time_tree_default(self) -> None:
|
||||
"""Benchmark tree with default settings."""
|
||||
_runner.invoke(resource_app, ["tree", "local/bench-res"])
|
||||
|
||||
def time_tree_json(self) -> None:
|
||||
"""Benchmark tree with JSON output."""
|
||||
_runner.invoke(resource_app, ["tree", "local/bench-res", "--format", "json"])
|
||||
|
||||
def time_tree_depth(self) -> None:
|
||||
"""Benchmark tree with depth limit."""
|
||||
_runner.invoke(resource_app, ["tree", "local/bench-res", "--depth", "1"])
|
||||
|
||||
|
||||
class ResourceInspectSuite:
|
||||
"""Benchmark resource inspect command throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up mock service."""
|
||||
res = _mock_resource()
|
||||
tree = [_mock_tree_node(res)]
|
||||
self._mock_service = MagicMock()
|
||||
self._mock_service.show_resource.return_value = res
|
||||
self._mock_service.get_resource_tree.return_value = tree
|
||||
self._patcher = patch(
|
||||
"cleveragents.cli.commands.resource._get_registry_service",
|
||||
return_value=self._mock_service,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
def teardown(self) -> None:
|
||||
self._patcher.stop()
|
||||
|
||||
def time_inspect_default(self) -> None:
|
||||
"""Benchmark inspect with default settings."""
|
||||
_runner.invoke(resource_app, ["inspect", "local/bench-res"])
|
||||
|
||||
def time_inspect_json(self) -> None:
|
||||
"""Benchmark inspect with JSON output."""
|
||||
_runner.invoke(resource_app, ["inspect", "local/bench-res", "--format", "json"])
|
||||
|
||||
def time_inspect_with_tree(self) -> None:
|
||||
"""Benchmark inspect with --tree flag."""
|
||||
_runner.invoke(resource_app, ["inspect", "local/bench-res", "--tree"])
|
||||
|
||||
|
||||
class ResourceLinkSuite:
|
||||
"""Benchmark resource link-child/unlink-child command throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up mock service."""
|
||||
self._mock_service = MagicMock()
|
||||
self._mock_service.link_child.return_value = None
|
||||
self._mock_service.unlink_child.return_value = None
|
||||
self._patcher = patch(
|
||||
"cleveragents.cli.commands.resource._get_registry_service",
|
||||
return_value=self._mock_service,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
def teardown(self) -> None:
|
||||
self._patcher.stop()
|
||||
|
||||
def time_link_child(self) -> None:
|
||||
"""Benchmark link-child command."""
|
||||
_runner.invoke(resource_app, ["link-child", "local/parent", "local/child"])
|
||||
|
||||
def time_unlink_child(self) -> None:
|
||||
"""Benchmark unlink-child command."""
|
||||
_runner.invoke(
|
||||
resource_app, ["unlink-child", "--yes", "local/parent", "local/child"]
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
# Resource CLI Reference
|
||||
|
||||
The `agents resource` command group manages resource types and resource
|
||||
instances in the CleverAgents resource registry.
|
||||
|
||||
## Resource Instance Commands
|
||||
|
||||
### `agents resource add`
|
||||
|
||||
Add a new resource instance of a given type.
|
||||
|
||||
```bash
|
||||
agents resource add git-checkout local/my-repo --path /home/user/repo
|
||||
agents resource add fs-directory local/data --path /data --read-only
|
||||
```
|
||||
|
||||
### `agents resource list`
|
||||
|
||||
List registered resources with optional type filter.
|
||||
|
||||
```bash
|
||||
agents resource list
|
||||
agents resource list --type git-checkout
|
||||
agents resource list --format json
|
||||
```
|
||||
|
||||
### `agents resource show`
|
||||
|
||||
Show details of a specific resource by name or ULID.
|
||||
|
||||
```bash
|
||||
agents resource show local/my-repo
|
||||
agents resource show 01HXYZ1234567890ABCDEFGHIJ
|
||||
```
|
||||
|
||||
### `agents resource remove`
|
||||
|
||||
Remove a resource from the registry.
|
||||
|
||||
```bash
|
||||
agents resource remove local/my-repo
|
||||
agents resource remove --yes local/my-repo
|
||||
```
|
||||
|
||||
### `agents resource tree`
|
||||
|
||||
Display a resource and its children as a tree (DAG). Output is
|
||||
deterministically ordered by resource name.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------------|------------------------------------------|
|
||||
| `--depth N` | Maximum tree depth (-1 for unlimited) |
|
||||
| `--type TYPE`| Filter children by resource type |
|
||||
| `--format` | Output format (json/yaml/plain/table/rich) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Show full tree
|
||||
agents resource tree local/my-repo
|
||||
|
||||
# Limit depth to 2 levels
|
||||
agents resource tree local/my-repo --depth 2
|
||||
|
||||
# Show only fs-directory children
|
||||
agents resource tree local/my-repo --type fs-directory
|
||||
|
||||
# JSON output
|
||||
agents resource tree local/my-repo --format json
|
||||
```
|
||||
|
||||
### `agents resource inspect`
|
||||
|
||||
Show detailed information about a resource. Optionally display
|
||||
the child tree or read file content.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|-----------------|----------------------------------------------|
|
||||
| `--tree` | Also show child tree |
|
||||
| `--file PATH` | Show file content (for git-checkout/fs-mount) |
|
||||
| `--format` | Output format (json/yaml/plain/table/rich) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Basic inspect
|
||||
agents resource inspect local/my-repo
|
||||
|
||||
# Inspect with child tree
|
||||
agents resource inspect local/my-repo --tree
|
||||
|
||||
# Read a file from a git-checkout resource
|
||||
agents resource inspect local/my-repo --file README.md
|
||||
|
||||
# JSON output
|
||||
agents resource inspect local/my-repo --format json
|
||||
```
|
||||
|
||||
### `agents resource link-child`
|
||||
|
||||
Create a DAG edge between two resources (parent -> child). The
|
||||
child's resource type must be allowed by the parent's type
|
||||
constraints. Cycles are rejected.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
agents resource link-child local/my-repo local/my-dir
|
||||
agents resource link-child local/my-repo local/my-dir --format json
|
||||
```
|
||||
|
||||
### `agents resource unlink-child`
|
||||
|
||||
Remove a DAG edge between two resources. Requires confirmation
|
||||
unless `--yes` is passed.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|-----------|---------------------------|
|
||||
| `--yes` | Skip confirmation prompt |
|
||||
| `--format`| Output format |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
agents resource unlink-child local/my-repo local/my-dir
|
||||
agents resource unlink-child --yes local/my-repo local/my-dir
|
||||
```
|
||||
|
||||
## Resource Type Commands
|
||||
|
||||
### `agents resource type add`
|
||||
|
||||
Register a custom resource type from a YAML configuration file.
|
||||
|
||||
```bash
|
||||
agents resource type add --config ./resource-types/my-type.yaml
|
||||
```
|
||||
|
||||
### `agents resource type list`
|
||||
|
||||
List all registered resource types (built-in and custom).
|
||||
|
||||
```bash
|
||||
agents resource type list
|
||||
agents resource type list --format json
|
||||
```
|
||||
|
||||
### `agents resource type show`
|
||||
|
||||
Show details of a registered resource type.
|
||||
|
||||
```bash
|
||||
agents resource type show git-checkout
|
||||
agents resource type show --format json local/my-type
|
||||
```
|
||||
|
||||
### `agents resource type remove`
|
||||
|
||||
Remove a custom resource type. Built-in types cannot be removed.
|
||||
|
||||
```bash
|
||||
agents resource type remove local/my-type
|
||||
agents resource type remove --yes local/my-type
|
||||
```
|
||||
@@ -0,0 +1,198 @@
|
||||
Feature: Resource CLI tree, inspect, link-child, and unlink-child commands
|
||||
As a CleverAgents user
|
||||
I want to view resource trees, inspect resources, and manage DAG edges via the CLI
|
||||
So that I can navigate and manipulate resource hierarchies
|
||||
|
||||
Background:
|
||||
Given a fresh resource tree test registry
|
||||
|
||||
# ---- Resource Tree ----
|
||||
|
||||
Scenario: Tree of a resource with no children
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/root-repo" at "/tmp/root"
|
||||
When I display resource tree for "local/root-repo"
|
||||
Then the tree output should contain "local/root-repo"
|
||||
And the tree output should contain "git-checkout"
|
||||
|
||||
Scenario: Tree of a resource with children shows hierarchy
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/parent-repo" at "/tmp/parent"
|
||||
And a resource tree resource "fs-directory" named "local/child-dir" at "/tmp/child"
|
||||
And resource tree child "local/child-dir" is linked to parent "local/parent-repo"
|
||||
When I display resource tree for "local/parent-repo"
|
||||
Then the tree output should contain "local/parent-repo"
|
||||
And the tree output should contain "local/child-dir"
|
||||
|
||||
Scenario: Tree with depth limit of 1
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/deep-root" at "/tmp/deep"
|
||||
And a resource tree resource "fs-directory" named "local/deep-child" at "/tmp/dc"
|
||||
And a resource tree resource "fs-directory" named "local/deep-grandchild" at "/tmp/dgc"
|
||||
And resource tree child "local/deep-child" is linked to parent "local/deep-root"
|
||||
And resource tree child "local/deep-grandchild" is linked to parent "local/deep-child"
|
||||
When I display resource tree depth-limited to 1 for "local/deep-root"
|
||||
Then the tree output should contain "local/deep-child"
|
||||
And the tree output should not contain "local/deep-grandchild"
|
||||
|
||||
Scenario: Tree with type filter
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/filter-root" at "/tmp/fr"
|
||||
And a resource tree resource "fs-directory" named "local/filter-dir" at "/tmp/fd"
|
||||
And resource tree child "local/filter-dir" is linked to parent "local/filter-root"
|
||||
When I display resource tree type-filtered to "fs-directory" for "local/filter-root"
|
||||
Then the tree output should contain "local/filter-dir"
|
||||
|
||||
Scenario: Tree in JSON format
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/json-tree" at "/tmp/jt"
|
||||
When I display resource tree as "json" for "local/json-tree"
|
||||
Then the tree output should be valid JSON
|
||||
|
||||
Scenario: Tree of non-existent resource fails
|
||||
When I display resource tree for "nonexistent-resource"
|
||||
Then the tree command should fail
|
||||
And the tree output should contain "not found"
|
||||
|
||||
# ---- Deterministic ordering ----
|
||||
|
||||
Scenario: Tree children are sorted by name
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/sort-root" at "/tmp/sr"
|
||||
And a resource tree resource "fs-directory" named "local/z-last" at "/tmp/zl"
|
||||
And a resource tree resource "fs-directory" named "local/a-first" at "/tmp/af"
|
||||
And resource tree child "local/z-last" is linked to parent "local/sort-root"
|
||||
And resource tree child "local/a-first" is linked to parent "local/sort-root"
|
||||
When I display resource tree for "local/sort-root"
|
||||
Then the tree output should show "local/a-first" before "local/z-last"
|
||||
|
||||
# ---- Resource Inspect ----
|
||||
|
||||
Scenario: Inspect a resource shows details
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/inspect-repo" at "/tmp/ir"
|
||||
When I run basic inspect on "local/inspect-repo"
|
||||
Then the tree output should contain "local/inspect-repo"
|
||||
And the tree output should contain "git-checkout"
|
||||
|
||||
Scenario: Inspect with --tree shows children
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/inspect-parent" at "/tmp/ip"
|
||||
And a resource tree resource "fs-directory" named "local/inspect-child" at "/tmp/ic"
|
||||
And resource tree child "local/inspect-child" is linked to parent "local/inspect-parent"
|
||||
When I run inspect with subtree on "local/inspect-parent"
|
||||
Then the tree output should contain "local/inspect-child"
|
||||
|
||||
Scenario: Inspect with --tree and no children
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/no-children" at "/tmp/nc"
|
||||
When I run inspect with subtree on "local/no-children"
|
||||
Then the tree output should contain "no children"
|
||||
|
||||
Scenario: Inspect with --file for existing file
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/file-repo" at a temp directory
|
||||
And a temp file "hello.txt" with content "Hello World"
|
||||
When I run inspect with file "hello.txt" on "local/file-repo"
|
||||
Then the tree output should contain "Hello World"
|
||||
|
||||
Scenario: Inspect with --file for missing file
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/no-file" at "/tmp/nf"
|
||||
When I run inspect with file "nonexistent.txt" on "local/no-file"
|
||||
Then the tree output should contain "not found"
|
||||
|
||||
Scenario: Inspect in JSON format
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/json-inspect" at "/tmp/ji"
|
||||
When I run inspect formatted as "json" on "local/json-inspect"
|
||||
Then the tree output should be valid JSON
|
||||
|
||||
Scenario: Inspect non-existent resource fails
|
||||
When I run basic inspect on "nonexistent-resource"
|
||||
Then the tree command should fail
|
||||
And the tree output should contain "not found"
|
||||
|
||||
# ---- Resource link-child ----
|
||||
|
||||
Scenario: Link child successfully
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/link-parent" at "/tmp/lp"
|
||||
And a resource tree resource "fs-directory" named "local/link-child" at "/tmp/lc"
|
||||
When I link DAG child "local/link-child" to parent "local/link-parent"
|
||||
Then the tree output should contain "Linked"
|
||||
|
||||
Scenario: Link child in JSON format
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/json-parent" at "/tmp/jp"
|
||||
And a resource tree resource "fs-directory" named "local/json-child" at "/tmp/jc"
|
||||
When I link as "json" DAG child "local/json-child" to parent "local/json-parent"
|
||||
Then the tree output should be valid JSON
|
||||
And the tree output should contain "linked"
|
||||
|
||||
Scenario: Link child with non-existent parent fails
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "fs-directory" named "local/orphan" at "/tmp/orph"
|
||||
When I link DAG child "local/orphan" to parent "nonexistent-parent"
|
||||
Then the tree command should fail
|
||||
And the tree output should contain "not found"
|
||||
|
||||
Scenario: Link child with non-existent child fails
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/lonely-parent" at "/tmp/lonely"
|
||||
When I link DAG child "nonexistent-child" to parent "local/lonely-parent"
|
||||
Then the tree command should fail
|
||||
And the tree output should contain "not found"
|
||||
|
||||
Scenario: Duplicate link fails
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/dup-parent" at "/tmp/dp"
|
||||
And a resource tree resource "fs-directory" named "local/dup-child" at "/tmp/dpc"
|
||||
And resource tree child "local/dup-child" is linked to parent "local/dup-parent"
|
||||
When I link DAG child "local/dup-child" to parent "local/dup-parent"
|
||||
Then the tree command should fail
|
||||
And the tree output should contain "already exists"
|
||||
|
||||
Scenario: Cycle detection rejects link
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "fs-directory" named "local/cyc-a" at "/tmp/ca"
|
||||
And a resource tree resource "fs-directory" named "local/cyc-b" at "/tmp/cb"
|
||||
And resource tree child "local/cyc-b" is linked to parent "local/cyc-a"
|
||||
When I link DAG child "local/cyc-a" to parent "local/cyc-b"
|
||||
Then the tree command should fail
|
||||
And the tree output should contain "ycle"
|
||||
|
||||
# ---- Resource unlink-child ----
|
||||
|
||||
Scenario: Unlink child successfully
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/unlink-parent" at "/tmp/up"
|
||||
And a resource tree resource "fs-directory" named "local/unlink-child" at "/tmp/uc"
|
||||
And resource tree child "local/unlink-child" is linked to parent "local/unlink-parent"
|
||||
When I unlink DAG child "local/unlink-child" from parent "local/unlink-parent" confirmed
|
||||
Then the tree output should contain "Unlinked"
|
||||
|
||||
Scenario: Unlink child in JSON format
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/json-unlp" at "/tmp/jul"
|
||||
And a resource tree resource "fs-directory" named "local/json-unlc" at "/tmp/juc"
|
||||
And resource tree child "local/json-unlc" is linked to parent "local/json-unlp"
|
||||
When I unlink as "json" DAG child "local/json-unlc" from parent "local/json-unlp" confirmed
|
||||
Then the tree output should be valid JSON
|
||||
And the tree output should contain "unlinked"
|
||||
|
||||
Scenario: Unlink non-existent link fails
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/nolink-parent" at "/tmp/nlp"
|
||||
And a resource tree resource "fs-directory" named "local/nolink-child" at "/tmp/nlc"
|
||||
When I unlink DAG child "local/nolink-child" from parent "local/nolink-parent" confirmed
|
||||
Then the tree command should fail
|
||||
And the tree output should contain "not found"
|
||||
|
||||
Scenario: Unlink aborted without yes flag
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a resource tree resource "git-checkout" named "local/abort-parent" at "/tmp/ap"
|
||||
And a resource tree resource "fs-directory" named "local/abort-child" at "/tmp/ac"
|
||||
And resource tree child "local/abort-child" is linked to parent "local/abort-parent"
|
||||
When I unlink DAG child "local/abort-child" from parent "local/abort-parent" declined
|
||||
Then the tree command should fail
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Step definitions for resource_cli_tree.feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from rich.console import Console
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.application.services.resource_registry_service import (
|
||||
ResourceRegistryService,
|
||||
)
|
||||
from cleveragents.infrastructure.database.models import Base
|
||||
|
||||
|
||||
def _make_tree_service(context: Context) -> ResourceRegistryService:
|
||||
"""Create or return a cached ResourceRegistryService for tree tests."""
|
||||
if not hasattr(context, "tree_cli_service"):
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _fk(conn: Any, _rec: Any) -> None:
|
||||
conn.cursor().execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
context.tree_cli_service = ResourceRegistryService(session_factory=factory)
|
||||
return context.tree_cli_service
|
||||
|
||||
|
||||
def _capture_tree_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]:
|
||||
"""Run a CLI function capturing its console output and success status."""
|
||||
buf = StringIO()
|
||||
console = Console(file=buf, width=200, no_color=True)
|
||||
|
||||
import cleveragents.cli.commands.resource as resource_mod
|
||||
|
||||
orig_console = resource_mod.console
|
||||
resource_mod.console = console
|
||||
|
||||
failed = False
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
except SystemExit:
|
||||
failed = True
|
||||
except Exception:
|
||||
failed = True
|
||||
finally:
|
||||
resource_mod.console = orig_console
|
||||
|
||||
return buf.getvalue(), failed
|
||||
|
||||
|
||||
def _patch_tree_service(context: Context) -> Any:
|
||||
"""Monkey-patch the container to return our in-memory service."""
|
||||
import cleveragents.cli.commands.resource as resource_mod
|
||||
|
||||
orig_fn = resource_mod._get_registry_service
|
||||
|
||||
def _mock_get() -> ResourceRegistryService:
|
||||
return _make_tree_service(context)
|
||||
|
||||
resource_mod._get_registry_service = _mock_get
|
||||
return orig_fn
|
||||
|
||||
|
||||
def _unpatch_tree_service(orig_fn: Any) -> None:
|
||||
"""Restore original service getter."""
|
||||
import cleveragents.cli.commands.resource as resource_mod
|
||||
|
||||
resource_mod._get_registry_service = orig_fn
|
||||
|
||||
|
||||
# ---- Background ----
|
||||
|
||||
|
||||
@given("a fresh resource tree test registry")
|
||||
def step_fresh_tree_registry(context: Context) -> None:
|
||||
"""Set up a fresh in-memory database for tree tests."""
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _fk(conn: Any, _rec: Any) -> None:
|
||||
conn.cursor().execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
context.tree_cli_service = ResourceRegistryService(session_factory=factory)
|
||||
context.tree_cli_output = ""
|
||||
context.tree_cli_failed = False
|
||||
context.tree_temp_dir = None
|
||||
|
||||
|
||||
@given("resource tree built-in types are bootstrapped")
|
||||
def step_tree_bootstrap(context: Context) -> None:
|
||||
"""Bootstrap built-in resource types for tree tests."""
|
||||
service = _make_tree_service(context)
|
||||
service.bootstrap_builtin_types()
|
||||
|
||||
|
||||
@given('a resource tree resource "{type_name}" named "{name}" at "{path}"')
|
||||
def step_tree_add_resource(
|
||||
context: Context, type_name: str, name: str, path: str
|
||||
) -> None:
|
||||
"""Add a resource for tree tests."""
|
||||
service = _make_tree_service(context)
|
||||
service.register_resource(
|
||||
type_name=type_name,
|
||||
name=name,
|
||||
location=path,
|
||||
description=f"Test resource {name}",
|
||||
read_only=False,
|
||||
properties={"path": path},
|
||||
)
|
||||
|
||||
|
||||
@given('a resource tree resource "{type_name}" named "{name}" at a temp directory')
|
||||
def step_tree_add_resource_temp(context: Context, type_name: str, name: str) -> None:
|
||||
"""Add a resource at a temp directory."""
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
context.tree_temp_dir = tmp_dir
|
||||
service = _make_tree_service(context)
|
||||
service.register_resource(
|
||||
type_name=type_name,
|
||||
name=name,
|
||||
location=tmp_dir,
|
||||
description=f"Test resource {name}",
|
||||
read_only=False,
|
||||
properties={"path": tmp_dir},
|
||||
)
|
||||
|
||||
|
||||
@given('a temp file "{filename}" with content "{content}"')
|
||||
def step_tree_create_temp_file(context: Context, filename: str, content: str) -> None:
|
||||
"""Create a temp file in the temp directory."""
|
||||
tmp_dir = context.tree_temp_dir
|
||||
filepath = Path(tmp_dir) / filename
|
||||
filepath.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
@given('resource tree child "{child}" is linked to parent "{parent}"')
|
||||
def step_tree_link_child(context: Context, child: str, parent: str) -> None:
|
||||
"""Link a child to a parent."""
|
||||
service = _make_tree_service(context)
|
||||
service.link_child(parent, child)
|
||||
|
||||
|
||||
# ---- Tree commands ----
|
||||
|
||||
|
||||
@when('I display resource tree for "{name}"')
|
||||
def step_run_tree(context: Context, name: str) -> None:
|
||||
"""Run resource tree command."""
|
||||
from cleveragents.cli.commands.resource import resource_tree
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_tree, resource=name, depth=-1, type_filter=None, fmt="rich"
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
@when('I display resource tree depth-limited to {depth:d} for "{name}"')
|
||||
def step_run_tree_depth(context: Context, depth: int, name: str) -> None:
|
||||
"""Run resource tree with depth limit."""
|
||||
from cleveragents.cli.commands.resource import resource_tree
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_tree, resource=name, depth=depth, type_filter=None, fmt="rich"
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
@when('I display resource tree type-filtered to "{type_filter}" for "{name}"')
|
||||
def step_run_tree_type(context: Context, type_filter: str, name: str) -> None:
|
||||
"""Run resource tree with type filter."""
|
||||
from cleveragents.cli.commands.resource import resource_tree
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_tree, resource=name, depth=-1, type_filter=type_filter, fmt="rich"
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
@when('I display resource tree as "{fmt}" for "{name}"')
|
||||
def step_run_tree_fmt(context: Context, fmt: str, name: str) -> None:
|
||||
"""Run resource tree with format."""
|
||||
from cleveragents.cli.commands.resource import resource_tree
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_tree, resource=name, depth=-1, type_filter=None, fmt=fmt
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
# ---- Inspect commands ----
|
||||
|
||||
|
||||
@when('I run basic inspect on "{name}"')
|
||||
def step_run_inspect(context: Context, name: str) -> None:
|
||||
"""Run resource inspect command."""
|
||||
from cleveragents.cli.commands.resource import resource_inspect
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_inspect, resource=name, tree=False, file=None, fmt="rich"
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
@when('I run inspect with subtree on "{name}"')
|
||||
def step_run_inspect_tree(context: Context, name: str) -> None:
|
||||
"""Run resource inspect with --tree."""
|
||||
from cleveragents.cli.commands.resource import resource_inspect
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_inspect, resource=name, tree=True, file=None, fmt="rich"
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
@when('I run inspect with file "{filepath}" on "{name}"')
|
||||
def step_run_inspect_file(context: Context, filepath: str, name: str) -> None:
|
||||
"""Run resource inspect with --file."""
|
||||
from cleveragents.cli.commands.resource import resource_inspect
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_inspect, resource=name, tree=False, file=filepath, fmt="rich"
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
@when('I run inspect formatted as "{fmt}" on "{name}"')
|
||||
def step_run_inspect_fmt(context: Context, fmt: str, name: str) -> None:
|
||||
"""Run resource inspect with format."""
|
||||
from cleveragents.cli.commands.resource import resource_inspect
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_inspect, resource=name, tree=False, file=None, fmt=fmt
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
# ---- Link-child commands ----
|
||||
|
||||
|
||||
@when('I link DAG child "{child}" to parent "{parent}"')
|
||||
def step_run_link_child(context: Context, child: str, parent: str) -> None:
|
||||
"""Run resource link-child command."""
|
||||
from cleveragents.cli.commands.resource import resource_link_child
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_link_child, parent=parent, child=child, fmt="rich"
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
@when('I link as "{fmt}" DAG child "{child}" to parent "{parent}"')
|
||||
def step_run_link_child_fmt(
|
||||
context: Context, fmt: str, child: str, parent: str
|
||||
) -> None:
|
||||
"""Run resource link-child with format."""
|
||||
from cleveragents.cli.commands.resource import resource_link_child
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_link_child, parent=parent, child=child, fmt=fmt
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
# ---- Unlink-child commands ----
|
||||
|
||||
|
||||
@when('I unlink DAG child "{child}" from parent "{parent}" confirmed')
|
||||
def step_run_unlink_child_yes(context: Context, child: str, parent: str) -> None:
|
||||
"""Run resource unlink-child with --yes."""
|
||||
from cleveragents.cli.commands.resource import resource_unlink_child
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_unlink_child, parent=parent, child=child, yes=True, fmt="rich"
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
@when('I unlink as "{fmt}" DAG child "{child}" from parent "{parent}" confirmed')
|
||||
def step_run_unlink_child_yes_fmt(
|
||||
context: Context, fmt: str, child: str, parent: str
|
||||
) -> None:
|
||||
"""Run resource unlink-child with --yes and format."""
|
||||
from cleveragents.cli.commands.resource import resource_unlink_child
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
output, failed = _capture_tree_output(
|
||||
resource_unlink_child, parent=parent, child=child, yes=True, fmt=fmt
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
@when('I unlink DAG child "{child}" from parent "{parent}" declined')
|
||||
def step_run_unlink_child_no_yes(context: Context, child: str, parent: str) -> None:
|
||||
"""Run resource unlink-child without --yes (confirmation denied)."""
|
||||
from cleveragents.cli.commands.resource import resource_unlink_child
|
||||
|
||||
orig = _patch_tree_service(context)
|
||||
try:
|
||||
with patch(
|
||||
"cleveragents.cli.commands.resource.typer.confirm",
|
||||
return_value=False,
|
||||
):
|
||||
output, failed = _capture_tree_output(
|
||||
resource_unlink_child,
|
||||
parent=parent,
|
||||
child=child,
|
||||
yes=False,
|
||||
fmt="rich",
|
||||
)
|
||||
context.tree_cli_output = output
|
||||
context.tree_cli_failed = failed
|
||||
finally:
|
||||
_unpatch_tree_service(orig)
|
||||
|
||||
|
||||
# ---- Assertions ----
|
||||
|
||||
|
||||
@then('the tree output should contain "{text}"')
|
||||
def step_tree_output_contains(context: Context, text: str) -> None:
|
||||
"""Check that CLI output contains the expected text."""
|
||||
output = context.tree_cli_output
|
||||
assert text.lower() in output.lower(), (
|
||||
f"Expected '{text}' in output, got:\n{output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the tree output should not contain "{text}"')
|
||||
def step_tree_output_not_contains(context: Context, text: str) -> None:
|
||||
"""Check that CLI output does not contain the text."""
|
||||
output = context.tree_cli_output
|
||||
assert text.lower() not in output.lower(), (
|
||||
f"Did not expect '{text}' in output, got:\n{output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tree command should fail")
|
||||
def step_tree_command_failed(context: Context) -> None:
|
||||
"""Check that the command failed."""
|
||||
assert context.tree_cli_failed, "Expected command to fail but it succeeded"
|
||||
|
||||
|
||||
@then("the tree output should be valid JSON")
|
||||
def step_tree_output_valid_json(context: Context) -> None:
|
||||
"""Check that the output is valid JSON."""
|
||||
output = context.tree_cli_output.strip()
|
||||
try:
|
||||
json.loads(output)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AssertionError(f"Output is not valid JSON: {exc}\n{output}") from exc
|
||||
|
||||
|
||||
@then('the tree output should show "{first}" before "{second}"')
|
||||
def step_tree_output_order(context: Context, first: str, second: str) -> None:
|
||||
"""Check that first appears before second in output."""
|
||||
output = context.tree_cli_output.lower()
|
||||
first_pos = output.find(first.lower())
|
||||
second_pos = output.find(second.lower())
|
||||
assert first_pos >= 0, f"'{first}' not found in output:\n{context.tree_cli_output}"
|
||||
assert second_pos >= 0, (
|
||||
f"'{second}' not found in output:\n{context.tree_cli_output}"
|
||||
)
|
||||
assert first_pos < second_pos, (
|
||||
f"'{first}' (pos {first_pos}) should appear before "
|
||||
f"'{second}' (pos {second_pos}) in output:\n{context.tree_cli_output}"
|
||||
)
|
||||
+18
-18
@@ -2575,24 +2575,24 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
|
||||
- [X] Git [Jeff]: `git branch -d feature/m2-resource-dag`
|
||||
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: B1.cli | Branch: feature/m2-resource-cli-extensions | Planned: Day 13 | Expected: Day 18) - Commit message: "feat(cli): add resource tree and inspect commands"**
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git pull origin master`
|
||||
- [ ] Git [Jeff]: `git checkout -b feature/m2-resource-cli-extensions`
|
||||
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master`
|
||||
- [ ] Code [Jeff]: Implement `agents resource tree` with `--depth` and `--type` filters and deterministic ordering.
|
||||
- [ ] Code [Jeff]: Implement `agents resource inspect` with `--tree` and `--file` options for git-checkout/fs-mount.
|
||||
- [ ] Code [Jeff]: Implement `agents resource link-child`/`unlink-child` to manage DAG edges.
|
||||
- [ ] Docs [Jeff]: Update CLI reference with resource tree/inspect/link examples.
|
||||
- [ ] Tests (Behave) [Jeff]: Add scenarios for tree output ordering and link-child error cases.
|
||||
- [ ] Tests (Robot) [Jeff]: Add Robot test that runs resource tree/inspect on a git-checkout resource.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/resource_cli_tree_bench.py` for CLI overhead baseline.
|
||||
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [ ] Git [Jeff]: `git commit -m "feat(cli): add resource tree and inspect commands"`
|
||||
- [ ] Git [Jeff]: `git push -u origin feature/m2-resource-cli-extensions`
|
||||
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m2-resource-cli-extensions` to `master` with description "Add resource tree/inspect/link CLI commands with tests/docs.".
|
||||
- [X] **COMMIT (Owner: Jeff | Group: B1.cli | Branch: feature/m2-resource-cli-extensions | Planned: Day 13 | Done: Day 18, February 18, 2026) - Commit message: "feat(cli): add resource tree and inspect commands"**
|
||||
- [X] Git [Jeff]: `git checkout master`
|
||||
- [X] Git [Jeff]: `git pull origin master`
|
||||
- [X] Git [Jeff]: `git checkout -b feature/m2-resource-cli-extensions`
|
||||
- [X] Git [Jeff]: `git fetch origin && git merge origin/master`
|
||||
- [X] Code [Jeff]: Implement `agents resource tree` with `--depth` and `--type` filters and deterministic ordering.
|
||||
- [X] Code [Jeff]: Implement `agents resource inspect` with `--tree` and `--file` options for git-checkout/fs-mount.
|
||||
- [X] Code [Jeff]: Implement `agents resource link-child`/`unlink-child` to manage DAG edges.
|
||||
- [X] Docs [Jeff]: Update CLI reference with resource tree/inspect/link examples.
|
||||
- [X] Tests (Behave) [Jeff]: Add scenarios for tree output ordering and link-child error cases.
|
||||
- [X] Tests (Robot) [Jeff]: Add Robot test that runs resource tree/inspect on a git-checkout resource.
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/resource_cli_tree_bench.py` for CLI overhead baseline.
|
||||
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [X] Git [Jeff]: `git commit -m "feat(cli): add resource tree and inspect commands"`
|
||||
- [X] Git [Jeff]: `git push -u origin feature/m2-resource-cli-extensions`
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m2-resource-cli-extensions` to `master` with description "Add resource tree/inspect/link CLI commands with tests/docs.".
|
||||
|
||||
**Parallel Group B2: Project Context Policies (M3)**
|
||||
**PARALLEL SUBTRACK B2.model [Jeff]**: Project context policy model + persistence fields
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
*** Settings ***
|
||||
Documentation Resource CLI Tree and Inspect Integration Tests
|
||||
Library Process
|
||||
Library OperatingSystem
|
||||
|
||||
*** Variables ***
|
||||
${PYTHON} python
|
||||
|
||||
*** Test Cases ***
|
||||
Resource Tree Shows Root Resource
|
||||
[Documentation] Run resource tree on a resource with no children
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... import json
|
||||
... from datetime import datetime, UTC
|
||||
... from sqlalchemy import create_engine, event
|
||||
... 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:")
|
||||
... @event.listens_for(engine, "connect")
|
||||
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
|
||||
... Base.metadata.create_all(engine)
|
||||
... factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
... svc = ResourceRegistryService(session_factory=factory)
|
||||
... svc.bootstrap_builtin_types()
|
||||
... svc.register_resource(type_name="git-checkout", name="local/tree-test", location="/tmp/tt", properties={"path": "/tmp/tt"})
|
||||
... tree = svc.get_resource_tree("local/tree-test")
|
||||
... assert len(tree) == 1
|
||||
... node = tree[0]
|
||||
... assert node["resource"].name == "local/tree-test"
|
||||
... assert node["children"] == []
|
||||
... print("Resource tree root test passed")
|
||||
${result}= Run Process ${PYTHON} -c ${script}
|
||||
Should Be Equal As Integers ${result.rc} 0 Tree root test failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} Resource tree root test passed
|
||||
|
||||
Resource Inspect Shows Details
|
||||
[Documentation] Inspect a resource and verify basic details
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... import json
|
||||
... from datetime import datetime, UTC
|
||||
... from sqlalchemy import create_engine, event
|
||||
... 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:")
|
||||
... @event.listens_for(engine, "connect")
|
||||
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
|
||||
... Base.metadata.create_all(engine)
|
||||
... factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
... svc = ResourceRegistryService(session_factory=factory)
|
||||
... svc.bootstrap_builtin_types()
|
||||
... res = svc.register_resource(type_name="git-checkout", name="local/inspect-test", location="/tmp/it", properties={"path": "/tmp/it"})
|
||||
... shown = svc.show_resource("local/inspect-test")
|
||||
... assert shown.name == "local/inspect-test"
|
||||
... assert shown.resource_type_name == "git-checkout"
|
||||
... assert shown.location == "/tmp/it"
|
||||
... print("Resource inspect test passed")
|
||||
${result}= Run Process ${PYTHON} -c ${script}
|
||||
Should Be Equal As Integers ${result.rc} 0 Inspect test failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} Resource inspect test passed
|
||||
|
||||
Link And Unlink Child Via Service
|
||||
[Documentation] Link a child via service and then unlink it
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... import json
|
||||
... from datetime import datetime, UTC
|
||||
... from sqlalchemy import create_engine, event
|
||||
... 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:")
|
||||
... @event.listens_for(engine, "connect")
|
||||
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
|
||||
... Base.metadata.create_all(engine)
|
||||
... factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
... svc = ResourceRegistryService(session_factory=factory)
|
||||
... svc.bootstrap_builtin_types()
|
||||
... svc.register_resource(type_name="git-checkout", name="local/link-p", location="/tmp/lp", properties={"path": "/tmp/lp"})
|
||||
... svc.register_resource(type_name="fs-directory", name="local/link-c", location="/tmp/lc", properties={"path": "/tmp/lc"})
|
||||
... svc.link_child("local/link-p", "local/link-c")
|
||||
... children = svc.get_children("local/link-p")
|
||||
... assert len(children) == 1
|
||||
... assert children[0].name == "local/link-c"
|
||||
... svc.unlink_child("local/link-p", "local/link-c")
|
||||
... children = svc.get_children("local/link-p")
|
||||
... assert len(children) == 0
|
||||
... print("Link and unlink child passed")
|
||||
${result}= Run Process ${PYTHON} -c ${script}
|
||||
Should Be Equal As Integers ${result.rc} 0 Link/unlink test failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} Link and unlink child passed
|
||||
|
||||
Resource Tree With Children Shows Hierarchy
|
||||
[Documentation] Resource tree with linked children shows hierarchy
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... import json
|
||||
... from datetime import datetime, UTC
|
||||
... from sqlalchemy import create_engine, event
|
||||
... 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:")
|
||||
... @event.listens_for(engine, "connect")
|
||||
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
|
||||
... Base.metadata.create_all(engine)
|
||||
... factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
... svc = ResourceRegistryService(session_factory=factory)
|
||||
... svc.bootstrap_builtin_types()
|
||||
... svc.register_resource(type_name="git-checkout", name="local/tree-parent", location="/tmp/tp", properties={"path": "/tmp/tp"})
|
||||
... svc.register_resource(type_name="fs-directory", name="local/tree-child", location="/tmp/tc", properties={"path": "/tmp/tc"})
|
||||
... svc.link_child("local/tree-parent", "local/tree-child")
|
||||
... tree = svc.get_resource_tree("local/tree-parent")
|
||||
... assert len(tree) == 1
|
||||
... root = tree[0]
|
||||
... assert root["resource"].name == "local/tree-parent"
|
||||
... assert len(root["children"]) == 1
|
||||
... child_node = root["children"][0]
|
||||
... assert child_node["resource"].name == "local/tree-child"
|
||||
... print("Tree with children test passed")
|
||||
${result}= Run Process ${PYTHON} -c ${script}
|
||||
Should Be Equal As Integers ${result.rc} 0 Tree children test failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} Tree with children test passed
|
||||
|
||||
*** Keywords ***
|
||||
@@ -51,6 +51,7 @@ from cleveragents.domain.models.core.resource_type import (
|
||||
SandboxStrategy,
|
||||
)
|
||||
from cleveragents.infrastructure.database.models import (
|
||||
ResourceLinkModel,
|
||||
ResourceModel,
|
||||
ResourceTypeModel,
|
||||
)
|
||||
@@ -429,12 +430,290 @@ class ResourceRegistryService:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# -- DAG operations ------------------------------------------------------
|
||||
|
||||
def link_child(self, parent_name_or_id: str, child_name_or_id: str) -> None:
|
||||
"""Link a child resource to a parent in the DAG.
|
||||
|
||||
Resolves resource names or ULIDs and delegates to the
|
||||
repository's ``link_child`` method with full validation
|
||||
(type compatibility, cycle detection).
|
||||
|
||||
Args:
|
||||
parent_name_or_id: Parent resource name or ULID.
|
||||
child_name_or_id: Child resource name or ULID.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If either resource is not found.
|
||||
ValidationError: If linking fails (cycle, type mismatch, duplicate).
|
||||
"""
|
||||
parent = self.show_resource(parent_name_or_id)
|
||||
child = self.show_resource(child_name_or_id)
|
||||
|
||||
session = self._session()
|
||||
try:
|
||||
# Check type compatibility
|
||||
parent_type_row = (
|
||||
session.query(ResourceTypeModel)
|
||||
.filter_by(name=parent.resource_type_name)
|
||||
.first()
|
||||
)
|
||||
if parent_type_row is not None:
|
||||
allowed_raw = getattr(parent_type_row, "allowed_child_types_json", None)
|
||||
allowed_children: list[str] = (
|
||||
json.loads(str(allowed_raw)) if allowed_raw else []
|
||||
)
|
||||
if (
|
||||
allowed_children
|
||||
and child.resource_type_name not in allowed_children
|
||||
):
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"Type '{child.resource_type_name}' is not allowed "
|
||||
f"as a child of '{parent.resource_type_name}'"
|
||||
),
|
||||
details={
|
||||
"parent_type": parent.resource_type_name,
|
||||
"child_type": child.resource_type_name,
|
||||
},
|
||||
)
|
||||
|
||||
if parent.resource_id == child.resource_id:
|
||||
raise ValidationError(
|
||||
message="Cannot link a resource to itself",
|
||||
details={
|
||||
"parent_id": parent.resource_id,
|
||||
"child_id": child.resource_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Check for duplicate link
|
||||
existing = (
|
||||
session.query(ResourceLinkModel)
|
||||
.filter_by(
|
||||
parent_id=parent.resource_id,
|
||||
child_id=child.resource_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"Link already exists: "
|
||||
f"{parent.resource_id} -> {child.resource_id}"
|
||||
),
|
||||
details={
|
||||
"parent_id": parent.resource_id,
|
||||
"child_id": child.resource_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Cycle detection: walk ancestors of parent
|
||||
ancestors = _get_ancestors(session, parent.resource_id)
|
||||
if child.resource_id in ancestors:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"Cycle detected: linking {child.resource_id} "
|
||||
f"as child of {parent.resource_id} would "
|
||||
f"create a cycle"
|
||||
),
|
||||
details={
|
||||
"parent_id": parent.resource_id,
|
||||
"child_id": child.resource_id,
|
||||
},
|
||||
)
|
||||
|
||||
link = ResourceLinkModel(
|
||||
parent_id=parent.resource_id,
|
||||
child_id=child.resource_id,
|
||||
created_at=datetime.now(tz=UTC).isoformat(),
|
||||
)
|
||||
session.add(link)
|
||||
session.flush()
|
||||
session.commit()
|
||||
except (NotFoundError, ValidationError):
|
||||
session.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def unlink_child(self, parent_name_or_id: str, child_name_or_id: str) -> None:
|
||||
"""Remove a parent-child link from the DAG.
|
||||
|
||||
Args:
|
||||
parent_name_or_id: Parent resource name or ULID.
|
||||
child_name_or_id: Child resource name or ULID.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If either resource or the link is not found.
|
||||
"""
|
||||
parent = self.show_resource(parent_name_or_id)
|
||||
child = self.show_resource(child_name_or_id)
|
||||
|
||||
session = self._session()
|
||||
try:
|
||||
link = (
|
||||
session.query(ResourceLinkModel)
|
||||
.filter_by(
|
||||
parent_id=parent.resource_id,
|
||||
child_id=child.resource_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if link is None:
|
||||
raise NotFoundError(
|
||||
resource_type="resource_link",
|
||||
resource_id=(f"{parent.resource_id} -> {child.resource_id}"),
|
||||
)
|
||||
session.delete(link)
|
||||
session.flush()
|
||||
session.commit()
|
||||
except NotFoundError:
|
||||
session.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_children(self, name_or_id: str) -> list[Resource]:
|
||||
"""Get direct children of a resource.
|
||||
|
||||
Args:
|
||||
name_or_id: Resource name or ULID.
|
||||
|
||||
Returns:
|
||||
List of child ``Resource`` domain objects, sorted by name.
|
||||
"""
|
||||
resource = self.show_resource(name_or_id)
|
||||
session = self._session()
|
||||
try:
|
||||
links = (
|
||||
session.query(ResourceLinkModel)
|
||||
.filter_by(parent_id=resource.resource_id)
|
||||
.all()
|
||||
)
|
||||
children: list[Resource] = []
|
||||
for link in links:
|
||||
child_row = (
|
||||
session.query(ResourceModel)
|
||||
.filter_by(resource_id=str(link.child_id))
|
||||
.first()
|
||||
)
|
||||
if child_row is not None:
|
||||
children.append(_db_resource_to_domain(child_row))
|
||||
# Deterministic ordering: sort by name, then resource_id
|
||||
children.sort(key=lambda r: (r.name or "", r.resource_id))
|
||||
return children
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_resource_tree(
|
||||
self,
|
||||
name_or_id: str,
|
||||
depth: int = -1,
|
||||
type_filter: str | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Get resource tree as nested dicts.
|
||||
|
||||
Each node is ``{"resource": Resource, "children": [...]}``.
|
||||
|
||||
Args:
|
||||
name_or_id: Root resource name or ULID.
|
||||
depth: Maximum depth (-1 for unlimited).
|
||||
type_filter: Only include resources of this type.
|
||||
|
||||
Returns:
|
||||
A list with a single root node dict.
|
||||
"""
|
||||
root = self.show_resource(name_or_id)
|
||||
return [self._build_tree_node(root, depth, type_filter, set())]
|
||||
|
||||
def _build_tree_node(
|
||||
self,
|
||||
resource: Resource,
|
||||
depth: int,
|
||||
type_filter: str | None,
|
||||
visited: set[str],
|
||||
) -> dict[str, object]:
|
||||
"""Recursively build a tree node.
|
||||
|
||||
Args:
|
||||
resource: Current resource.
|
||||
depth: Remaining depth (-1 for unlimited).
|
||||
type_filter: Type filter string or None.
|
||||
visited: Set of visited resource IDs (cycle protection).
|
||||
|
||||
Returns:
|
||||
Dict with ``resource`` and ``children`` keys.
|
||||
"""
|
||||
visited = visited | {resource.resource_id}
|
||||
children_nodes: list[dict[str, object]] = []
|
||||
|
||||
if depth != 0:
|
||||
next_depth = depth - 1 if depth > 0 else -1
|
||||
session = self._session()
|
||||
try:
|
||||
links = (
|
||||
session.query(ResourceLinkModel)
|
||||
.filter_by(parent_id=resource.resource_id)
|
||||
.all()
|
||||
)
|
||||
child_resources: list[Resource] = []
|
||||
for link in links:
|
||||
child_row = (
|
||||
session.query(ResourceModel)
|
||||
.filter_by(resource_id=str(link.child_id))
|
||||
.first()
|
||||
)
|
||||
if child_row is not None:
|
||||
child_res = _db_resource_to_domain(child_row)
|
||||
if type_filter and child_res.resource_type_name != type_filter:
|
||||
continue
|
||||
if child_res.resource_id not in visited:
|
||||
child_resources.append(child_res)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Deterministic ordering
|
||||
child_resources.sort(key=lambda r: (r.name or "", r.resource_id))
|
||||
for child in child_resources:
|
||||
children_nodes.append(
|
||||
self._build_tree_node(child, next_depth, type_filter, visited)
|
||||
)
|
||||
|
||||
return {"resource": resource, "children": children_nodes}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_ancestors(session: Any, resource_id: str) -> set[str]:
|
||||
"""Walk parent links to collect all ancestor resource IDs.
|
||||
|
||||
Uses BFS to traverse the DAG upward from ``resource_id``.
|
||||
"""
|
||||
ancestors: set[str] = set()
|
||||
queue = [resource_id]
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
parent_links = (
|
||||
session.query(ResourceLinkModel).filter_by(child_id=current).all()
|
||||
)
|
||||
for link in parent_links:
|
||||
pid = str(link.parent_id)
|
||||
if pid not in ancestors:
|
||||
ancestors.add(pid)
|
||||
queue.append(pid)
|
||||
return ancestors
|
||||
|
||||
|
||||
def _spec_to_db(spec: ResourceTypeSpec, source: str) -> ResourceTypeModel:
|
||||
"""Convert a ResourceTypeSpec domain object to a DB model."""
|
||||
now_iso = datetime.now(tz=UTC).isoformat()
|
||||
|
||||
@@ -14,12 +14,16 @@ instances in the CleverAgents resource registry.
|
||||
|
||||
## Resource Instance Commands
|
||||
|
||||
| Command | Description |
|
||||
|----------------------------|-------------------------------------------|
|
||||
| ``agents resource add`` | Add a resource instance of a given type |
|
||||
| ``agents resource list`` | List resources with optional type filter |
|
||||
| ``agents resource show`` | Show resource details (by name or ULID) |
|
||||
| ``agents resource remove`` | Remove a resource from the registry |
|
||||
| Command | Description |
|
||||
|----------------------------------|-------------------------------------------|
|
||||
| ``agents resource add`` | Add a resource instance of a given type |
|
||||
| ``agents resource list`` | List resources with optional type filter |
|
||||
| ``agents resource show`` | Show resource details (by name or ULID) |
|
||||
| ``agents resource remove`` | Remove a resource from the registry |
|
||||
| ``agents resource tree`` | Display resource DAG as a tree |
|
||||
| ``agents resource inspect`` | Show detailed resource info |
|
||||
| ``agents resource link-child`` | Create DAG edge between resources |
|
||||
| ``agents resource unlink-child`` | Remove DAG edge between resources |
|
||||
|
||||
## Example Usage
|
||||
|
||||
@@ -32,9 +36,19 @@ agents resource list
|
||||
|
||||
# Show details
|
||||
agents resource show local/my-repo
|
||||
|
||||
# Display resource tree
|
||||
agents resource tree local/my-repo --depth 3
|
||||
|
||||
# Inspect a resource
|
||||
agents resource inspect local/my-repo --tree
|
||||
|
||||
# Link resources in DAG
|
||||
agents resource link-child local/parent local/child
|
||||
agents resource unlink-child local/parent local/child
|
||||
```
|
||||
|
||||
Based on ``implementation_plan.md`` -- Task B0.cli.resources.
|
||||
Based on ``implementation_plan.md`` -- Tasks B0.cli.resources, B1.cli.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -602,6 +616,359 @@ def resource_show(
|
||||
raise typer.Abort() from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource tree command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.command("tree")
|
||||
def resource_tree(
|
||||
resource: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help="Resource name or ULID to display tree for",
|
||||
),
|
||||
],
|
||||
depth: Annotated[
|
||||
int,
|
||||
typer.Option("--depth", "-d", help="Maximum tree depth (-1 for unlimited)"),
|
||||
] = -1,
|
||||
type_filter: Annotated[
|
||||
str | None,
|
||||
typer.Option("--type", "-t", help="Filter children by resource type"),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Display a resource and its children as a tree (DAG).
|
||||
|
||||
Shows the resource hierarchy with optional depth limit and type filter.
|
||||
Output is deterministically ordered by resource name.
|
||||
|
||||
Examples:
|
||||
agents resource tree local/my-repo
|
||||
agents resource tree local/my-repo --depth 2
|
||||
agents resource tree local/my-repo --type fs-directory
|
||||
agents resource tree local/my-repo --format json
|
||||
"""
|
||||
try:
|
||||
service = _get_registry_service()
|
||||
tree = service.get_resource_tree(resource, depth=depth, type_filter=type_filter)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = _tree_to_dict(tree)
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
_print_tree_rich(tree, indent=0)
|
||||
|
||||
except NotFoundError as exc:
|
||||
console.print(f"[red]Resource not found:[/red] {resource}")
|
||||
raise typer.Abort() from exc
|
||||
except CleverAgentsError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc.message}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
|
||||
def _tree_to_dict(
|
||||
nodes: list[dict[str, object]],
|
||||
) -> list[dict[str, object]]:
|
||||
"""Convert tree nodes to serialisable dicts."""
|
||||
result: list[dict[str, object]] = []
|
||||
for node in nodes:
|
||||
res = node["resource"]
|
||||
children_raw = node.get("children", [])
|
||||
children_list: list[dict[str, object]] = []
|
||||
if isinstance(children_raw, list):
|
||||
children_list = children_raw
|
||||
child_dicts = _tree_to_dict(children_list)
|
||||
entry: dict[str, object] = _resource_dict(res)
|
||||
entry["children"] = child_dicts
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def _print_tree_rich(
|
||||
nodes: list[dict[str, object]],
|
||||
indent: int,
|
||||
) -> None:
|
||||
"""Render tree nodes with rich formatting."""
|
||||
for node in nodes:
|
||||
res = node["resource"]
|
||||
prefix = " " * indent
|
||||
name_display = getattr(res, "name", None) or getattr(res, "resource_id", "?")
|
||||
type_name = getattr(res, "resource_type_name", "")
|
||||
if indent == 0:
|
||||
console.print(
|
||||
f"{prefix}[bold cyan]{name_display}[/bold cyan] "
|
||||
f"[dim]({type_name})[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"{prefix}[cyan]{name_display}[/cyan] [dim]({type_name})[/dim]"
|
||||
)
|
||||
children_raw = node.get("children", [])
|
||||
children_list: list[dict[str, object]] = []
|
||||
if isinstance(children_raw, list):
|
||||
children_list = children_raw
|
||||
_print_tree_rich(children_list, indent + 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource inspect command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.command("inspect")
|
||||
def resource_inspect(
|
||||
resource: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help="Resource name or ULID to inspect",
|
||||
),
|
||||
],
|
||||
tree: Annotated[
|
||||
bool,
|
||||
typer.Option("--tree", help="Also show child tree"),
|
||||
] = False,
|
||||
file: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--file",
|
||||
help="Show file content (for git-checkout/fs-mount resources)",
|
||||
),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Show detailed information about a resource.
|
||||
|
||||
Optionally display the child tree with ``--tree`` or show file
|
||||
content for git-checkout/fs-mount resources with ``--file PATH``.
|
||||
|
||||
Examples:
|
||||
agents resource inspect local/my-repo
|
||||
agents resource inspect local/my-repo --tree
|
||||
agents resource inspect local/my-repo --file README.md
|
||||
agents resource inspect local/my-repo --format json
|
||||
"""
|
||||
try:
|
||||
service = _get_registry_service()
|
||||
res = service.show_resource(resource)
|
||||
|
||||
data: dict[str, object] = _resource_dict(res)
|
||||
|
||||
# Optionally include child tree
|
||||
if tree:
|
||||
tree_data = service.get_resource_tree(resource, depth=-1)
|
||||
data["children"] = _tree_to_dict(
|
||||
tree_data[0].get("children", []) # type: ignore[union-attr]
|
||||
if tree_data
|
||||
else []
|
||||
)
|
||||
|
||||
# Optionally show file content
|
||||
if file is not None:
|
||||
file_content = _read_resource_file(res, file)
|
||||
data["file_path"] = file
|
||||
data["file_content"] = file_content
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
# Rich display
|
||||
props_display = ""
|
||||
if res.properties:
|
||||
props_lines = [f" {k}: {v}" for k, v in res.properties.items()]
|
||||
props_display = "\n".join(props_lines)
|
||||
else:
|
||||
props_display = " (none)"
|
||||
|
||||
details = (
|
||||
f"[bold]Resource ID:[/bold] {res.resource_id}\n"
|
||||
f"[bold]Name:[/bold] {res.name or '(unnamed)'}\n"
|
||||
f"[bold]Type:[/bold] {res.resource_type_name}\n"
|
||||
f"[bold]Classification:[/bold] {res.classification}\n"
|
||||
f"[bold]Description:[/bold] {res.description or '(none)'}\n"
|
||||
f"[bold]Location:[/bold] {res.location or '(none)'}\n"
|
||||
f"[bold]Properties:[/bold]\n{props_display}\n"
|
||||
f"[bold]Created:[/bold] {res.created_at}\n"
|
||||
f"[bold]Updated:[/bold] {res.updated_at}"
|
||||
)
|
||||
|
||||
console.print(Panel(details, title="Resource Inspect", expand=False))
|
||||
|
||||
if tree:
|
||||
console.print("\n[bold]Children:[/bold]")
|
||||
tree_nodes = service.get_resource_tree(resource, depth=-1)
|
||||
if tree_nodes:
|
||||
children_raw = tree_nodes[0].get("children", [])
|
||||
children_list: list[dict[str, object]] = []
|
||||
if isinstance(children_raw, list):
|
||||
children_list = children_raw
|
||||
if children_list:
|
||||
_print_tree_rich(children_list, indent=1)
|
||||
else:
|
||||
console.print(" (no children)")
|
||||
|
||||
if file is not None:
|
||||
file_content = _read_resource_file(res, file)
|
||||
console.print(f"\n[bold]File:[/bold] {file}")
|
||||
console.print(file_content)
|
||||
|
||||
except NotFoundError as exc:
|
||||
console.print(f"[red]Resource not found:[/red] {resource}")
|
||||
raise typer.Abort() from exc
|
||||
except CleverAgentsError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc.message}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
|
||||
def _read_resource_file(resource: Any, file_path: str) -> str:
|
||||
"""Read a file relative to a resource's location.
|
||||
|
||||
For git-checkout and fs-mount resources, the file is resolved
|
||||
relative to the resource's location (path property or location field).
|
||||
"""
|
||||
location = resource.location
|
||||
if not location and resource.properties:
|
||||
location = resource.properties.get("path")
|
||||
|
||||
if not location:
|
||||
return "(no location set for this resource)"
|
||||
|
||||
full_path = Path(location) / file_path
|
||||
if not full_path.exists():
|
||||
return f"(file not found: {full_path})"
|
||||
|
||||
try:
|
||||
return full_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
return f"(error reading file: {exc})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource link-child command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.command("link-child")
|
||||
def resource_link_child(
|
||||
parent: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Parent resource name or ULID"),
|
||||
],
|
||||
child: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Child resource name or ULID"),
|
||||
],
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Create a DAG edge between two resources (parent -> child).
|
||||
|
||||
The child's resource type must be allowed by the parent's type
|
||||
constraints. Cycles are rejected.
|
||||
|
||||
Examples:
|
||||
agents resource link-child local/my-repo local/my-dir
|
||||
"""
|
||||
try:
|
||||
service = _get_registry_service()
|
||||
service.link_child(parent, child)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data: dict[str, object] = {
|
||||
"status": "linked",
|
||||
"parent": parent,
|
||||
"child": child,
|
||||
}
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
console.print(f"[green]Linked:[/green] {parent} -> {child}")
|
||||
|
||||
except NotFoundError as exc:
|
||||
console.print(f"[red]Resource not found:[/red] {exc.resource_id}")
|
||||
raise typer.Abort() from exc
|
||||
except ValidationError as exc:
|
||||
console.print(f"[red]Validation error:[/red] {exc.message}")
|
||||
raise typer.Abort() from exc
|
||||
except CleverAgentsError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc.message}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource unlink-child command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.command("unlink-child")
|
||||
def resource_unlink_child(
|
||||
parent: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Parent resource name or ULID"),
|
||||
],
|
||||
child: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Child resource name or ULID"),
|
||||
],
|
||||
yes: Annotated[
|
||||
bool,
|
||||
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
|
||||
] = False,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Remove a DAG edge between two resources.
|
||||
|
||||
Requires confirmation unless ``--yes`` is passed.
|
||||
|
||||
Examples:
|
||||
agents resource unlink-child local/my-repo local/my-dir
|
||||
agents resource unlink-child --yes local/my-repo local/my-dir
|
||||
"""
|
||||
try:
|
||||
if not yes:
|
||||
confirm = typer.confirm(
|
||||
f"Unlink '{child}' from '{parent}'?",
|
||||
)
|
||||
if not confirm:
|
||||
console.print("[yellow]Aborted.[/yellow]")
|
||||
raise typer.Abort()
|
||||
|
||||
service = _get_registry_service()
|
||||
service.unlink_child(parent, child)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data: dict[str, object] = {
|
||||
"status": "unlinked",
|
||||
"parent": parent,
|
||||
"child": child,
|
||||
}
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
console.print(f"[green]Unlinked:[/green] {parent} -> {child}")
|
||||
|
||||
except NotFoundError as exc:
|
||||
console.print(f"[red]Not found:[/red] {exc.resource_id}")
|
||||
raise typer.Abort() from exc
|
||||
except CleverAgentsError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc.message}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
|
||||
@app.command("remove")
|
||||
def resource_remove(
|
||||
resource: Annotated[
|
||||
|
||||
Reference in New Issue
Block a user