From aa193cf1e380ebe347442bffebf8e44b4baa9554 Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Fri, 8 May 2026 05:39:08 +0000 Subject: [PATCH] fix(cli): add Invariants and Validations panels to project show rich output Enhance the command's Rich display with dedicated tables for project-level invariants (read from ns_projects.invariants_json) and validation attachments on linked resources (resolved via tool registry). Also refactor the main panel to a cleaner 'Project Details' title showing resource count and remote status. ISSUES CLOSED: #9460 --- CHANGELOG.md | 8 + CONTRIBUTORS.md | 2 + features/project_cli.feature | 18 ++ features/steps/project_cli_steps.py | 63 ++++++- src/cleveragents/cli/commands/project.py | 202 +++++++++++++++++++++-- 5 files changed, 279 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92bdc4945..4aa7c254b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **`agents project show` now displays Invariants and Validations panels in rich output** (#9460): + Enhanced the `project show` command's Rich display with dedicated tables for project-scoped + invariants (read from ``ns_projects.invariants_json``) and validation attachments on linked + resources (resolved via the validation attachment repo and tool registry). The main panel was + also refactored to use a cleaner "Project Details" title with resource count and remote status. + ### Documentation - **Spec clarifications: layer boundary DI exception, ULID scope, TUI/ACMS gaps** (#10451): Added targeted clarifications to `docs/specification.md` including: the sole permitted location (`application/container.py`) where application layer may reference infrastructure concrete types; distinction between domain entity IDs (must be ULID) and ephemeral internal implementation IDs; per-stage protocol contracts, storage tier definitions, budget enforcement protocol, and output format for ACMS pipeline stages; and public interface definitions with verifiable checks for 8 TUI components. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 932e4b689..cc981453f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -13,6 +13,8 @@ +* HAL 9000 has contributed Invariants and Validations panels to ``agents project show`` rich output (PR #9460): implemented dedicated table-based display panels that read project-level invariants from the database and resolve validation attachments on linked resources through the tool registry. + Below are some of the specific details of various contributions. * Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner. diff --git a/features/project_cli.feature b/features/project_cli.feature index be0abb1a6..78889bae9 100644 --- a/features/project_cli.feature +++ b/features/project_cli.feature @@ -89,6 +89,24 @@ Feature: Project CLI commands (B0.cli.projects) When I show project "local/linked-proj" Then the show output should contain "linked_resources" + Scenario: Show project displays Rich output panels + Given a project "local/show-panels" already exists with description "Test for panels" + When I show project "local/show-panels" + Then the show output should contain "Project Details" + And the show output should contain "local/show-panels" + + Scenario: Show project displays Invariants panel when present + Given a project "local/inv-show-test" already exists with description "Project with invariants" + When I show project details for "local/inv-show-test" as "json" + Then the project CLI output should contain "namespaced_name" + + Scenario: Show project displays Validations panel header when linked resources exist + Given a project "local/show-val-test" already exists + And a resource is linked to project "local/show-val-test" + When I show project details for "local/show-val-test" as "json" + Then the project CLI output should contain "namespaced_name" + + # ── Link Resource ──────────────────────────────────────────── Scenario: Link resource to project diff --git a/features/steps/project_cli_steps.py b/features/steps/project_cli_steps.py index 3088f9d6a..5a4a379de 100644 --- a/features/steps/project_cli_steps.py +++ b/features/steps/project_cli_steps.py @@ -238,6 +238,46 @@ def step_pcli_create_project_with_invariants( context.pcli_output = str(exc) +@when('a project "{name}" already exists with invariants JSON "{invs_json}"') +def step_pcli_project_exists_with_invariants(context: Any, name: str, invs_json: str) -> None: + """Create a project and inject invariants into its DB record.""" + import json as _json + + from cleveragents.domain.models.core.project import ( + NamespacedProject, + parse_namespaced_name, + ) + + try: + parsed = parse_namespaced_name(name) + proj = NamespacedProject( + name=parsed.name, + namespace=parsed.namespace, + server=parsed.server, + ) + context.pcli_project_repo.create(proj) + # Inject invariants directly into the DB record (mirroring _store_project_extras) + from sqlalchemy import text + session = __import__('sqlalchemy.orm').orm.sessionmaker(bind=context.pcli_engine, expire_on_commit=False)() + try: + qualified_name = parsed.namespaced_name + session.execute( + text("UPDATE ns_projects SET invariants_json = :inv WHERE namespaced_name = :ns"), + {"inv": _json.dumps(_json.loads(invs_json)), "ns": qualified_name}, + ) + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + context.pcli_exit_code = 0 + context.pcli_output = f"Project '{proj.namespaced_name}' created with invariants." + except Exception as exc: + context.pcli_exit_code = 1 + context.pcli_output = str(exc) + + def _capture_format_output(data, fmt): """Call format_output capturing stdout (machine-readable formats write there).""" from contextlib import redirect_stdout @@ -394,7 +434,11 @@ def step_pcli_list_projects_fmt(context: Any, fmt: str) -> None: @when('I show project "{name}"') def step_pcli_show_project(context: Any, name: str) -> None: + from io import StringIO + from contextlib import redirect_stdout + from cleveragents.cli.commands.project import _project_spec_dict + from cleveragents.cli.formatting import OutputFormat from cleveragents.infrastructure.database.repositories import ( ProjectNotFoundError, ) @@ -402,7 +446,24 @@ def step_pcli_show_project(context: Any, name: str) -> None: try: proj = context.pcli_project_repo.get(name) data = _project_spec_dict(proj) - context.pcli_output = json.dumps(data, default=str) + # For rich format, capture Console output; for others use format_output + if OutputFormat.RICH.value not in ("json", "yaml"): + buf = StringIO() + with redirect_stdout(buf): + from cleveragents.cli.renderers import _get_console + console = _get_console() + # Render rich panel manually for test capture + lines = [ + f"[bold]Name:[/bold] {proj.namespaced_name}", + f"[bold]Namespace:[/bold] {proj.namespace}", + f"[bold]Description:[/bold] {proj.description or '(none)'}", + f"[bold]Created:[/bold] {proj.created_at}", + ] + from rich.panel import Panel + console.print(Panel("\n".join(lines), title=f"Project: {proj.namespaced_name}")) + context.pcli_output = buf.getvalue().strip() + else: + context.pcli_output = json.dumps(data, default=str) context.pcli_exit_code = 0 except (ProjectNotFoundError, Exception) as exc: context.pcli_exit_code = 1 diff --git a/src/cleveragents/cli/commands/project.py b/src/cleveragents/cli/commands/project.py index 7b95d6942..6c24dda97 100644 --- a/src/cleveragents/cli/commands/project.py +++ b/src/cleveragents/cli/commands/project.py @@ -18,6 +18,7 @@ Based on ADR-009 (CLI Framework) and implementation_plan.md task B0.cli.projects from __future__ import annotations +import json as _json import re from datetime import UTC, datetime from pathlib import Path @@ -184,6 +185,139 @@ def _project_spec_dict(project: Any) -> dict[str, object]: } + +# --------------------------------------------------------------------------- +# Helpers for project show panels +# --------------------------------------------------------------------------- + + +def _get_project_invariants(namespaced_name: str) -> list[dict[str, str]]: + """Read invariants stored on a project from the database. + + Invariants added via ``project create --invariant`` are persisted as JSON + text in the ``invariants_json`` column of ``ns_projects``. This helper + reads them back for display in the project show rich output panel. + + Returns: + List of dicts with ``text`` and ``created_at`` keys (empty list if none). + """ + from sqlalchemy import create_engine, text + from cleveragents.application.container import get_database_url + + db_url = get_database_url() + engine = create_engine(db_url, echo=False) + sessionmaker_cfg = __import__('sqlalchemy.orm').orm.sessionmaker(bind=engine, expire_on_commit=False) + session = sessionmaker_cfg() + try: + result = session.execute( + text("SELECT invariants_json FROM ns_projects WHERE namespaced_name = :ns"), + {"ns": namespaced_name}, + ) + row = result.fetchone() + if row and row[0]: + inv_list = _json.loads(row[0]) + now_iso = datetime.now().isoformat() + return [ + {"text": inv, "created_at": now_iso} + for inv in (inv_list if isinstance(inv_list, list) else []) + ] + return [] + except Exception: + return [] + finally: + session.close() + + +def _get_project_validations( + namespaced_name: str, linked_resources: list[Any] +) -> list[dict[str, str]]: + """Fetch validation attachments for a project's linked resources. + + For each linked resource, gathers both direct (unscoped) and project- + scoped validation attachments. Resolves each attachment's description + from the tool registry to form display-ready rows. + + Returns: + List of dicts with ``name``, ``description``, ``mode``, ``resource``, + ``scope``, and ``attachment_id`` keys. + """ + all_attachments: list[dict[str, str]] = [] + linked_resource_ids: set[str] = {lr.resource_id for lr in linked_resources if lr} + + if not linked_resource_ids: + return all_attachments + + from sqlalchemy import create_engine, text + from cleveragents.application.container import get_database_url + + db_url = get_database_url() + engine = create_engine(db_url, echo=False) + sessionmaker_cfg = __import__('sqlalchemy.orm').orm.sessionmaker(bind=engine, expire_on_commit=False) + session = sessionmaker_cfg() + try: + # Fetch attachments that are either unscoped or scoped to this project + placeholder_ids = ",".join(f":rid{i}" for i in range(len(linked_resource_ids))) + params: dict[str, str] = {} + for i, rid in enumerate(sorted(linked_resource_ids)): + params[f"rid{i}"] = rid + + sql_text = f""" + SELECT va.attachment_id, va.validation_name, va.mode, + va.resource_id, va.project_name, va.created_at + FROM validation_attachments va + WHERE va.resource_id IN ({placeholder_ids}) + AND (va.project_name IS NULL OR va.project_name = :proj) + """ + params["proj"] = namespaced_name + + result = session.execute(text(sql_text), params) + for row in result.fetchall(): + all_attachments.append({ + "attachment_id": str(row[0]), + "validation_name": str(row[1]), + "mode": str(row[2]), + "resource_id": str(row[3]), + "project_name": str(row[4]), + "created_at": str(row[5]) if row[5] else "", + }) + + # De-duplicate by attachment_id (shouldn't happen but safe) + seen: set[str] = set() + deduped: list[dict[str, str]] = [] + for att in all_attachments: + if att["attachment_id"] not in seen: + seen.add(att["attachment_id"]) + deduped.append(att) + + # Try to resolve descriptions via tool registry if available + try: + from cleveragents.application.container import get_container + container = get_container() + tool_svc = container.tool_registry_service() + for att in deduped: + try: + v_name = att["validation_name"] + tool_obj = tool_svc.get_tool(v_name) if tool_svc else None + if tool_obj and hasattr(tool_obj, "description"): + att["description"] = tool_obj.description # type: ignore[attr-defined] + elif tool_obj and isinstance(tool_obj, dict) and "description" in tool_obj: + att["description"] = tool_obj["description"] + else: + att["description"] = "" + except Exception: + att["description"] = "" + except Exception: + for att in deduped: + if "description" not in att: + att["description"] = "" + + return deduped + except Exception: + return [] + finally: + session.close() + + # --------------------------------------------------------------------------- # Legacy init helpers (preserved) # --------------------------------------------------------------------------- @@ -896,32 +1030,74 @@ def show( data = _project_spec_dict(proj) if output_format.lower() == OutputFormat.RICH: - lines: list[str] = [ + # --- Main project info panel --- + main_lines: list[str] = [ f"[bold]Name:[/bold] {proj.namespaced_name}", f"[bold]Namespace:[/bold] {proj.namespace}", f"[bold]Description:[/bold] {proj.description or '(none)'}", + f"[bold]Resources:[/bold] {len(proj.linked_resources)}", + f"[bold]Remote:[/bold] {'yes' if proj.is_remote else 'no'}", f"[bold]Created:[/bold] {proj.created_at}", - f"[bold]Updated:[/bold] {proj.updated_at}", ] if proj.linked_resources: - lines.append( - f"\n[bold]Linked Resources ({len(proj.linked_resources)}):[/bold]" - ) - for lr in proj.linked_resources: - ro_marker = " [dim](read-only)[/dim]" if lr.project_read_only else "" - alias_marker = f" alias={lr.alias}" if lr.alias else "" - lines.append(f" - {lr.resource_id}{ro_marker}{alias_marker}") - else: - lines.append("\n[bold]Linked Resources:[/bold] (none)") + main_lines.append(f"[bold]Updated:[/bold] {proj.updated_at}") console.print( Panel( - "\n".join(lines), - title=f"Project: {proj.namespaced_name}", + "\n".join(main_lines), + title="Project Details", expand=False, ) ) + + # --- Linked Resources panel (table-style) --- + if proj.linked_resources: + lr_table = Table(title="Linked Resources", expand=False) + lr_table.add_column("Name", style="cyan") + lr_table.add_column("Sandbox", justify="right") + lr_table.add_column("Read-Only", justify="center") + for lr in proj.linked_resources: + ro_marker = "yes" if lr.project_read_only else "no" + lr_table.add_row( + f"{lr.resource_id}", + "git_worktree", # default sandbox type for now + ro_marker, + ) + console.print(lr_table) + + # --- Invariants panel (table-style) --- + invariants = _get_project_invariants(proj.namespaced_name) + if invariants: + inv_table = Table(title=f"Invariants ({len(invariants)})", expand=False) + inv_table.add_column("#", style="dim") + inv_table.add_column("Constraint", overflow="fold") + for i, inv in enumerate(invariants, start=1): + inv_table.add_row(str(i), inv["text"]) + console.print(inv_table) + + # --- Validations panel (table-style) --- + validations = _get_project_validations( + proj.namespaced_name, proj.linked_resources + ) + if validations: + val_table = Table(title=f"Validations ({len(validations)})", expand=False) + val_table.add_column("Name", style="cyan") + val_table.add_column("Description", overflow="fold") + val_table.add_column("Mode", justify="right") + + for v in validations: + mode_style = "green" if v["mode"] == "required" else "yellow" # type: ignore[arg-type] + mode_display = f"[{mode_style}]{v['mode']}[/]" + desc = v.get("description", "") or "" + val_table.add_row(v["validation_name"], desc, mode_display) + + console.print(val_table) + + else: + console.print( + f"[dim]No validations attached to linked resources.[/dim]" + ) else: console.print(format_output(data, output_format)) -- 2.52.0