""" Morning Report Generator — runs at 0600 to compile overnight activity. Gathers: cycles executed, issues closed, PRs merged, commits pushed. Outputs a structured report for delivery to the main channel. Includes a HEARTBEAT PANEL that checks all cron job heartbeats via bezalel_heartbeat_check.py (poka-yoke #1096). Any stale jobs surface as blockers in the report. """ import importlib.util import json import os import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path def generate_morning_report(): """Generate the morning report for the last 24h.""" now = datetime.now(timezone.utc) since = now - timedelta(hours=24) since_str = since.strftime("%Y-%m-%dT%H:%M:%SZ") repos = [ "Timmy_Foundation/timmy-home", "Timmy_Foundation/timmy-config", "Timmy_Foundation/the-nexus", "Timmy_Foundation/hermes-agent", ] report = { "generated_at": now.strftime("%Y-%m-%d %H:%M UTC"), "period": f"Last 24h since {since_str}", "highlights": [], "blockers": [], "repos": {}, } token = open(os.path.expanduser("~/.config/gitea/token")).read().strip() from urllib.request import Request, urlopen headers = {"Authorization": f"token {token}", "Accept": "application/json"} for repo in repos: repo_data = {"closed_issues": 0, "merged_prs": 0, "recent_commits": 0} # Closed issues in last 24h url = f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/issues?state=closed&since={since_str}" try: resp = urlopen(Request(url, headers=headers), timeout=10) issues = json.loads(resp.read()) repo_data["closed_issues"] = len(issues) for i in issues[:5]: report["highlights"].append(f"Closed {repo.split('/')[-1]}#{i['number']}: {i['title']}") except Exception: pass # Merged PRs url = f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/pulls?state=closed" try: resp = urlopen(Request(url, headers=headers), timeout=10) prs = json.loads(resp.read()) merged = [p for p in prs if p.get("merged")] repo_data["merged_prs"] = len(merged) except Exception: pass report["repos"][repo.split("/")[-1]] = repo_data # Check for stuck workers (blockers) worker_logs = list(Path("/tmp").glob("codeclaw-qwen-worker-*.log")) stuck = 0 for wf in worker_logs: try: data = json.loads(wf.read_text().strip()) if data.get("exit") != 0 and not data.get("has_work"): stuck += 1 except (json.JSONDecodeError, ValueError): pass if stuck > 0: report["blockers"].append(f"{stuck} worker(s) failed without producing work") # Check dead letter queue dlq_path = Path(os.path.expanduser("~/.local/timmy/burn-state/dead-letter.json")) if dlq_path.exists(): try: dlq = json.loads(dlq_path.read_text()) if dlq: report["blockers"].append(f"{len(dlq)} action(s) in dead letter queue") except Exception: pass # Checkpoint status cp_path = Path(os.path.expanduser("~/.local/timmy/burn-state/cycle-state.json")) if cp_path.exists(): try: cp = json.loads(cp_path.read_text()) if cp.get("status") == "in-progress": ts = cp.get("timestamp", "") if ts and datetime.fromisoformat(ts) < since: report["blockers"].append(f"Stale checkpoint: {cp.get('action')} since {ts}") except Exception: pass # Summary total_closed = sum(r["closed_issues"] for r in report["repos"].values()) total_merged = sum(r["merged_prs"] for r in report["repos"].values()) print(f"=== MORNING REPORT {report['generated_at']} ===") print(f"Period: {report['period']}") print(f"Issues closed: {total_closed}") print(f"PRs merged: {total_merged}") print("") if report["highlights"]: print("HIGHLIGHTS:") for h in report["highlights"]: print(f" + {h}") if report["blockers"]: print("BLOCKERS:") for b in report["blockers"]: print(f" - {b}") if not report["highlights"] and not report["blockers"]: print("No significant activity or blockers detected.") print("") # ── Heartbeat panel (poka-yoke #1096) ──────────────────────────────────── # Import bezalel_heartbeat_check via importlib so we don't need __init__.py # or a sys.path hack. If the module is missing or the dir doesn't exist, # we print a "not provisioned" notice and continue — never crash the report. _hb_result = None try: _project_root = Path(__file__).parent.parent _hb_spec = importlib.util.spec_from_file_location( "bezalel_heartbeat_check", _project_root / "bin" / "bezalel_heartbeat_check.py", ) if _hb_spec is not None: _hb_mod = importlib.util.module_from_spec(_hb_spec) sys.modules.setdefault("bezalel_heartbeat_check", _hb_mod) _hb_spec.loader.exec_module(_hb_mod) # type: ignore[union-attr] _hb_result = _hb_mod.check_cron_heartbeats() except Exception: _hb_result = None print("HEARTBEAT PANEL:") if _hb_result is None or not _hb_result.get("jobs"): print(" HEARTBEAT PANEL: no data (bezalel not provisioned)") report["heartbeat_panel"] = {"status": "not_provisioned"} else: for _job in _hb_result["jobs"]: _prefix = "+" if _job["healthy"] else "-" print(f" {_prefix} {_job['job']}: {_job['message']}") if not _job["healthy"]: report["blockers"].append( f"Stale heartbeat: {_job['job']} — {_job['message']}" ) print("") report["heartbeat_panel"] = { "checked_at": _hb_result.get("checked_at"), "healthy_count": _hb_result.get("healthy_count", 0), "stale_count": _hb_result.get("stale_count", 0), "jobs": _hb_result.get("jobs", []), } # Save report report_dir = Path(os.path.expanduser("~/.local/timmy/reports")) report_dir.mkdir(parents=True, exist_ok=True) report_file = report_dir / f"morning-{now.strftime('%Y-%m-%d')}.json" report_file.write_text(json.dumps(report, indent=2)) print(f"Report saved: {report_file}") return report if __name__ == "__main__": generate_morning_report()