fix(cli): add Invariants and Validations panels to project show rich output #11019
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user