Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

489 lines
15 KiB
Python

"""Slash command /rca — browse persisted RCA reports across sessions."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from rich.console import Console
from rich.markup import escape
from core.agent_harness.session import default_session_repo
from surfaces.interactive_shell.command_registry.investigation import (
render_investigation_report,
write_investigation_export,
)
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
WARNING,
print_repl_table,
repl_table,
)
from surfaces.interactive_shell.ui.components.choice_menu import (
CRUMB_SEP,
prepare_repl_output_line,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
_RCA_ROOT = "/rca"
_RCA_LATEST = "__latest__"
_RCA_HISTORY = "__history__"
_RCA_SAVE = "__save__"
_HISTORY_ALIASES = frozenset({"history", "list", "ls"})
_EXPORT_SUFFIXES = frozenset({".md", ".json"})
def _investigation_id(record: dict[str, object]) -> str:
return str(record.get("investigation_id") or "")
def _record_timestamp(record: dict[str, object], *, style: str) -> str:
return format_repl_timestamp(record.get("completed_at"), style=style) # type: ignore[arg-type]
def _rca_breadcrumb(suffix: str) -> str:
return _RCA_ROOT if not suffix else f"{_RCA_ROOT}{CRUMB_SEP}{suffix}"
def _rca_record_label(record: dict[str, object]) -> str:
inv_id = _investigation_id(record) or "—"
completed = _record_timestamp(record, style="compact")
preview = str(record.get("root_cause_preview") or "—")
if len(preview) > 44:
preview = preview[:41] + "…"
trigger = str(record.get("trigger") or "").strip()
trigger_part = f" {trigger[:28]}" if trigger else ""
return f"{inv_id[:8]} {completed} {preview}{trigger_part}"
def _print_rca_empty(console: Console) -> None:
console.print(f"[{DIM}]no persisted RCA reports yet.[/]")
console.print(
f"[{DIM}]run an investigation with[/] [{WARNING}]/investigate[/] "
f"[{DIM}]to populate history.[/]"
)
def _require_rca_records(console: Console) -> list[dict[str, object]] | None:
records = default_session_repo().load_investigation_history()
if not records:
_print_rca_empty(console)
return None
return records
def _rca_record_export_state(record: dict[str, object]) -> dict[str, object]:
report = str(record.get("report") or "")
return {
"investigation_id": record.get("investigation_id"),
"session_id": record.get("session_id"),
"completed_at": record.get("completed_at"),
"trigger": record.get("trigger"),
"root_cause": record.get("root_cause"),
"problem_md": report,
"report": report,
"root_cause_category": record.get("root_cause_category"),
"alert_name": record.get("alert_name"),
"run_id": record.get("run_id"),
}
def _print_rca_lookup_failure(
console: Console,
investigation_id: str,
*,
match_count: int,
) -> None:
if match_count > 1:
console.print(
f"[{WARNING}]ambiguous id prefix:[/] {escape(investigation_id)} "
f"[{DIM}]({match_count} matches — use more characters)[/]"
)
return
console.print(f"[{ERROR}]RCA report not found:[/] {escape(investigation_id)}")
def _resolve_rca_record(
investigation_id: str | None,
*,
records: list[dict[str, object]] | None = None,
) -> dict[str, object] | None:
repo = default_session_repo()
if investigation_id:
loaded = repo.load_investigation(investigation_id)
if loaded is not None:
return loaded
if records:
for record in records:
inv_id = _investigation_id(record)
if inv_id.startswith(investigation_id):
return record
return None
history = records or repo.load_investigation_history()
if not history:
return None
latest = history[0]
inv_id = _investigation_id(latest)
if not inv_id:
return latest
return repo.load_investigation(inv_id) or latest
def _strip_outer_quotes(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1].strip()
return value
def _normalize_rca_save_path(raw_path: str, *, investigation_id: str = "") -> Path:
"""Normalize user-entered save paths (strip quotes, expand ~, folder → file)."""
value = _strip_outer_quotes(raw_path.strip())
treat_as_dir = value.endswith(("/", "\\"))
dest = Path(value).expanduser()
if dest.suffix.lower() not in _EXPORT_SUFFIXES and (treat_as_dir or dest.is_dir()):
dest = dest / f"rca-{investigation_id[:8] or 'report'}.md"
return dest
def _save_rca_record(console: Console, record: dict[str, object], dest_path: str) -> bool:
inv_id = _investigation_id(record)
dest = _normalize_rca_save_path(dest_path, investigation_id=inv_id)
try:
dest.parent.mkdir(parents=True, exist_ok=True)
write_investigation_export(
dest,
root_cause=str(record.get("root_cause") or ""),
report=str(record.get("report") or ""),
full_state=_rca_record_export_state(record),
)
console.print(f"[{HIGHLIGHT}]saved:[/] {escape(str(dest))}")
except IsADirectoryError:
console.print(
f"[{ERROR}]save failed:[/] {escape(str(dest))} is a directory — "
f"include a filename (e.g. [{WARNING}]report.md[/])"
)
except Exception as exc:
report_exception(exc, context="surfaces.interactive_shell.rca_save")
console.print(f"[{ERROR}]save failed:[/] {escape(str(exc))}")
return True
def _prompt_rca_save_path(console: Console) -> str | None:
console.print()
console.print(
f"[{DIM}]Enter output file or folder (.md or .json). "
f"Example:[/] [{WARNING}]rca-report.md[/] "
f"[{DIM}]or[/] [{WARNING}]/Users/you/Downloads/rca reports/[/]"
)
try:
value = console.input(f"[{HIGHLIGHT}]file path> [/]").strip()
except (EOFError, KeyboardInterrupt):
return None
return value or None
def _report_picker_choices(
records: list[dict[str, object]],
*,
include_latest: bool,
) -> list[tuple[str, str]]:
choices: list[tuple[str, str]] = []
if include_latest:
choices.append((_RCA_LATEST, "latest"))
choices.extend(
(inv_id, _rca_record_label(record))
for record in records
if (inv_id := _investigation_id(record))
)
choices.append(("done", "done"))
return choices
def _pick_rca_report(
records: list[dict[str, object]],
*,
breadcrumb_suffix: str,
include_latest: bool = False,
) -> str | None:
picked = repl_choose_one(
title="rca report",
breadcrumb=_rca_breadcrumb(breadcrumb_suffix),
choices=_report_picker_choices(records, include_latest=include_latest),
)
if picked is None or picked == "done":
return None
return picked
def _picked_investigation_id(picked: str, records: list[dict[str, object]]) -> str:
if picked == _RCA_LATEST:
return _investigation_id(records[0])
return picked
def _interactive_rca_report_menu(
session: Session,
console: Console,
*,
breadcrumb_suffix: str,
include_latest: bool,
on_pick: Callable[[Session, Console, dict[str, object]], bool],
) -> bool:
records = _require_rca_records(console)
if records is None:
return True
picked = _pick_rca_report(
records,
breadcrumb_suffix=breadcrumb_suffix,
include_latest=include_latest,
)
if picked is None:
return True
record = _resolve_rca_record(_picked_investigation_id(picked, records), records=records)
if record is None:
console.print(f"[{DIM}]RCA report not found.[/]")
return True
return on_pick(session, console, record)
def _interactive_show_record(
session: Session,
console: Console,
record: dict[str, object],
) -> bool:
inv_id = _investigation_id(record)
if not inv_id:
return True
_cmd_rca_show(session, console, inv_id, record=record)
repl_section_break(console)
return True
def _interactive_save_record(
_session: Session,
console: Console,
record: dict[str, object],
) -> bool:
dest_path = _prompt_rca_save_path(console)
if dest_path is None:
return True
return _save_rca_record(console, record, dest_path)
def _interactive_rca_history_menu(session: Session, console: Console) -> bool:
return _interactive_rca_report_menu(
session,
console,
breadcrumb_suffix="history",
include_latest=False,
on_pick=_interactive_show_record,
)
def _interactive_rca_save_menu(session: Session, console: Console) -> bool:
return _interactive_rca_report_menu(
session,
console,
breadcrumb_suffix="save",
include_latest=True,
on_pick=_interactive_save_record,
)
def _interactive_rca_root_menu(session: Session, console: Console) -> bool:
records = _require_rca_records(console)
if records is None:
return True
picked = repl_choose_one(
title="rca report",
breadcrumb=_RCA_ROOT,
choices=[
(_RCA_LATEST, "latest"),
(_RCA_HISTORY, "history"),
(_RCA_SAVE, "save"),
("done", "done"),
],
)
if picked is None or picked == "done":
return True
if picked == _RCA_HISTORY:
return _interactive_rca_history_menu(session, console)
if picked == _RCA_SAVE:
return _interactive_rca_save_menu(session, console)
latest_id = _investigation_id(records[0])
if not latest_id:
return True
return _interactive_show_record(session, console, records[0])
def _cmd_rca_history(_session: Session, console: Console) -> bool:
records = _require_rca_records(console)
if records is None:
return True
table = repl_table(title="RCA history\n", title_style=BOLD_BRAND)
table.add_column("#", style="bold", justify="right")
table.add_column("ID", style="bold")
table.add_column("Completed")
table.add_column("Trigger", overflow="fold")
table.add_column("Root cause", overflow="fold", style=DIM)
for index, record in enumerate(records, start=1):
table.add_row(
str(index),
_investigation_id(record) or "—",
_record_timestamp(record, style="table"),
escape(str(record.get("trigger") or record.get("session_name") or "—")),
escape(str(record.get("root_cause_preview") or "—")),
)
print_repl_table(console, table)
console.print(
f"[{DIM}]show full report:[/] [{WARNING}]/rca show <id>[/] "
f"[{DIM}]save:[/] [{WARNING}]/rca save <path>[/] "
f"[{DIM}]or[/] [{WARNING}]/rca save <id> <path>[/]"
)
return True
def _print_rca_record_header(console: Console, record: dict[str, object]) -> None:
console.print()
console.print(
f"[{DIM}]id[/] [bold]{escape(_investigation_id(record))}[/] "
f"[{DIM}]session[/] {escape(str(record.get('session_id') or '')[:8])} "
f"[{DIM}]completed[/] {escape(_record_timestamp(record, style='table'))}"
)
trigger = str(record.get("trigger") or "").strip()
if trigger:
console.print(f"[{DIM}]trigger[/] {escape(trigger)}")
def _cmd_rca_show(
_session: Session,
console: Console,
investigation_id: str,
*,
record: dict[str, object] | None = None,
) -> bool:
if record is not None:
resolved = record
else:
loaded, match_count = default_session_repo().lookup_investigation(investigation_id)
if match_count != 1:
_print_rca_lookup_failure(console, investigation_id, match_count=match_count)
return True
if loaded is None:
_print_rca_lookup_failure(console, investigation_id, match_count=0)
return True
resolved = loaded
_print_rca_record_header(console, resolved)
render_investigation_report(
console,
root_cause=str(resolved.get("root_cause") or ""),
report=str(resolved.get("report") or ""),
)
return True
def _cmd_rca_save(
_session: Session,
console: Console,
*,
investigation_id: str | None,
dest_path: str,
) -> bool:
if investigation_id:
record, match_count = default_session_repo().lookup_investigation(investigation_id)
if match_count != 1:
_print_rca_lookup_failure(console, investigation_id, match_count=match_count)
return True
if record is None:
_print_rca_lookup_failure(console, investigation_id, match_count=0)
return True
else:
record = _resolve_rca_record(None)
if record is None:
_print_rca_empty(console)
return True
return _save_rca_record(console, record, dest_path)
def _cmd_rca(_session: Session, console: Console, args: list[str]) -> bool:
prepare_repl_output_line()
if not args:
if repl_tty_interactive():
return _interactive_rca_root_menu(_session, console)
return _cmd_rca_history(_session, console)
sub = args[0].lower().strip()
if sub in _HISTORY_ALIASES:
if repl_tty_interactive():
return _interactive_rca_history_menu(_session, console)
return _cmd_rca_history(_session, console)
if sub == "show":
if len(args) < 2:
if repl_tty_interactive():
return _interactive_rca_root_menu(_session, console)
console.print(f"[{DIM}]usage:[/] /rca show <investigation-id-prefix>")
return True
return _cmd_rca_show(_session, console, args[1])
if sub == "save":
if len(args) == 1:
if repl_tty_interactive():
return _interactive_rca_save_menu(_session, console)
console.print(
f"[{DIM}]usage:[/] /rca save <path> "
f"[{DIM}]or[/] /rca save <investigation-id> <path>"
)
return True
if len(args) == 2:
return _cmd_rca_save(_session, console, investigation_id=None, dest_path=args[1])
return _cmd_rca_save(_session, console, investigation_id=args[1], dest_path=args[2])
console.print(
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
f"(try [bold]/rca history[/bold], [bold]/rca show <id>[/bold], "
f"or [bold]/rca save <path>[/bold])"
)
return True
_RCA_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("history", "list persisted RCA reports across sessions"),
("show", "show one RCA report by investigation id"),
("save", "save an RCA report to a file (.md or .json)"),
)
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/rca",
"Browse persisted RCA investigation reports.",
_cmd_rca,
usage=(
"/rca",
"/rca history",
"/rca show <investigation-id>",
"/rca save <path>",
"/rca save <investigation-id> <path>",
),
first_arg_completions=_RCA_FIRST_ARGS,
),
]
__all__ = ["COMMANDS"]