#!/usr/bin/env python3 """ OpenClaw System, Log & Repository Self-Audit Engine Analyzes container logs, session transcripts, and correlates error events with recent Git commits across infrapuzzle, mop-skills, and knowledge-wiki. """ import argparse import datetime import json import os import re import subprocess import sys from collections import defaultdict from pathlib import Path from typing import Any, Dict, List, Optional, Tuple # Default search directories (in-cluster vs local fallback) DEFAULT_CANDIDATE_PATHS = { "infrapuzzle": [ Path("/home/node/infrapuzzle/infrapuzzle"), Path("/home/node/.openclaw/repos/infrapuzzle"), Path("/home/node/infrapuzzle"), Path("/Users/moritz/src/infrapuzzle"), ], "mop-skills": [ Path("/home/node/mop-skills/mop-skills"), Path("/Users/moritz/src/mop-skills"), ], "knowledge-wiki": [ Path("/home/node/.openclaw/wiki/main"), Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"), ], } DEFAULT_SESSIONS_DIRS = [ Path("/home/node/.openclaw/agents"), Path.home() / ".openclaw/agents", ] def resolve_repo_path(repo_name: str, override: Optional[str] = None) -> Optional[Path]: if override: p = Path(override) if p.exists() and (p / ".git").exists(): return p for candidate in DEFAULT_CANDIDATE_PATHS.get(repo_name, []): if candidate.exists() and (candidate / ".git").exists(): return candidate return None def resolve_sessions_dir(override: Optional[str] = None) -> Optional[Path]: if override: p = Path(override) if p.exists(): return p for candidate in DEFAULT_SESSIONS_DIRS: if candidate.exists(): return candidate return None def resolve_wiki_dir(override: Optional[str] = None) -> Optional[Path]: return resolve_repo_path("knowledge-wiki", override) def run_cmd(cmd: List[str], cwd: Optional[Path] = None) -> Tuple[int, str, str]: try: proc = subprocess.run( cmd, cwd=str(cwd) if cwd else None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=120, ) return proc.returncode, proc.stdout, proc.stderr except Exception as e: return -1, "", str(e) def get_git_commits(repo_path: Path, since: str) -> List[Dict[str, str]]: if not repo_path or not repo_path.exists(): return [] # format: hash | iso_date | author | subject cmd = [ "git", "-C", str(repo_path), "log", f"--since={since}", '--pretty=format:%h%x09%ad%x09%an%x09%s', "--date=iso-strict", "-n", "20", ] rc, stdout, _ = run_cmd(cmd) if rc != 0 or not stdout.strip(): return [] commits = [] for line in stdout.strip().splitlines(): parts = line.split("\t") if len(parts) >= 4: commits.append({ "hash": parts[0], "date": parts[1], "author": parts[2], "subject": parts[3], }) return commits def normalize_error(msg: str) -> str: """Normalize error strings to group identical issues together.""" # Strip ISO timestamps s = re.sub(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)?", "", msg) # Strip UUIDs s = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "", s) # Strip toolCallIds and runIds s = re.sub(r"(call_[0-9a-zA-Z]+|runId=[0-9a-zA-Z-]+)", "", s) # Strip memory addresses s = re.sub(r"0x[0-9a-fA-F]+", "", s) # Strip excessive whitespace s = " ".join(s.split()) return s[:250] def fetch_container_logs(since: str, tail: int = 25000) -> str: """Fetch container stdout logs using kubectl or read from openclaw log files.""" # 1. Try in-cluster kubectl directly kubectl_bin = "/home/node/.openclaw/bin/kubectl" if Path("/home/node/.openclaw/bin/kubectl").exists() else "kubectl" cmd = [ kubectl_bin, "logs", f"--since={since}", f"--tail={tail}", "-n", "openclaw", "openclaw-0", "-c", "openclaw", ] rc, stdout, _ = run_cmd(cmd) if rc == 0 and stdout.strip(): return stdout # 2. Fallback: Check local logs directory local_log_dir = Path("/home/node/.openclaw/logs") if local_log_dir.exists(): combined = [] for log_file in local_log_dir.glob("*.log"): try: combined.append(log_file.read_text(errors="replace")) except Exception: pass if combined: return "\n".join(combined) return "" def parse_container_logs(raw_logs: str) -> List[Dict[str, Any]]: """Parse and cluster errors found in container stdout logs.""" error_clusters: Dict[str, Dict[str, Any]] = defaultdict(lambda: { "signature": "", "category": "GATEWAY", "count": 0, "first_seen": None, "last_seen": None, "sample": "", }) if not raw_logs: return [] lines = raw_logs.splitlines() for line in lines: line_lower = line.lower() # False positive exclusions if "iserror=false" in line_lower: continue if "[model-fetch] start" in line_lower: continue if "timeoutms=undefined" in line_lower and "status=200" in line_lower: continue if "response provider=" in line_lower and "status=200" in line_lower: continue is_error = False category = "GATEWAY" # Categorization patterns if "iserror=true" in line_lower or "tool failed" in line_lower or "[embedded run tool end: " in line and "error" in line_lower: is_error = True category = "TOOL" elif "mcp" in line_lower and ("exit" in line_lower or "crashed" in line_lower or "closed with code" in line_lower or "failed to start" in line_lower): is_error = True category = "MCP_SERVER" elif "unhandledrejection" in line_lower or "uncaughtexception" in line_lower or "traceback (most recent call last)" in line_lower or "fatal error" in line_lower: is_error = True category = "RUNTIME_EXCEPTION" elif any(f"status={code}" in line for code in ["500", "502", "503", "504", "429"]) or ("model-fetch" in line_lower and ("failed" in line_lower or "timed out" in line_lower)): is_error = True category = "LLM_PROVIDER" elif " [error] " in line_lower or "level=error" in line_lower or "econnrefused" in line_lower or "etimedout" in line_lower: is_error = True category = "SYSTEM_ERROR" if is_error: # Extract timestamp if present at beginning of line ts_match = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^\s]*)", line) ts = ts_match.group(1) if ts_match else "" sig = normalize_error(line) cluster = error_clusters[sig] cluster["signature"] = sig cluster["category"] = category cluster["count"] += 1 cluster["sample"] = line[:400] if ts: if not cluster["first_seen"] or ts < cluster["first_seen"]: cluster["first_seen"] = ts if not cluster["last_seen"] or ts > cluster["last_seen"]: cluster["last_seen"] = ts # Convert to sorted list by frequency sorted_clusters = sorted(error_clusters.values(), key=lambda x: x["count"], reverse=True) return sorted_clusters def parse_session_transcripts(sessions_dir: Path, since_hours: float = 168.0) -> List[Dict[str, Any]]: """Scan and parse JSONL session transcripts for tool errors.""" if not sessions_dir or not sessions_dir.exists(): return [] cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=since_hours) tool_failures: Dict[str, Dict[str, Any]] = defaultdict(lambda: { "agent": "", "tool": "", "signature": "", "count": 0, "sample": "", "first_seen": None, "last_seen": None, }) # Sessions are stored at /sessions/*.jsonl for jsonl_file in sessions_dir.glob("*/sessions/*.jsonl"): try: mtime = datetime.datetime.fromtimestamp(jsonl_file.stat().st_mtime, tz=datetime.timezone.utc) if mtime < cutoff: continue agent_name = jsonl_file.parent.parent.name with open(jsonl_file, "r", encoding="utf-8", errors="replace") as f: for line in f: if not line.strip(): continue try: record = json.loads(line) except Exception: continue # Check for tool errors in various OpenClaw transcript representations role = record.get("role") msg_type = record.get("type") is_err = record.get("isError", False) content = record.get("content", "") if isinstance(content, list): content_str = json.dumps(content) else: content_str = str(content) # Detect failure markers has_error_keyword = any(k in content_str.lower() for k in [ "tool failed", "error:", "traceback", "validationerror", "status code 5", "connection refused", "timeout", ]) if role == "tool" and (is_err or has_error_keyword): tool_name = record.get("name") or record.get("tool") or "unknown_tool" sig = f"[{agent_name}] {tool_name}: {normalize_error(content_str)}" entry = tool_failures[sig] entry["agent"] = agent_name entry["tool"] = tool_name entry["signature"] = sig entry["count"] += 1 entry["sample"] = content_str[:300] ts = record.get("createdAt") or record.get("timestamp") or mtime.isoformat() if not entry["first_seen"] or ts < entry["first_seen"]: entry["first_seen"] = ts if not entry["last_seen"] or ts > entry["last_seen"]: entry["last_seen"] = ts except Exception: continue return sorted(tool_failures.values(), key=lambda x: x["count"], reverse=True) def build_audit_report( since: str, repos_commits: Dict[str, List[Dict[str, str]]], container_errors: List[Dict[str, Any]], session_errors: List[Dict[str, Any]], ) -> Dict[str, Any]: """Compile structured audit report from all inputs.""" now = datetime.datetime.now(datetime.timezone.utc) year, week, _ = now.isocalendar() total_container_errors = sum(c["count"] for c in container_errors) total_session_tool_errors = sum(s["count"] for s in session_errors) # Determine severity has_critical = any(c["category"] in ["MCP_SERVER", "RUNTIME_EXCEPTION"] and c["count"] >= 3 for c in container_errors) has_high = total_session_tool_errors > 5 or total_container_errors > 20 if has_critical: status = "CRITICAL" elif has_high: status = "DEGRADED" elif total_container_errors > 0 or total_session_tool_errors > 0: status = "WARNING" else: status = "HEALTHY" return { "year": year, "week": week, "generated_at": now.isoformat(), "since": since, "status": status, "total_container_errors": total_container_errors, "total_session_tool_errors": total_session_tool_errors, "repos_commits": repos_commits, "container_errors": container_errors, "session_errors": session_errors, } def render_markdown_report(report: Dict[str, Any]) -> str: """Format audit report into clean Markdown with YAML frontmatter.""" year = report["year"] week = report["week"] status = report["status"] status_emoji = { "HEALTHY": "🟢", "WARNING": "🟡", "DEGRADED": "🟠", "CRITICAL": "🔴", }.get(status, "⚪") lines = [ "---", f'title: "Weekly OpenClaw System Audit {year}-W{week:02d}"', f'date: "{report["generated_at"]}"', f'status: "{status.lower()}"', f'evaluated_window: "{report["since"]}"', "type: audit-report", "---", "", f"# {status_emoji} OpenClaw System Health Audit — {year}-W{week:02d}", "", f"- **Generated At**: `{report['generated_at']}`", f"- **Audit Window**: Last `{report['since']}`", f"- **Overall System State**: **{status}**", f"- **Container Log Errors**: `{report['total_container_errors']}` occurrences", f"- **Session Tool Failures**: `{report['total_session_tool_errors']}` occurrences", "", "---", "", "## 🗂️ Recent Repository Commits Timeline", "", "Cross-repository correlation of recent code and configuration modifications:", "", ] repos = report.get("repos_commits", {}) for repo_name, commits in repos.items(): lines.append(f"### 📦 `{repo_name}` ({len(commits)} commits)") if not commits: lines.append("_No commits within this audit window._\n") continue lines.append("| Hash | Date | Author | Message |") lines.append("|---|---|---|---|") for c in commits[:8]: msg = c['subject'].replace("|", "\\|") lines.append(f"| `{c['hash']}` | {c['date'][:10]} | {c['author']} | {msg} |") lines.append("") lines.extend([ "---", "", "## 🛠️ Session Transcript Tool Failures", "", "Tool invocation errors across subagent conversations (`/home/node/.openclaw/agents/*/sessions/`):", "", ]) session_errs = report.get("session_errors", []) if not session_errs: lines.append("✅ **No tool failures detected in agent session transcripts.**\n") else: lines.append("| Agent | Tool | Count | Sample Error |") lines.append("|---|---|---|---|") for s in session_errs[:15]: sample_clean = s['sample'].replace("|", "\\|").replace("\n", " ")[:120] lines.append(f"| `{s['agent']}` | `{s['tool']}` | **{s['count']}** | `{sample_clean}` |") lines.append("") lines.extend([ "---", "", "## 🖥️ Container Runtime & Gateway Log Anomalies", "", "Aggregated errors from OpenClaw pod stdout (`kubectl logs -n openclaw openclaw-0 -c openclaw`):", "", ]) container_errs = report.get("container_errors", []) if not container_errs: lines.append("✅ **No runtime exceptions or container errors detected.**\n") else: lines.append("| Category | Count | Normalized Signature | Sample |") lines.append("|---|---|---|---|") for c in container_errs[:15]: sig_clean = c['signature'].replace("|", "\\|").replace("\n", " ")[:100] sample_clean = c['sample'].replace("|", "\\|").replace("\n", " ")[:100] lines.append(f"| `{c['category']}` | **{c['count']}** | `{sig_clean}` | `{sample_clean}` |") lines.append("") lines.extend([ "---", "", "## 🎯 Actionable Antigravity Recommendations", "", ]) if status == "HEALTHY": lines.append("All services, MCP bridges, and subagent tools operate within healthy parameters. No remediation required.") else: lines.append("The following candidate fixes should be investigated and resolved:") # List top issues for idx, s in enumerate(session_errs[:3], start=1): lines.append(f"{idx}. **Fix Tool Failure in `{s['agent']}` / `{s['tool']}`**: `{s['signature'][:140]}`") for idx, c in enumerate(container_errs[:3], start=len(session_errs[:3]) + 1): lines.append(f"{idx}. **Resolve `{c['category']}` Anomaly**: `{c['signature'][:140]}`") lines.append("") return "\n".join(lines) def update_tasks_antigravity(wiki_dir: Path, report: Dict[str, Any]) -> List[str]: """Append actionable issues to tasks/antigravity.md under ## 📥 Pending Tasks.""" tasks_file = wiki_dir / "tasks" / "antigravity.md" if not tasks_file.exists(): return [] try: content = tasks_file.read_text(encoding="utf-8") except Exception as e: print(f"Error reading {tasks_file}: {e}", file=sys.stderr) return [] added_tasks = [] today = datetime.datetime.now().strftime("%Y-%m-%d") # Combine top session and container errors top_candidates = [] for s in report.get("session_errors", []): if s["count"] >= 2: top_candidates.append({ "title": f"Fix Tool Failure: {s['agent']} / {s['tool']}", "detail": f"Observed {s['count']} times in session transcripts. Sample: {s['sample'][:250]}", "key": f"{s['agent']}:{s['tool']}", }) for c in report.get("container_errors", []): if c["count"] >= 3 and c["category"] in ["MCP_SERVER", "RUNTIME_EXCEPTION", "TOOL"]: top_candidates.append({ "title": f"Resolve {c['category']} Error in OpenClaw Gateway", "detail": f"Observed {c['count']} times in pod logs. Signature: {c['signature'][:200]}", "key": c["signature"][:40], }) # Only append if not already mentioned in tasks file pending_anchor = "## 📥 Pending Tasks\n" if pending_anchor not in content: return [] new_task_blocks = [] for cand in top_candidates: # Check deduplication if cand["title"] in content or cand["key"] in content: continue block = ( f"- [ ] **{today}: {cand['title']}**\n" f" - **Details**: Detected by IT Guy weekly self-audit (`audit_openclaw.py`). {cand['detail']}\n" f" - **Target**: OpenClaw Gateway / MCP / Subagent configuration\n" f" - **Requested by**: IT Guy Agent (Autonomous Weekly Audit)\n" ) new_task_blocks.append(block) added_tasks.append(cand["title"]) if new_task_blocks: parts = content.split(pending_anchor, 1) updated_content = parts[0] + pending_anchor + "\n" + "\n".join(new_task_blocks) + "\n" + parts[1] tasks_file.write_text(updated_content, encoding="utf-8") return added_tasks def git_commit_and_push(wiki_dir: Path, commit_msg: str) -> bool: """Commit and push changes in knowledge-wiki repository.""" rc1, _, _ = run_cmd(["git", "-C", str(wiki_dir), "add", "-A"]) if rc1 != 0: return False rc2, _, _ = run_cmd(["git", "-C", str(wiki_dir), "commit", "-m", commit_msg]) # rc 1 can mean nothing to commit rc3, _, _ = run_cmd(["git", "-C", str(wiki_dir), "push", "origin", "main"]) return rc3 == 0 def main(): parser = argparse.ArgumentParser(description="OpenClaw System, Log & Repository Self-Audit Engine") parser.add_argument("--since", default="7d", help="Audit time window (e.g. 7d, 24h, 168h)") parser.add_argument("--write-report", action="store_true", help="Save markdown report to operations/audits/ in knowledge-wiki") parser.add_argument("--add-task", action="store_true", help="Append new actionable issues to tasks/antigravity.md") parser.add_argument("--git-push", action="store_true", help="Commit and push changes to knowledge-wiki git remote") parser.add_argument("--json", action="store_true", help="Output machine-readable JSON to stdout") parser.add_argument("--infrapuzzle-path", help="Path to infrapuzzle repo override") parser.add_argument("--mop-skills-path", help="Path to mop-skills repo override") parser.add_argument("--wiki-path", help="Path to knowledge-wiki repo override") parser.add_argument("--sessions-dir", help="Path to OpenClaw sessions directory override") args = parser.parse_args() # Convert since string to hours for file timestamps since_hours = 168.0 if args.since.endswith("d"): since_hours = float(args.since[:-1]) * 24.0 elif args.since.endswith("h"): since_hours = float(args.since[:-1]) # 1. Resolve Repositories repos = { "infrapuzzle": resolve_repo_path("infrapuzzle", args.infrapuzzle_path), "mop-skills": resolve_repo_path("mop-skills", getattr(args, "mop_skills_path", None)), "knowledge-wiki": resolve_wiki_dir(args.wiki_path), } # 2. Correlate Git Commits repos_commits = {} for r_name, r_path in repos.items(): if r_path: repos_commits[r_name] = get_git_commits(r_path, args.since) else: repos_commits[r_name] = [] # 3. Ingest Container Logs raw_logs = fetch_container_logs(args.since) container_errors = parse_container_logs(raw_logs) # 4. Ingest Session Transcripts sessions_dir = resolve_sessions_dir(args.sessions_dir) session_errors = parse_session_transcripts(sessions_dir, since_hours) if sessions_dir else [] # 5. Build Report report = build_audit_report(args.since, repos_commits, container_errors, session_errors) # 6. JSON output mode if args.json: print(json.dumps(report, indent=2)) return # 7. Render Markdown md_report = render_markdown_report(report) # 8. Write Report if requested wiki_dir = repos.get("knowledge-wiki") if args.write_report and wiki_dir: audits_dir = wiki_dir / "operations" / "audits" audits_dir.mkdir(parents=True, exist_ok=True) report_path = audits_dir / f"{report['year']}-W{report['week']:02d}.md" report_path.write_text(md_report, encoding="utf-8") print(f"✅ Audit report saved: {report_path}") # 9. Append to tasks/antigravity.md if requested added_tasks = [] if args.add_task and wiki_dir: added_tasks = update_tasks_antigravity(wiki_dir, report) if added_tasks: print(f"📋 Added {len(added_tasks)} new task(s) to tasks/antigravity.md:") for t in added_tasks: print(f" - {t}") else: print("📋 No new tasks required (all issues healthy or already queued).") # 10. Commit & Push if requested if args.git_push and wiki_dir and (args.write_report or added_tasks): success = git_commit_and_push(wiki_dir, f"chore(audit): weekly health audit {report['year']}-W{report['week']:02d}") if success: print("🚀 Pushed audit report and task updates to git.moritzgraf.de!") else: print("⚠️ Git push skipped or failed (check credentials/remote).") # Print summary to stdout print(md_report) if __name__ == "__main__": main()