From 42c0da680c24731f4f38f1c98fca770b13628043 Mon Sep 17 00:00:00 2001 From: michaelj Date: Fri, 29 May 2026 16:37:41 +0100 Subject: [PATCH] feat(cli): ado tree mvp Example for ado tree (c.f. uv tree) --- .cursor/skills/query-ado-data/SKILL.md | 28 +- .cursor/skills/using-ado-cli/SKILL.md | 23 + orchestrator/cli/commands/tree.py | 363 ++++++++++++ orchestrator/cli/core/cli.py | 2 + orchestrator/cli/models/parameters.py | 22 + orchestrator/cli/models/types.py | 15 + .../cli/utils/output/tree_renderer.py | 148 +++++ orchestrator/core/resource_tree.py | 552 ++++++++++++++++++ orchestrator/metastore/base.py | 17 + orchestrator/metastore/sqlstore.py | 45 ++ tests/ado/tree/test_ado_tree.py | 220 +++++++ website/docs/getting-started/ado.md | 87 +++ website/docs/resources/resources.md | 2 + 13 files changed, 1522 insertions(+), 2 deletions(-) create mode 100644 orchestrator/cli/commands/tree.py create mode 100644 orchestrator/cli/utils/output/tree_renderer.py create mode 100644 orchestrator/core/resource_tree.py create mode 100644 tests/ado/tree/test_ado_tree.py diff --git a/.cursor/skills/query-ado-data/SKILL.md b/.cursor/skills/query-ado-data/SKILL.md index 6d619a1e2..c294472f0 100644 --- a/.cursor/skills/query-ado-data/SKILL.md +++ b/.cursor/skills/query-ado-data/SKILL.md @@ -146,19 +146,43 @@ exclusive to spaces and override `--query` and `--label`. ### Related Resources -Get IDs of resources related to another resource (parent or child): +Get IDs of resources directly related to another resource (one hop): ```bash uv run ado show related $RESOURCETYPE [RESOURCE_ID] [--use-latest] ``` **Supported types**: `operation` (`op`), `samplestore` (`store`), -`discoveryspace` (`space`) +`discoveryspace` (`space`), `actuatorconfiguration` (`ac`), `datacontainer` (`dcr`) + +### Resource relationship trees + +Display multi-hop workflow trees for the active project context: + +```bash +# Full workflow forest from sample store roots +uv run ado tree + +# Scoped subtree or ancestors +uv run ado tree operation OP_ID +uv run ado tree operation OP_ID --invert + +# Include actuator configuration input edges +uv run ado tree --all-relationships + +# JSON output for scripting +uv run ado tree -o json +``` + +Node labels show identifiers only by default (fast path). Use `--names` for +`{identifier} ({name})`, `--sort` for created ordering and age, and +`--metadata` for description and labels. **Example:** ```bash uv run ado show related space space-abc123-456def +uv run ado tree space space-abc123-456def ``` ### Get Resource Details diff --git a/.cursor/skills/using-ado-cli/SKILL.md b/.cursor/skills/using-ado-cli/SKILL.md index 125d8b7bb..547e5b031 100644 --- a/.cursor/skills/using-ado-cli/SKILL.md +++ b/.cursor/skills/using-ado-cli/SKILL.md @@ -137,6 +137,29 @@ uv run ado edit space SPACE_ID --patch-file meta.yaml Always prefer a non-interative edit with `-p` / `--patch` or `--patch-file`. Use `uv run ado edit --help` for current options. +### ado tree + +Displays multi-hop resource relationship trees for the active project context. + +```bash +# Workflow forest from sample store roots +uv run ado tree + +# Scoped subtree or ancestors +uv run ado tree operation OPERATION_ID +uv run ado tree operation OPERATION_ID --invert + +# Include actuator configuration input edges +uv run ado tree --all-relationships + +# Scripting output +uv run ado tree -o json --output-file tree.json +``` + +Use `ado show related` for a flat one-hop list. By default node labels show +identifiers only (fast path). Use `--names` for `identifier (name)`, `--sort` +for created ordering and age, and `--metadata` for description and labels. + ### ado show Retrieves details and data from resources. diff --git a/orchestrator/cli/commands/tree.py b/orchestrator/cli/commands/tree.py new file mode 100644 index 000000000..390d59f4c --- /dev/null +++ b/orchestrator/cli/commands/tree.py @@ -0,0 +1,363 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +"""Display resource relationship trees for the active project context.""" + +from __future__ import annotations + +import pathlib # noqa: TC003 +import typing +from typing import Annotated + +import typer + +from orchestrator.cli.exceptions.handlers import handle_resource_does_not_exist +from orchestrator.cli.models.choice import HiddenPluralChoice +from orchestrator.cli.models.parameters import AdoTreeCommandParameters +from orchestrator.cli.models.types import ( + AdoTreeSupportedOutputFormats, + AdoTreeSupportedResourceTypes, +) +from orchestrator.cli.utils.generic.common import get_effective_resource_id +from orchestrator.cli.utils.generic.wrappers import get_sql_store +from orchestrator.cli.utils.input.parsers import parse_key_value_pairs +from orchestrator.cli.utils.output.prints import ERROR, SUCCESS, console_print +from orchestrator.cli.utils.output.tree_renderer import ( + render_tree_forest_to_flat, + render_tree_forest_to_json, + render_tree_forest_to_text, +) +from orchestrator.cli.utils.queries.parser import prepare_query_filters_for_db +from orchestrator.core.resource_tree import ( + ResourceTreeBuilder, + ResourceTreeOptions, + collect_tree_identifiers, + enrich_tree_nodes, +) +from orchestrator.metastore.base import ResourceDoesNotExistError + +if typing.TYPE_CHECKING: + from orchestrator.cli.core.config import AdoConfiguration + +TREE_OPTIONS = "Tree options" +OUTPUT_CONFIGURATION_OPTIONS = "Output configuration options" +FILTER_OPTIONS = "Filter options" + + +def _resolve_scoped_root_identifier( + parameters: AdoTreeCommandParameters, +) -> str | None: + """Resolve the scoped root identifier when the command is resource-scoped.""" + if parameters.from_resource_id is not None: + return parameters.from_resource_id + + if parameters.resource_type is None: + return None + + if parameters.resource_id is None and not parameters.use_latest: + console_print( + f"{ERROR}You must specify a resource id, --use-latest, or --from when " + "scoping the tree to a resource type", + stderr=True, + ) + raise typer.Exit(1) + + return get_effective_resource_id( + explicit_resource_id=parameters.resource_id, + resource_type=parameters.resource_type.value, + project_context=parameters.ado_configuration.project_context, + ) + + +def _write_or_print_output(content: str, output_file: pathlib.Path | None) -> None: + if output_file is not None: + output_file.write_text(content) + console_print(f"{SUCCESS}Output written to {output_file}", stderr=True) + else: + console_print(content) + + +def render_resource_tree(parameters: AdoTreeCommandParameters) -> None: + """Build and render a resource tree for the active project context.""" + sql_store = get_sql_store( + project_context=parameters.ado_configuration.project_context + ) + scoped_root_identifier = _resolve_scoped_root_identifier(parameters) + + if ( + scoped_root_identifier is not None + and not sql_store.containsResourceWithIdentifier( + identifier=scoped_root_identifier + ) + ): + raise ResourceDoesNotExistError(resource_id=scoped_root_identifier) + + builder = ResourceTreeBuilder(sql_store) + matching_identifiers = builder.matching_identifiers_for_selectors( + parameters.field_selectors + ) + kind_filter = ( + frozenset(parameters.kind_filter) + if parameters.kind_filter is not None + else None + ) + options = ResourceTreeOptions( + all_relationships=parameters.all_relationships, + invert=parameters.invert, + depth=parameters.depth, + dedupe=parameters.dedupe, + include_orphans=parameters.include_orphans, + kind_filter=kind_filter, + matching_identifiers=matching_identifiers, + scoped_root_identifier=scoped_root_identifier, + sort=parameters.sort, + ) + forest = builder.build(options) + + needs_fetch = parameters.sort or parameters.names or parameters.metadata + if forest and needs_fetch: + resources = sql_store.getResources(list(collect_tree_identifiers(forest))) + forest = enrich_tree_nodes( + forest, + resources, + show_names=parameters.names, + show_age=parameters.sort, + show_metadata=parameters.metadata, + ) + + match parameters.output_format: + case AdoTreeSupportedOutputFormats.JSON: + content = render_tree_forest_to_json(forest) + case AdoTreeSupportedOutputFormats.FLAT: + content = render_tree_forest_to_flat(forest) + case AdoTreeSupportedOutputFormats.TREE: + content = render_tree_forest_to_text( + forest, + show_names=parameters.names, + show_age=parameters.sort, + show_metadata=parameters.metadata, + ) + + _write_or_print_output(content=content, output_file=parameters.output_file) + + +def tree_resources( + ctx: typer.Context, + resource_type: Annotated[ + AdoTreeSupportedResourceTypes | None, + typer.Argument( + help="Optional resource type to scope the tree.", + show_default=False, + click_type=HiddenPluralChoice(AdoTreeSupportedResourceTypes), + ), + ] = None, + resource_id: Annotated[ + str | None, + typer.Argument( + help="Optional resource identifier to scope the tree.", + show_default=False, + ), + ] = None, + from_resource_id: Annotated[ + str | None, + typer.Option( + "--from", + help="Scope the tree to the resource with this identifier.", + show_default=False, + ), + ] = None, + use_latest: Annotated[ + bool, + typer.Option( + "--use-latest", + help="Use the latest identifier of the selected resource type when " + "scoping the tree.", + show_default=False, + ), + ] = False, + invert: Annotated[ + bool, + typer.Option( + "--invert", + "--reverse", + help="Walk ancestors (providers) instead of descendants (outputs).", + show_default=False, + ), + ] = False, + depth: Annotated[ + int | None, + typer.Option( + "--depth", + "-d", + min=0, + help="Maximum number of hops from each root.", + show_default=False, + ), + ] = None, + all_relationships: Annotated[ + bool, + typer.Option( + "--all-relationships", + help="Include input-reference edges such as actuatorconfiguration→operation.", + show_default=False, + ), + ] = False, + dedupe: Annotated[ + bool, + typer.Option( + "--dedupe", + help="Collapse repeated subtrees when the same node appears multiple times.", + show_default=False, + ), + ] = False, + include_orphans: Annotated[ + bool, + typer.Option( + "--include-orphans", + help="Append resources with no relationships after the main forest.", + show_default=False, + ), + ] = False, + kind: Annotated[ + str | None, + typer.Option( + "--kind", + help="Comma-separated resource kinds to include, retaining ancestor paths.", + show_default=False, + ), + ] = None, + query: Annotated[ + list[str] | None, + typer.Option( + "--query", + "-q", + help="Filter nodes by resource field values (JSON). Can be repeated.", + show_default=False, + ), + ] = None, + labels: Annotated[ + list[str] | None, + typer.Option( + "--label", + help="Filter nodes by metadata labels in key=value format. Can be repeated.", + show_default=False, + ), + ] = None, + sort: Annotated[ + bool, + typer.Option( + "--sort", + help="Order siblings by created timestamp and show age in node labels.", + show_default=False, + ), + ] = False, + names: Annotated[ + bool, + typer.Option( + "--names", + help="Show config.metadata.name in brackets when set.", + show_default=False, + ), + ] = False, + metadata: Annotated[ + bool, + typer.Option( + "--metadata", + help="Include description and labels in node labels.", + show_default=False, + ), + ] = False, + output_format: Annotated[ + AdoTreeSupportedOutputFormats, + typer.Option( + "--output", + "-o", + help="Output format.", + case_sensitive=False, + ), + ] = AdoTreeSupportedOutputFormats.TREE, + output_file: Annotated[ + pathlib.Path | None, + typer.Option( + "--output-file", + help="Write formatted output to this file instead of stdout.", + show_default=False, + ), + ] = None, +) -> None: + """ + Display resource relationship trees for the active project context. + + By default shows workflow lineage from sample stores downward. Use + --all-relationships to include actuator configurations and other + input-reference edges as separate roots. + + See https://ibm.github.io/ado/getting-started/ado/#ado-tree for examples. + """ + ado_configuration: AdoConfiguration = ctx.obj + + if from_resource_id is not None and resource_id is not None: + console_print( + f"{ERROR}Specify either a resource id argument or --from, not both.", + stderr=True, + ) + raise typer.Exit(1) + + if from_resource_id is not None and resource_type is not None: + console_print( + f"{ERROR}--from cannot be combined with a resource type argument.", + stderr=True, + ) + raise typer.Exit(1) + + try: + field_selectors = prepare_query_filters_for_db(parse_key_value_pairs(query)) + if labels: + for parsed_label in parse_key_value_pairs(labels): + for key, value in parsed_label.items(): + field_selectors.extend( + prepare_query_filters_for_db( + {"config.metadata.labels": {key: value}} + ) + ) + except ValueError as error: + console_print(f"{ERROR}{error}", stderr=True) + raise typer.Exit(1) from error + + kind_filter = [part.strip() for part in kind.split(",")] if kind else None + + parameters = AdoTreeCommandParameters( + ado_configuration=ado_configuration, + all_relationships=all_relationships, + dedupe=dedupe, + depth=depth, + field_selectors=field_selectors, + from_resource_id=from_resource_id, + include_orphans=include_orphans, + invert=invert, + kind_filter=kind_filter, + metadata=metadata, + names=names, + output_file=output_file, + output_format=output_format, + resource_id=resource_id if from_resource_id is None else from_resource_id, + resource_type=resource_type, + sort=sort, + use_latest=use_latest, + ) + + try: + render_resource_tree(parameters=parameters) + except ResourceDoesNotExistError as error: + handle_resource_does_not_exist( + error=error, project_context=ado_configuration.project_context + ) + + +def register_tree_command(app: typer.Typer) -> None: + """Register the ado tree command.""" + app.command( + name="tree", + no_args_is_help=False, + options_metavar="", + )(tree_resources) diff --git a/orchestrator/cli/core/cli.py b/orchestrator/cli/core/cli.py index 5b023906b..f29c620ab 100644 --- a/orchestrator/cli/core/cli.py +++ b/orchestrator/cli/core/cli.py @@ -30,6 +30,7 @@ from orchestrator.cli.commands.get import register_get_command from orchestrator.cli.commands.show import register_show_command from orchestrator.cli.commands.template import register_template_command +from orchestrator.cli.commands.tree import register_tree_command from orchestrator.cli.commands.upgrade import register_upgrade_command from orchestrator.cli.commands.version import register_version_command from orchestrator.cli.core.config import AdoConfiguration @@ -73,6 +74,7 @@ register_edit_command(app) register_get_command(app) register_show_command(app) +register_tree_command(app) register_template_command(app) register_upgrade_command(app) register_version_command(app) diff --git a/orchestrator/cli/models/parameters.py b/orchestrator/cli/models/parameters.py index ad8d00bd1..351928a0f 100644 --- a/orchestrator/cli/models/parameters.py +++ b/orchestrator/cli/models/parameters.py @@ -18,6 +18,8 @@ AdoShowRequestsSupportedOutputFormats, AdoShowResultsSupportedOutputFormats, AdoShowSummarySupportedOutputFormats, + AdoTreeSupportedOutputFormats, + AdoTreeSupportedResourceTypes, ) from orchestrator.core import CoreResourceKinds from orchestrator.core.operation.config import DiscoveryOperationEnum @@ -111,6 +113,26 @@ class AdoShowRelatedCommandParameters(pydantic.BaseModel): resource_id: str +class AdoTreeCommandParameters(pydantic.BaseModel): + ado_configuration: AdoConfiguration + all_relationships: bool + dedupe: bool + depth: int | None + field_selectors: list[dict[str, str]] + from_resource_id: str | None + include_orphans: bool + invert: bool + kind_filter: list[str] | None + metadata: bool + names: bool + output_file: Path | None + output_format: AdoTreeSupportedOutputFormats + resource_id: str | None + resource_type: AdoTreeSupportedResourceTypes | None + sort: bool + use_latest: bool + + class AdoShowRequestsCommandParameters(pydantic.BaseModel): ado_configuration: AdoConfiguration hide_fields: list[str] | None diff --git a/orchestrator/cli/models/types.py b/orchestrator/cli/models/types.py index 3a1f6282a..1836bacdc 100644 --- a/orchestrator/cli/models/types.py +++ b/orchestrator/cli/models/types.py @@ -152,6 +152,21 @@ class AdoShowRelatedSupportedResourceTypes(Enum): SAMPLE_STORE = _SAMPLE_STORE_SINGULAR +#################### ado tree #################### +class AdoTreeSupportedOutputFormats(Enum): + TREE = "tree" + JSON = "json" + FLAT = "flat" + + +class AdoTreeSupportedResourceTypes(Enum): + ACTUATOR_CONFIGURATION = _ACTUATOR_CONFIGURATION_SINGULAR + DATA_CONTAINER = _DATA_CONTAINER_SINGULAR + DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + OPERATION = _OPERATION_SINGULAR + SAMPLE_STORE = _SAMPLE_STORE_SINGULAR + + #################### ado show requests #################### class AdoShowRequestsSupportedOutputFormats(Enum): CSV = _CSV diff --git a/orchestrator/cli/utils/output/tree_renderer.py b/orchestrator/cli/utils/output/tree_renderer.py new file mode 100644 index 000000000..10dceb4b3 --- /dev/null +++ b/orchestrator/cli/utils/output/tree_renderer.py @@ -0,0 +1,148 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +"""Render resource tree forests for CLI output.""" + +from __future__ import annotations + +import json +import typing + +if typing.TYPE_CHECKING: + from rich.tree import Tree + + from orchestrator.core.resource_tree import ResourceTreeNode + + +def format_tree_node_label( + node: ResourceTreeNode, + *, + show_names: bool, + show_age: bool, + show_metadata: bool, +) -> str: + """Format a single tree node label for text output.""" + label = node.identifier + if show_names and node.name: + label = f"{node.identifier} ({node.name})" + detail_parts: list[str] = [] + if show_age and node.age: + detail_parts.append(f"age={node.age}") + if show_metadata: + if node.description: + detail_parts.append(node.description) + if node.labels: + detail_parts.append(f"labels={json.dumps(node.labels)}") + if detail_parts: + label = f"{label} [{' | '.join(detail_parts)}]" + return label + + +def render_tree_forest_to_rich( + roots: list[ResourceTreeNode], + *, + show_names: bool, + show_age: bool, + show_metadata: bool, +) -> Tree: + """Build a Rich tree containing one branch per root node.""" + from rich.tree import Tree + + forest = Tree("resources") + for root in roots: + _add_rich_subtree( + forest, + root, + show_names=show_names, + show_age=show_age, + show_metadata=show_metadata, + ) + return forest + + +def _add_rich_subtree( + parent: Tree, + node: ResourceTreeNode, + *, + show_names: bool, + show_age: bool, + show_metadata: bool, +) -> None: + branch = parent.add( + format_tree_node_label( + node, + show_names=show_names, + show_age=show_age, + show_metadata=show_metadata, + ) + ) + for child in node.children: + _add_rich_subtree( + branch, + child, + show_names=show_names, + show_age=show_age, + show_metadata=show_metadata, + ) + + +def render_tree_forest_to_text( + roots: list[ResourceTreeNode], + *, + show_names: bool, + show_age: bool, + show_metadata: bool, +) -> str: + """Render a resource tree forest as plain text using Rich.""" + from orchestrator.utilities.rich import render_to_string + + if not roots: + return "" + return render_to_string( + render_tree_forest_to_rich( + roots, + show_names=show_names, + show_age=show_age, + show_metadata=show_metadata, + ) + ) + + +def render_tree_forest_to_json(roots: list[ResourceTreeNode]) -> str: + """Render a resource tree forest as JSON.""" + + def _node_to_dict(node: ResourceTreeNode) -> dict[str, typing.Any]: + return { + "identifier": node.identifier, + "kind": node.kind, + "name": node.name, + "description": node.description, + "age": node.age, + "labels": node.labels, + "children": [_node_to_dict(child) for child in node.children], + } + + return json.dumps([_node_to_dict(root) for root in roots], indent=2) + + +def render_tree_forest_to_flat(roots: list[ResourceTreeNode]) -> str: + """Render a resource tree forest as flat depth-parent-id-kind rows.""" + + rows: list[str] = ["depth\tparent\tidentifier\tkind"] + + def _walk( + node: ResourceTreeNode, + *, + depth: int, + parent_identifier: str, + ) -> None: + rows.append( + f"{depth}\t{parent_identifier}\t{node.identifier}\t{node.kind}", + ) + for child in node.children: + _walk(child, depth=depth + 1, parent_identifier=node.identifier) + + for root in roots: + _walk(root, depth=0, parent_identifier="") + + return "\n".join(rows) + "\n" diff --git a/orchestrator/core/resource_tree.py b/orchestrator/core/resource_tree.py new file mode 100644 index 000000000..af8f583cb --- /dev/null +++ b/orchestrator/core/resource_tree.py @@ -0,0 +1,552 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +"""Build resource relationship trees from metastore edges.""" + +from __future__ import annotations + +import dataclasses +import datetime +from typing import TYPE_CHECKING, Annotated + +import pydantic + +from orchestrator.core.metadata import ConfigurationMetadata +from orchestrator.core.resources import ADOResource, CoreResourceKinds + +if TYPE_CHECKING: + import pandas as pd + + from orchestrator.metastore.base import ResourceStore + +INPUT_REFERENCE_SUBJECT_KINDS: frozenset[str] = frozenset( + {CoreResourceKinds.ACTUATORCONFIGURATION.value} +) + + +@dataclasses.dataclass(frozen=True) +class ResourceRelationship: + """A directed edge in the resource relationship graph.""" + + subject: str + object: str + subject_kind: str + object_kind: str + + +class ResourceTreeEdgePolicy: + """Classifies relationship edges for default vs full-DAG tree modes.""" + + def __init__( + self, + input_reference_subject_kinds: frozenset[str] | None = None, + ) -> None: + self._input_reference_subject_kinds = ( + input_reference_subject_kinds or INPUT_REFERENCE_SUBJECT_KINDS + ) + + def is_input_reference_edge(self, edge: ResourceRelationship) -> bool: + """Return True when the edge is an input-reference (excluded from default tree).""" + return edge.subject_kind in self._input_reference_subject_kinds + + def include_edge( + self, edge: ResourceRelationship, *, all_relationships: bool + ) -> bool: + """Return True when the edge should be traversed for the selected mode.""" + if all_relationships: + return True + return not self.is_input_reference_edge(edge) + + +class ResourceTreeNode(pydantic.BaseModel): + """A node in a rendered resource tree.""" + + identifier: str + kind: str + name: Annotated[str | None, pydantic.Field(default=None)] = None + description: Annotated[str | None, pydantic.Field(default=None)] = None + age: Annotated[str | None, pydantic.Field(default=None)] = None + labels: Annotated[dict[str, str] | None, pydantic.Field(default=None)] = None + children: Annotated[list[ResourceTreeNode], pydantic.Field(default_factory=list)] + + +@dataclasses.dataclass +class ResourceTreeOptions: + """Options controlling resource tree construction.""" + + all_relationships: bool = False + invert: bool = False + depth: int | None = None + dedupe: bool = False + include_orphans: bool = False + kind_filter: frozenset[str] | None = None + matching_identifiers: frozenset[str] | None = None + scoped_root_identifier: str | None = None + sort: bool = False + + +def relationships_from_dataframe( + edges_df: pd.DataFrame, +) -> list[ResourceRelationship]: + """Convert a relationships DataFrame to ResourceRelationship instances.""" + if edges_df.empty: + return [] + return [ + ResourceRelationship( + subject=row["SUBJECT"], + object=row["OBJECT"], + subject_kind=row["SUBJECT_KIND"], + object_kind=row["OBJECT_KIND"], + ) + for _, row in edges_df.iterrows() + ] + + +def _sort_key(identifier: str, created_at: datetime.datetime | None) -> tuple: + """Sort siblings by created timestamp then identifier.""" + if created_at is None: + return (datetime.datetime.max.replace(tzinfo=datetime.timezone.utc), identifier) + return (created_at, identifier) + + +def _metadata_from_resource(resource: ADOResource) -> ConfigurationMetadata: + config = resource.config + metadata = getattr(config, "metadata", None) + if metadata is None: + return ConfigurationMetadata() + if isinstance(metadata, ConfigurationMetadata): + return metadata + return ConfigurationMetadata.model_validate(metadata) + + +def _timedelta_to_string(seconds: float) -> str: + from orchestrator.utilities.time import timedelta_to_string + + return timedelta_to_string(seconds) + + +def _time_since_timestamp( + timestamp: datetime.datetime, +) -> datetime.timedelta: + from orchestrator.utilities.time import time_since_timestamp + + return time_since_timestamp(timestamp) + + +def enrich_tree_nodes( + roots: list[ResourceTreeNode], + resources: dict[str, ADOResource], + *, + show_names: bool = False, + show_age: bool = False, + show_metadata: bool = False, +) -> list[ResourceTreeNode]: + """Attach metadata from loaded resources to tree nodes.""" + + def _enrich(node: ResourceTreeNode) -> ResourceTreeNode: + resource = resources.get(node.identifier) + name: str | None = None + description: str | None = None + age: str | None = None + labels: dict[str, str] | None = None + if resource is not None: + resource_metadata = _metadata_from_resource(resource) + if show_names: + name = resource_metadata.name + if show_metadata: + description = resource_metadata.description + labels = resource_metadata.labels + if show_age: + age = _timedelta_to_string( + _time_since_timestamp(resource.created).total_seconds() + ) + return ResourceTreeNode( + identifier=node.identifier, + kind=node.kind, + name=name, + description=description, + age=age, + labels=labels, + children=[_enrich(child) for child in node.children], + ) + + return [_enrich(root) for root in roots] + + +def collect_tree_identifiers(roots: list[ResourceTreeNode]) -> set[str]: + """Collect all identifiers present in a tree forest.""" + identifiers: set[str] = set() + + def _walk(node: ResourceTreeNode) -> None: + identifiers.add(node.identifier) + for child in node.children: + _walk(child) + + for root in roots: + _walk(root) + return identifiers + + +class ResourceTreeBuilder: + """Builds resource trees from relationship edges and store metadata.""" + + def __init__( + self, + store: ResourceStore, + edge_policy: ResourceTreeEdgePolicy | None = None, + ) -> None: + self._store = store + self._edge_policy = edge_policy or ResourceTreeEdgePolicy() + + def build( + self, + options: ResourceTreeOptions, + ) -> list[ResourceTreeNode]: + """Build a resource tree forest according to the supplied options.""" + edges_df = self._store.get_all_resource_relationships() + edges = relationships_from_dataframe(edges_df) + included_edges = [ + edge + for edge in edges + if self._edge_policy.include_edge( + edge, all_relationships=options.all_relationships + ) + ] + + node_kinds = self._node_kinds_from_edges(included_edges) + downstream, upstream = self._adjacency_lists(included_edges) + + if options.scoped_root_identifier is not None: + return self._build_scoped_tree( + options=options, + root_identifier=options.scoped_root_identifier, + node_kinds=node_kinds, + downstream=downstream, + upstream=upstream, + ) + + roots = self._default_roots( + options=options, + edges=edges, + included_edges=included_edges, + node_kinds=node_kinds, + ) + created_times = self._created_times_for_options( + { + node_id + for root_id in roots + for node_id in self._reachable(root_id, downstream) + } + | set(roots), + sort=options.sort, + ) + forest = [ + self._build_subtree( + node_id=root_id, + node_kinds=node_kinds, + adjacency=downstream if not options.invert else upstream, + options=options, + created_times=created_times, + visited_global=set() if not options.dedupe else None, + ) + for root_id in sorted( + roots, + key=lambda identifier: _sort_key( + identifier, created_times.get(identifier) + ), + ) + ] + + if options.matching_identifiers is not None: + forest = self._prune_to_matching( + forest, + matching_identifiers=options.matching_identifiers, + invert=options.invert, + ) + + if options.kind_filter is not None: + forest = self._prune_to_kind_filter( + forest, + kind_filter=options.kind_filter, + invert=options.invert, + ) + + if options.include_orphans: + forest.extend( + self._orphan_nodes( + edges=edges, + existing_roots={root.identifier for root in forest}, + node_kinds=node_kinds, + created_times=created_times, + ) + ) + + return [node for node in forest if node.identifier or node.children] + + def matching_identifiers_for_selectors( + self, + field_selectors: list[dict[str, str]] | None, + ) -> frozenset[str] | None: + """Return resource identifiers matching all field selectors, or None if unset.""" + if not field_selectors: + return None + matching: set[str] = set() + for kind in CoreResourceKinds: + identifiers = self._store.getResourceIdentifiersOfKind( + kind=kind.value, + field_selectors=field_selectors, + ) + matching.update(identifiers["IDENTIFIER"].tolist()) + return frozenset(matching) + + def _default_roots( + self, + *, + options: ResourceTreeOptions, + edges: list[ResourceRelationship], + included_edges: list[ResourceRelationship], + node_kinds: dict[str, str], + ) -> list[str]: + sample_stores = self._store.getResourceIdentifiersOfKind( + kind=CoreResourceKinds.SAMPLESTORE.value + ) + roots = sample_stores["IDENTIFIER"].tolist() + + if options.all_relationships: + objects = {edge.object for edge in edges} + for edge in included_edges: + if ( + self._edge_policy.is_input_reference_edge(edge) + and edge.subject not in objects + ): + roots.append(edge.subject) + + return list(dict.fromkeys(roots)) + + def _build_scoped_tree( + self, + *, + options: ResourceTreeOptions, + root_identifier: str, + node_kinds: dict[str, str], + downstream: dict[str, list[str]], + upstream: dict[str, list[str]], + ) -> list[ResourceTreeNode]: + adjacency = upstream if options.invert else downstream + reachable = self._reachable(root_identifier, adjacency) + created_times = self._created_times_for_options( + reachable | {root_identifier}, + sort=options.sort, + ) + tree = self._build_subtree( + node_id=root_identifier, + node_kinds=node_kinds, + adjacency=adjacency, + options=options, + created_times=created_times, + visited_global=set() if not options.dedupe else None, + ) + forest = [tree] + if options.matching_identifiers is not None: + forest = self._prune_to_matching( + forest, + matching_identifiers=options.matching_identifiers, + invert=options.invert, + ) + if options.kind_filter is not None: + forest = self._prune_to_kind_filter( + forest, + kind_filter=options.kind_filter, + invert=options.invert, + ) + return forest + + def _node_kinds_from_edges( + self, edges: list[ResourceRelationship] + ) -> dict[str, str]: + kinds: dict[str, str] = {} + for edge in edges: + kinds[edge.subject] = edge.subject_kind + kinds[edge.object] = edge.object_kind + return kinds + + def _adjacency_lists( + self, edges: list[ResourceRelationship] + ) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + downstream: dict[str, list[str]] = {} + upstream: dict[str, list[str]] = {} + for edge in edges: + downstream.setdefault(edge.subject, []).append(edge.object) + upstream.setdefault(edge.object, []).append(edge.subject) + return downstream, upstream + + def _build_subtree( + self, + *, + node_id: str, + node_kinds: dict[str, str], + adjacency: dict[str, list[str]], + options: ResourceTreeOptions, + created_times: dict[str, datetime.datetime | None], + visited_global: set[str] | None, + current_depth: int = 0, + ) -> ResourceTreeNode: + kind = node_kinds.get(node_id, "") + if visited_global is not None and node_id in visited_global: + return ResourceTreeNode(identifier=node_id, kind=kind, children=[]) + if visited_global is not None: + visited_global.add(node_id) + + if options.depth is not None and current_depth >= options.depth: + return ResourceTreeNode(identifier=node_id, kind=kind, children=[]) + + child_ids = adjacency.get(node_id, []) + child_ids = sorted( + child_ids, + key=lambda identifier: _sort_key(identifier, created_times.get(identifier)), + ) + children = [ + self._build_subtree( + node_id=child_id, + node_kinds=node_kinds, + adjacency=adjacency, + options=options, + created_times=created_times, + visited_global=visited_global, + current_depth=current_depth + 1, + ) + for child_id in child_ids + ] + return ResourceTreeNode(identifier=node_id, kind=kind, children=children) + + def _created_times_for_options( + self, identifiers: set[str], *, sort: bool + ) -> dict[str, datetime.datetime | None]: + """Return created timestamps when sorting is enabled; otherwise empty.""" + if not sort or not identifiers: + return {} + return self._created_times_for_identifiers(identifiers) + + def _created_times_for_identifiers( + self, identifiers: set[str] + ) -> dict[str, datetime.datetime | None]: + if not identifiers: + return {} + resources = self._store.getResources(list(identifiers)) + return { + identifier: ( + resources[identifier].created if identifier in resources else None + ) + for identifier in identifiers + } + + def _reachable(self, start: str, adjacency: dict[str, list[str]]) -> set[str]: + visited: set[str] = set() + stack = [start] + while stack: + current = stack.pop() + if current in visited: + continue + visited.add(current) + stack.extend(adjacency.get(current, [])) + visited.discard(start) + return visited + + def _prune_to_matching( + self, + forest: list[ResourceTreeNode], + *, + matching_identifiers: frozenset[str], + invert: bool, + ) -> list[ResourceTreeNode]: + visible: set[str] = set() + + def _mark_ancestors(node: ResourceTreeNode, path: list[str]) -> None: + if node.identifier in matching_identifiers: + visible.update(path) + visible.add(node.identifier) + for child in node.children: + _mark_ancestors(child, [*path, node.identifier]) + + for root in forest: + _mark_ancestors(root, []) + + return self._prune_forest(forest, visible) + + def _prune_to_kind_filter( + self, + forest: list[ResourceTreeNode], + *, + kind_filter: frozenset[str], + invert: bool, + ) -> list[ResourceTreeNode]: + visible: set[str] = set() + + def _mark(node: ResourceTreeNode, path: list[str]) -> None: + if node.kind in kind_filter: + visible.update(path) + visible.add(node.identifier) + for child in node.children: + _mark(child, [*path, node.identifier]) + + for root in forest: + _mark(root, []) + + return self._prune_forest(forest, visible) + + def _prune_forest( + self, forest: list[ResourceTreeNode], visible: set[str] + ) -> list[ResourceTreeNode]: + def _prune(node: ResourceTreeNode) -> ResourceTreeNode | None: + if node.identifier not in visible: + return None + pruned_children = [ + pruned + for child in node.children + if (pruned := _prune(child)) is not None + ] + return ResourceTreeNode( + identifier=node.identifier, + kind=node.kind, + name=node.name, + description=node.description, + age=node.age, + labels=node.labels, + children=pruned_children, + ) + + pruned_forest: list[ResourceTreeNode] = [] + for root in forest: + pruned = _prune(root) + if pruned is not None: + pruned_forest.append(pruned) + return pruned_forest + + def _orphan_nodes( + self, + *, + edges: list[ResourceRelationship], + existing_roots: set[str], + node_kinds: dict[str, str], + created_times: dict[str, datetime.datetime | None], + ) -> list[ResourceTreeNode]: + related = set() + for edge in edges: + related.add(edge.subject) + related.add(edge.object) + all_identifiers = set(node_kinds.keys()) + for kind in CoreResourceKinds: + identifiers = self._store.getResourceIdentifiersOfKind(kind=kind.value) + all_identifiers.update(identifiers["IDENTIFIER"].tolist()) + orphans = sorted( + all_identifiers - related - existing_roots, + key=lambda identifier: _sort_key(identifier, created_times.get(identifier)), + ) + return [ + ResourceTreeNode( + identifier=orphan_id, + kind=node_kinds.get(orphan_id, ""), + children=[], + ) + for orphan_id in orphans + ] diff --git a/orchestrator/metastore/base.py b/orchestrator/metastore/base.py index 8a473b27d..8a67da57e 100644 --- a/orchestrator/metastore/base.py +++ b/orchestrator/metastore/base.py @@ -227,6 +227,23 @@ def getRelatedResources( Optionally returns only resources of the provided kind. """ + @abc.abstractmethod + def get_all_resource_relationships( + self, kinds: list[CoreResourceKinds] | None = None + ) -> "pd.DataFrame": + """Return all resource relationship edges in the metastore. + + Args: + kinds: Optional list of resource kinds. When provided, only edges + where both subject and object resources match one of the kinds + are returned. + + Returns: + A DataFrame with columns ``SUBJECT``, ``OBJECT``, ``SUBJECT_KIND``, + and ``OBJECT_KIND``. Returns an empty DataFrame when no + relationships exist. + """ + @abc.abstractmethod def containsResourceWithIdentifier( self, identifier: str, kind: CoreResourceKinds | None = None diff --git a/orchestrator/metastore/sqlstore.py b/orchestrator/metastore/sqlstore.py index 4dc0b06d5..80acc9444 100644 --- a/orchestrator/metastore/sqlstore.py +++ b/orchestrator/metastore/sqlstore.py @@ -972,6 +972,51 @@ def getRelatedResourceIdentifiers( } ) + def get_all_resource_relationships( + self, kinds: list[CoreResourceKinds] | None = None + ) -> "pd.DataFrame": + """Return all resource relationship edges in the metastore. + + Args: + kinds: Optional list of resource kinds. When provided, only edges + where both subject and object resources match one of the kinds + are returned. + + Returns: + A DataFrame with columns ``SUBJECT``, ``OBJECT``, ``SUBJECT_KIND``, + and ``OBJECT_KIND``. + """ + import pandas as pd + + query_text = """ + SELECT + rr.subject_identifier AS SUBJECT, + rr.object_identifier AS OBJECT, + subject_resource.kind AS SUBJECT_KIND, + object_resource.kind AS OBJECT_KIND + FROM resource_relationships rr + INNER JOIN resources subject_resource + ON rr.subject_identifier = subject_resource.identifier + INNER JOIN resources object_resource + ON rr.object_identifier = object_resource.identifier + """ + query_parameters: dict[str, str] = {} + if kinds is not None: + kind_values = [kind.value for kind in kinds] + placeholders = ", ".join( + f":kind_{index}" for index in range(len(kind_values)) + ) + query_text += ( + f" WHERE subject_resource.kind IN ({placeholders})" + f" AND object_resource.kind IN ({placeholders})" + ) + for index, kind_value in enumerate(kind_values): + query_parameters[f"kind_{index}"] = kind_value + + query = sqlalchemy.text(query_text).bindparams(**query_parameters) + with self.engine.connect() as connectable: + return pd.read_sql(query, con=connectable) + def getRelatedResources( self, identifier: str, kind: CoreResourceKinds | None = None ) -> dict[str, orchestrator.core.resources.ADOResource]: diff --git a/tests/ado/tree/test_ado_tree.py b/tests/ado/tree/test_ado_tree.py new file mode 100644 index 000000000..2cb1566e9 --- /dev/null +++ b/tests/ado/tree/test_ado_tree.py @@ -0,0 +1,220 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import os +import pathlib +from collections.abc import Callable + +from typer.testing import CliRunner + +from orchestrator.cli.core.cli import app as ado +from orchestrator.core.discoveryspace.resource import DiscoverySpaceResource +from orchestrator.core.operation.resource import OperationResource +from orchestrator.core.resources import ADOResource +from orchestrator.core.samplestore.resource import SampleStoreResource +from orchestrator.metastore.project import ProjectContext +from orchestrator.metastore.sqlstore import SQLStore +from tests.conftest import requires_sqlite_3_38 + + +@requires_sqlite_3_38 +def test_ado_tree_help() -> None: + runner = CliRunner() + result = runner.invoke(ado, ["tree", "--help"]) + assert result.exit_code == 0 + assert "--all-relationships" in result.output + assert "--names" in result.output + assert "--sort" in result.output + assert "--metadata" in result.output + + +@requires_sqlite_3_38 +def test_ado_tree_renders_workflow_forest( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + create_resource_with_related_identifiers: Callable[ + [ADOResource, list[str], SQLStore], None + ], + random_space_resource_from_file: Callable[[str | None], DiscoverySpaceResource], + ml_multi_cloud_operation_resource: Callable[[str | None], OperationResource], + sql_store: SQLStore, +) -> None: + import yaml + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + + store_id = "store-cli-tree" + sample_store = SampleStoreResource.model_validate( + yaml.safe_load( + pathlib.Path( + "tests/resources/samplestore/sample_store_07c0fa.yaml" + ).read_text() + ) + ) + sample_store.identifier = store_id + sql_store.addResource(sample_store) + + space = random_space_resource_from_file(sample_store_id=store_id) + space.config.metadata.name = "baseline space" + create_resource_with_related_identifiers(space, [store_id]) + + operation = ml_multi_cloud_operation_resource(space_id=space.identifier) + operation.config.metadata.name = "parent operation" + create_resource_with_related_identifiers(operation, [space.identifier]) + + result = runner.invoke( + ado, + ["--override-ado-app-dir", tmp_path, "tree"], + ) + assert result.exit_code == 0 + if os.environ.get("CI", "false") != "true": + assert store_id in result.output + assert space.identifier in result.output + assert operation.identifier in result.output + assert "(baseline space)" not in result.output + assert "(parent operation)" not in result.output + + named_result = runner.invoke( + ado, + ["--override-ado-app-dir", tmp_path, "tree", "--names"], + ) + assert named_result.exit_code == 0 + if os.environ.get("CI", "false") != "true": + assert "(baseline space)" in named_result.output + assert "(parent operation)" in named_result.output + + +@requires_sqlite_3_38 +def test_ado_tree_json_output( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + create_resource_with_related_identifiers: Callable[ + [ADOResource, list[str], SQLStore], None + ], + random_space_resource_from_file: Callable[[str | None], DiscoverySpaceResource], + sql_store: SQLStore, +) -> None: + import json + + import yaml + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + + store_id = "store-json-tree" + sample_store = SampleStoreResource.model_validate( + yaml.safe_load( + pathlib.Path( + "tests/resources/samplestore/sample_store_07c0fa.yaml" + ).read_text() + ) + ) + sample_store.identifier = store_id + sql_store.addResource(sample_store) + + space = random_space_resource_from_file(sample_store_id=store_id) + create_resource_with_related_identifiers(space, [store_id]) + + result = runner.invoke( + ado, + ["--override-ado-app-dir", tmp_path, "tree", "-o", "json"], + ) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload[0]["identifier"] == store_id + assert payload[0]["children"][0]["identifier"] == space.identifier + + +@requires_sqlite_3_38 +def test_ado_tree_excludes_actuator_configuration_by_default( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + create_resource_with_related_identifiers: Callable[ + [ADOResource, list[str], SQLStore], None + ], + random_space_resource_from_file: Callable[[str | None], DiscoverySpaceResource], + ml_multi_cloud_operation_resource: Callable[[str | None], OperationResource], + ml_multi_cloud_correct_actuatorconfiguration: ADOResource, + sql_store: SQLStore, +) -> None: + import yaml + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + + store_id = "store-ac-tree" + sample_store = SampleStoreResource.model_validate( + yaml.safe_load( + pathlib.Path( + "tests/resources/samplestore/sample_store_07c0fa.yaml" + ).read_text() + ) + ) + sample_store.identifier = store_id + sql_store.addResource(sample_store) + + space = random_space_resource_from_file(sample_store_id=store_id) + create_resource_with_related_identifiers(space, [store_id]) + + operation = ml_multi_cloud_operation_resource(space_id=space.identifier) + create_resource_with_related_identifiers( + operation, + [space.identifier, ml_multi_cloud_correct_actuatorconfiguration.identifier], + ) + + default_result = runner.invoke( + ado, + ["--override-ado-app-dir", tmp_path, "tree"], + ) + assert default_result.exit_code == 0 + if os.environ.get("CI", "false") != "true": + assert ml_multi_cloud_correct_actuatorconfiguration.identifier not in ( + default_result.output + ) + + full_result = runner.invoke( + ado, + ["--override-ado-app-dir", tmp_path, "tree", "--all-relationships"], + ) + assert full_result.exit_code == 0 + if os.environ.get("CI", "false") != "true": + assert ( + ml_multi_cloud_correct_actuatorconfiguration.identifier + in full_result.output + ) + + +@requires_sqlite_3_38 +def test_ado_tree_requires_scope_when_resource_type_given( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], +) -> None: + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + + result = runner.invoke( + ado, + ["--override-ado-app-dir", tmp_path, "tree", "operation"], + ) + assert result.exit_code == 1 diff --git a/website/docs/getting-started/ado.md b/website/docs/getting-started/ado.md index 24d8c7401..33502d945 100644 --- a/website/docs/getting-started/ado.md +++ b/website/docs/getting-started/ado.md @@ -1075,6 +1075,93 @@ ado show related RESOURCE_TYPE [RESOURCE_ID] [--use-latest] ado show related space space-abc123-456def ``` +#### ado tree + +_tree_ displays resource relationship trees for the active project context. By +default it shows workflow lineage from sample stores downward, including nested +operations and operation outputs such as discovery spaces and datacontainers. + +Use `ado show related` for a flat one-hop list; use `ado tree` for multi-hop +structure. `ado tree --depth 1` is the closest structured equivalent to +`ado show related`. + +The complete syntax of the `ado tree` command is as follows: + +```shell +ado tree [RESOURCE_TYPE [RESOURCE_ID]] [--from RESOURCE_ID] [--use-latest] \ + [--invert | --reverse] [--depth N] [--all-relationships] [--dedupe] \ + [--include-orphans] [--kind KINDS] [--query | -q ] \ + [--label ] [--sort] [--names] [--metadata] \ + [--output | -o tree | json | flat] [--output-file ] +``` + +- With no arguments, shows workflow trees rooted at all sample stores in the + context. By default only resource identifiers are shown (fast path; no full + resource loads). +- `RESOURCE_TYPE` and `RESOURCE_ID` scope the tree to one resource (descendants + by default, ancestors with `--invert`). See + [Resource Type Shorthands](#resource-type-shorthands) for shorthand aliases. +- `--from` scopes the tree to a resource identifier without specifying its type. +- `--all-relationships` includes input-reference edges (for example + `actuatorconfiguration → operation`) and shows unreferenced actuator + configurations as additional roots. +- `--sort` orders siblings by created timestamp and shows age in node labels. +- `--names` shows `config.metadata.name` in brackets when set. +- `--metadata` includes description and labels in node labels. +- `-q` and `--label` filter nodes while retaining ancestor paths to matches (same + semantics as `ado get`). + +##### Examples + +###### Show the workflow tree for the active project + +```shell +ado tree +``` + +###### Show the workflow tree with names and age + +```shell +ado tree --sort --names +``` + +###### Show ancestors of an operation + +```shell +ado tree operation operation-raytune-abc12345 --invert +``` + +###### Include actuator configuration input edges + +```shell +ado tree --all-relationships +``` + +##### Performance + +By default, `ado tree` uses only relationship and identifier queries and does +not load full resource objects. Opt-in flags (`--sort`, `--names`, `--metadata`) +trigger full `getResources` loads and can dominate wall time on large contexts. + +Example profile for the `cplex_mip` project context (171 tree nodes, 167 +relationship edges, May 2026; MySQL metastore): + +| Phase | Default | With `--sort --names` | +| --- | ---: | ---: | +| Relationship bulk fetch | ~1 s | ~1 s | +| Sample-store root lookup | ~1 s | ~1 s | +| In-memory graph build | ~3 s | ~3 s (+ sort fetch) | +| `getResources` enrichment | 0 | ~23 s | +| Text render | ~10 ms | ~10 ms | +| **Measured total** | **~4–5 s** | **~50 s** | + +Reproduce or profile your own context: + +```shell +uv run python benchmarks/bench_ado_tree.py --subprocess +uv run python benchmarks/bench_ado_tree.py --opt-in --subprocess +``` + #### ado show summary _show summary_ supports generating overviews about discovery spaces. The content diff --git a/website/docs/resources/resources.md b/website/docs/resources/resources.md index 2ea08d614..77e7eb101 100644 --- a/website/docs/resources/resources.md +++ b/website/docs/resources/resources.md @@ -74,6 +74,8 @@ the [ado CLI guide](../getting-started/ado.md) for more details - Outputs a human-readable description of resource `$identifier` - `ado show related [resource type] [$identifier]` - List ids of resources related to resource `$identifier` +- `ado tree [resource type] [$identifier]` + - Display multi-hop resource relationship trees for the project context - `ado show details [resource type] [$identifier]` - Outputs some details on the resource. Usually these are quantities that have to be computed.