2026-08-06 15:56:51 -03:00
|
|
|
#!/usr/bin/env python3
|
2026-08-06 16:41:30 -03:00
|
|
|
"""Narrate the triage flow live, showing every command it runs.
|
2026-08-06 15:56:51 -03:00
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
Run in a second terminal beside the demo. When a stage of
|
|
|
|
|
`mermaid/TestAutomation.mmd` is reached this prints, in order:
|
2026-08-06 15:56:51 -03:00
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
the stage banner, naming the chart subgraph
|
|
|
|
|
the service UI to look at, if one changes at that stage
|
|
|
|
|
each command, echoed before it runs, then its output
|
2026-08-06 15:56:51 -03:00
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
Echoing the commands is the point. An audience watching a dashboard has to
|
|
|
|
|
take the result on trust; watching `kubectl` run against the cluster and
|
|
|
|
|
reading the raw answer is the difference between a demonstration and an
|
|
|
|
|
assertion.
|
2026-08-06 15:56:51 -03:00
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
Two honest limits. This reports at subgraph granularity, not per node: the
|
|
|
|
|
chart draws the collector, response check, marker check and authorizer
|
|
|
|
|
separately, and a refusal here names the gate rather than walking all twelve.
|
|
|
|
|
And it follows one incident on one job, whereas the chart describes the whole
|
|
|
|
|
system.
|
2026-08-06 16:25:39 -03:00
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
Read-only. Every command below is a read; nothing here changes the cluster.
|
2026-08-06 15:56:51 -03:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
JOB = os.environ.get("MONITOR_JOB", "hermes-triage-demo")
|
|
|
|
|
NS_DEMO = "hermes-triage-demo"
|
|
|
|
|
NS_ARIADNE = "maintenance"
|
2026-08-06 16:41:30 -03:00
|
|
|
JENKINS = os.environ.get("JENKINS_URL", "https://ci.bstein.dev")
|
|
|
|
|
GITEA = os.environ.get("GITEA_URL", "https://scm.bstein.dev")
|
|
|
|
|
GRAFANA = os.environ.get("GRAFANA_URL", "https://metrics.bstein.dev")
|
|
|
|
|
HERMES_UI = os.environ.get("HERMES_URL", "https://agent.bstein.dev")
|
2026-08-06 15:56:51 -03:00
|
|
|
POLL_SECONDS = 4
|
|
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
BOLD, DIM, GREEN, CYAN, YELLOW, RED, RESET = (
|
|
|
|
|
"\033[1m", "\033[2m", "\033[32m", "\033[36m", "\033[33m", "\033[31m", "\033[0m"
|
2026-08-06 15:56:51 -03:00
|
|
|
)
|
|
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
# stage key -> (chart subgraph, what it means, chart path, UI worth showing)
|
|
|
|
|
STAGES: dict[str, tuple[str, str, str, str]] = {
|
|
|
|
|
"detect": (
|
|
|
|
|
"Detect and gather",
|
|
|
|
|
"A failed build becomes one incident",
|
|
|
|
|
"intake: detector -> evidence_sources",
|
|
|
|
|
f"{JENKINS}/job/{JOB}/ — the red build",
|
|
|
|
|
),
|
|
|
|
|
"evidence": (
|
|
|
|
|
"Detect and gather",
|
|
|
|
|
"Ariadne assembles the bounded evidence bundle",
|
|
|
|
|
"intake: collector -> bundle",
|
|
|
|
|
"",
|
|
|
|
|
),
|
|
|
|
|
"hermes": (
|
|
|
|
|
"Hermes analysis",
|
|
|
|
|
"Hermes returns a recommendation it cannot act on",
|
|
|
|
|
"hermes_plane: skills -> recommendation",
|
|
|
|
|
f"{HERMES_UI} — the agent run appears here",
|
|
|
|
|
),
|
|
|
|
|
"gates": (
|
|
|
|
|
"Ariadne policy gates",
|
|
|
|
|
"Ariadne decides on its own reading of the evidence",
|
|
|
|
|
"controls: response_check -> authorizer",
|
|
|
|
|
"",
|
|
|
|
|
),
|
|
|
|
|
"route": (
|
|
|
|
|
"Ariadne response",
|
|
|
|
|
"Which edge off the route diamond is taken",
|
|
|
|
|
"response: route",
|
|
|
|
|
"",
|
|
|
|
|
),
|
|
|
|
|
"response": (
|
|
|
|
|
"Ariadne response",
|
|
|
|
|
"Ariadne executes, proposes, or escalates",
|
|
|
|
|
"response: operational_path / code_path / human_path",
|
|
|
|
|
"",
|
|
|
|
|
),
|
|
|
|
|
"verify": (
|
|
|
|
|
"Ariadne response",
|
|
|
|
|
"One rebuild decides whether the repair held",
|
|
|
|
|
"response: operational_result -> validation build",
|
|
|
|
|
f"{JENKINS}/job/{JOB}/ — a new build starts on its own",
|
|
|
|
|
),
|
|
|
|
|
"outputs": (
|
|
|
|
|
"Inspectable outputs",
|
|
|
|
|
"Incident closed; the artifacts remain",
|
|
|
|
|
"outputs: audit_events, alert_output, issue_output",
|
|
|
|
|
f"{GRAFANA}/d/atlas-testing — triage panels",
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
ORDER = ["detect", "evidence", "hermes", "gates", "route", "response", "verify", "outputs"]
|
2026-08-06 15:56:51 -03:00
|
|
|
|
|
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
def stamp() -> str:
|
|
|
|
|
return datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run(cmd: list[str], *, show: bool = True, limit: int = 12) -> str:
|
|
|
|
|
"""Echo a command, run it, print its output, and return stdout."""
|
|
|
|
|
|
|
|
|
|
if show:
|
|
|
|
|
print(f" {DIM}${RESET} {CYAN}{' '.join(cmd)}{RESET}")
|
2026-08-06 15:56:51 -03:00
|
|
|
try:
|
2026-08-06 16:41:30 -03:00
|
|
|
done = subprocess.run(cmd, capture_output=True, text=True, timeout=25)
|
|
|
|
|
out = done.stdout.strip()
|
|
|
|
|
except Exception as exc: # a failed read must never stop the narration
|
|
|
|
|
print(f" {RED}(command failed: {exc}){RESET}")
|
2026-08-06 15:56:51 -03:00
|
|
|
return ""
|
2026-08-06 16:41:30 -03:00
|
|
|
if show:
|
|
|
|
|
for line in (out.split("\n")[:limit] or ["(no output)"]):
|
|
|
|
|
print(f" {line[:150]}")
|
|
|
|
|
print()
|
|
|
|
|
return out
|
2026-08-06 15:56:51 -03:00
|
|
|
|
|
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
def quiet(cmd: list[str]) -> str:
|
|
|
|
|
return run(cmd, show=False)
|
2026-08-06 15:56:51 -03:00
|
|
|
|
|
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
def banner(key: str) -> None:
|
|
|
|
|
subgraph, meaning, path, ui = STAGES[key]
|
|
|
|
|
print(f"\n{GREEN}{'─' * 70}{RESET}")
|
|
|
|
|
print(f"{GREEN}{BOLD} {stamp()} {subgraph}{RESET}{GREEN} — {meaning}{RESET}")
|
|
|
|
|
print(f"{DIM} chart: {path}{RESET}")
|
|
|
|
|
if ui:
|
|
|
|
|
print(f"{YELLOW} look at: {ui}{RESET}")
|
|
|
|
|
print(f"{GREEN}{'─' * 70}{RESET}")
|
2026-08-06 15:56:51 -03:00
|
|
|
|
|
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
def ariadne_records(tail: int = 400) -> list[dict]:
|
|
|
|
|
raw = quiet(
|
|
|
|
|
["kubectl", "-n", NS_ARIADNE, "logs", "deploy/ariadne", "-c", "ariadne", f"--tail={tail}"]
|
|
|
|
|
)
|
2026-08-06 15:56:51 -03:00
|
|
|
records = []
|
|
|
|
|
for line in raw.split("\n"):
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if line.startswith("{"):
|
|
|
|
|
try:
|
|
|
|
|
records.append(json.loads(line))
|
|
|
|
|
except ValueError:
|
|
|
|
|
continue
|
|
|
|
|
return records
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def job_state(records: list[dict]) -> dict:
|
|
|
|
|
for record in reversed(records):
|
|
|
|
|
if record.get("event") == "hermes_autotriage" and record.get("jobs"):
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(record["jobs"]).get(JOB) or {}
|
|
|
|
|
except ValueError:
|
|
|
|
|
return {}
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
def evidence_for(key: str) -> None:
|
|
|
|
|
"""Run the reads that show this stage actually happened."""
|
|
|
|
|
|
|
|
|
|
if key == "detect":
|
|
|
|
|
run(["kubectl", "-n", NS_DEMO, "get", "cm", "hermes-triage-demo-fixture",
|
|
|
|
|
"-o", "jsonpath={.data.state}"])
|
|
|
|
|
elif key == "evidence":
|
|
|
|
|
print(f" {DIM}the bundle is what Ariadne sends Hermes; the audit trail keeps it{RESET}\n")
|
|
|
|
|
run(["kubectl", "-n", NS_ARIADNE, "exec", "deploy/ariadne", "-c", "ariadne", "--",
|
|
|
|
|
"printenv", "ARIADNE_HERMES_AUTOTRIAGE_JOB_ALLOWLIST"], limit=2)
|
|
|
|
|
elif key == "hermes":
|
|
|
|
|
run(["kubectl", "-n", "hermes", "get", "pods", "-l", "app=hermes", "--no-headers"], limit=3)
|
|
|
|
|
print(f" {DIM}Hermes holds no Git or Kubernetes write access; it returns JSON only{RESET}\n")
|
|
|
|
|
elif key == "gates":
|
|
|
|
|
for var in ("ARIADNE_HERMES_ALLOWED_ACTIONS", "ARIADNE_HERMES_MIN_CONFIDENCE"):
|
|
|
|
|
run(["kubectl", "-n", NS_ARIADNE, "exec", "deploy/ariadne", "-c", "ariadne", "--",
|
|
|
|
|
"printenv", var], limit=2)
|
|
|
|
|
elif key in {"response", "outputs"}:
|
|
|
|
|
run(["kubectl", "-n", NS_DEMO, "get", "cm", "hermes-triage-demo-fixture",
|
|
|
|
|
"-o", "jsonpath={.data.state}"])
|
|
|
|
|
if key == "outputs":
|
|
|
|
|
print(f" {YELLOW}issues filed by triage: {GITEA}/bstein/ariadne/issues{RESET}\n")
|
|
|
|
|
elif key == "verify":
|
|
|
|
|
print(f" {DIM}exactly one rebuild is triggered; it never retries in a loop{RESET}\n")
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 15:56:51 -03:00
|
|
|
class Monitor:
|
|
|
|
|
"""Track which flow-chart stage the current incident has reached."""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
2026-08-06 16:41:30 -03:00
|
|
|
self.done: set[str] = set()
|
2026-08-06 15:56:51 -03:00
|
|
|
self.incident = ""
|
2026-08-06 16:07:03 -03:00
|
|
|
self.summarised = False
|
2026-08-06 16:41:30 -03:00
|
|
|
self.last_tick = ""
|
2026-08-06 15:56:51 -03:00
|
|
|
|
|
|
|
|
def mark(self, key: str, evidence: str) -> None:
|
|
|
|
|
if key in self.done:
|
|
|
|
|
return
|
2026-08-06 16:41:30 -03:00
|
|
|
self.done.add(key)
|
|
|
|
|
banner(key)
|
|
|
|
|
print(f" {BOLD}what happened:{RESET} {evidence}\n")
|
|
|
|
|
evidence_for(key)
|
2026-08-06 15:56:51 -03:00
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
def new_incident(self, incident: str) -> None:
|
2026-08-06 15:56:51 -03:00
|
|
|
if incident and incident != self.incident:
|
|
|
|
|
self.incident = incident
|
|
|
|
|
self.done.clear()
|
2026-08-06 16:07:03 -03:00
|
|
|
self.summarised = False
|
2026-08-06 16:41:30 -03:00
|
|
|
print(f"\n{BOLD}{YELLOW}══ incident {incident} ══{RESET}")
|
|
|
|
|
|
|
|
|
|
def checklist(self) -> None:
|
|
|
|
|
print(f"\n{BOLD} flow chart progress{RESET}")
|
|
|
|
|
for key in ORDER:
|
|
|
|
|
tick = f"{GREEN}✓{RESET}" if key in self.done else f"{DIM}·{RESET}"
|
|
|
|
|
print(f" {tick} {key:<9} {STAGES[key][0]}")
|
|
|
|
|
print()
|
2026-08-06 15:56:51 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
2026-08-06 16:41:30 -03:00
|
|
|
print(f"{BOLD}Hermes triage monitor — following mermaid/TestAutomation.mmd{RESET}")
|
|
|
|
|
print(f"{DIM}Every command is echoed before it runs. Read-only. Ctrl-C to stop.{RESET}")
|
|
|
|
|
print(f"{YELLOW}Jenkins: {JENKINS}/job/{JOB}/{RESET}")
|
|
|
|
|
print(f"{YELLOW}Gitea: {GITEA}/bstein{RESET}")
|
|
|
|
|
print(f"{YELLOW}Grafana: {GRAFANA}/d/atlas-testing{RESET}\n")
|
2026-08-06 15:56:51 -03:00
|
|
|
monitor = Monitor()
|
|
|
|
|
while True:
|
2026-08-06 16:41:30 -03:00
|
|
|
state = job_state(ariadne_records())
|
2026-08-06 15:56:51 -03:00
|
|
|
status = str(state.get("status") or "")
|
2026-08-06 16:41:30 -03:00
|
|
|
monitor.new_incident(str(state.get("incident_id") or ""))
|
2026-08-06 15:56:51 -03:00
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
if status in {"human_required", "awaiting_rebuild", "deduped", "failed"}:
|
|
|
|
|
monitor.mark("detect", f"incident {monitor.incident} opened from a terminal failure")
|
2026-08-06 15:56:51 -03:00
|
|
|
if status == "awaiting_rebuild":
|
2026-08-06 16:41:30 -03:00
|
|
|
monitor.mark("evidence", "bundle collected: console regions, tests, logs")
|
2026-08-06 15:56:51 -03:00
|
|
|
monitor.mark("hermes", "diagnosis returned and parsed against the frozen schema")
|
2026-08-06 16:41:30 -03:00
|
|
|
monitor.mark("gates", "every gate passed; the predefined action was authorized")
|
2026-08-06 16:25:39 -03:00
|
|
|
monitor.mark("route", "route --|authorized action|--> registry -> operational_result")
|
2026-08-06 16:41:30 -03:00
|
|
|
monitor.mark("response", f"{state.get('repair', 'action')} on {state.get('target', '')}")
|
2026-08-06 15:56:51 -03:00
|
|
|
monitor.mark("verify", "one rebuild triggered with seeding disabled")
|
|
|
|
|
if status == "human_required":
|
|
|
|
|
monitor.mark("evidence", "bundle collected")
|
|
|
|
|
monitor.mark("hermes", "diagnosis returned")
|
2026-08-06 16:41:30 -03:00
|
|
|
monitor.mark("gates", f"Ariadne refused: {state.get('reason', '')} — nothing ran")
|
2026-08-06 16:25:39 -03:00
|
|
|
monitor.mark("route", "route --|human required|--> human_required -> gitea_issue")
|
2026-08-06 16:41:30 -03:00
|
|
|
monitor.mark("response", "escalated; issue filed in the service repository")
|
2026-08-06 16:07:03 -03:00
|
|
|
if status == "healthy" and state.get("resolved") and not monitor.summarised:
|
2026-08-06 15:56:51 -03:00
|
|
|
monitor.mark("verify", "rebuild finished green")
|
|
|
|
|
monitor.mark("outputs", f"resolved: {', '.join(state['resolved'])}")
|
|
|
|
|
monitor.checklist()
|
2026-08-06 16:41:30 -03:00
|
|
|
# The resolving tick stays newest until another incident opens.
|
2026-08-06 16:07:03 -03:00
|
|
|
monitor.summarised = True
|
2026-08-06 15:56:51 -03:00
|
|
|
|
2026-08-06 16:41:30 -03:00
|
|
|
if status and status != monitor.last_tick:
|
|
|
|
|
monitor.last_tick = status
|
|
|
|
|
print(f"{DIM}{stamp()} tick: {status}{RESET}")
|
|
|
|
|
sys.stdout.flush()
|
2026-08-06 15:56:51 -03:00
|
|
|
time.sleep(POLL_SECONDS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
try:
|
|
|
|
|
main()
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
print("\nstopped")
|