fix(cli): add Invariants and Validations panels to project show rich output #11019

Merged
HAL9000 merged 1 commits from pr/9460-project-show-invariants-validations into agents/final-working 2026-05-09 13:25:43 +00:00
5 changed files with 279 additions and 14 deletions
+8
View File
@@ -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.
+2
View File
@@ -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.
+18
View File
@@ -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
+62 -1
View File
@@ -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
+189 -13
View File
@@ -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))