#!/usr/bin/python3 """AI Agent 升级迭代日报 — 抓取 + 存档 + 报告管道 (B1 阶段).""" # LLM-INTEGRATION-POINT: B2 将替换 summarize_with_llm 函数体 # (litellm analysis, POST http://127.0.0.1:4000/v1/chat/completions, # model="analysis", 无 auth, timeout=LLM_TIMEOUT) import json import logging import os import re import sqlite3 import subprocess import sys import time import xml.etree.ElementTree as ET from concurrent import futures from datetime import datetime from pathlib import Path import requests # blog-app root on sys.path so the analysis service (Stage 1 refactor) is # importable from cron/systemd contexts where CWD is not the app root. sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import config # noqa: E402 (needs the sys.path insert above) logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", stream=sys.stdout, ) log = logging.getLogger("daily-watchdog") BASE_DIR = Path.home() / "opencode-blog-showcase" DATA_DIR = BASE_DIR / "data" LOGS_DIR = BASE_DIR / "logs" DB_PATH = DATA_DIR / "watchdog.db" UA = "daily-watchdog/1.0" HTTP_TIMEOUT = 30 GITHUB_TIMEOUT = 30 EXA_TIMEOUT = 60 ATOM_TIMEOUT = 45 LLM_URL = "http://127.0.0.1:4000/v1/chat/completions" LLM_MODEL = "analysis" LLM_TIMEOUT = 300 # Prompt trimming budgets shared with services/analysis.py (OPT-10). LLM_BODY_LIMIT = config.LLM_BODY_LIMIT LLM_FIELD_LIMIT = config.LLM_FIELD_LIMIT LLM_MESSAGE_LIMIT = config.LLM_MESSAGE_LIMIT LLM_TOTAL_LIMIT = config.LLM_TOTAL_LIMIT # Parallel collection (OPT-9): 6 projects x 5 sources + 1 LLM call each are # I/O bound, so threads beat the GIL. Capped at 3 to stay under GitHub's # unauthenticated rate limit (60 req/hr) and to be polite to npm/atom. COLLECT_MAX_WORKERS = int(os.environ.get("BLOG_APP_COLLECT_WORKERS", "3")) # Global cap so one hanging project cannot stall the whole run: # slowest data source (Exa, 60s + one retry) + LLM timeout + slack. COLLECT_PROJECT_TIMEOUT = LLM_TIMEOUT + 120 CANONICAL_SOURCES = [ "GitHub Releases API", "GitHub commits API", "npm registry", "GitHub releases.atom", "Exa web search", ] PROJECTS = [ { "key": "opencode", "label": "sst/opencode", "github": "sst/opencode", "npm": "opencode-ai", "installed": "1.18.14", "star": True, }, { "key": "claude-code", "label": "anthropics/claude-code", "github": "anthropics/claude-code", "npm": "@anthropic-ai/claude-code", "installed": None, "star": True, }, { "key": "codex", "label": "openai/codex", "github": "openai/codex", "npm": "@openai/codex", "installed": None, "star": False, }, { "key": "hermes-agent", "label": "NousResearch/hermes-agent", "github": "NousResearch/hermes-agent", "npm": "hermes-agent", "installed": None, "star": True, }, { "key": "oh-my-openagent", "label": "code-yeongyu/oh-my-openagent", "github": "code-yeongyu/oh-my-openagent", "npm": "oh-my-openagent", "installed": None, "star": True, }, { "key": "hindsight", "label": "vectorize-io/hindsight", "github": "vectorize-io/hindsight", "npm": "hindsight", "installed": None, "star": True, "npm_relevance_check": True, }, ] ATOM_NS = "{http://www.w3.org/2005/Atom}" def _llm_failure(reason: str) -> dict: return { "summary_zh": f"(LLM 分析失败: {reason})", "breaking": None, "breaking_reason": "LLM 分析失败", "relevance_note": "", } def _truncate_field(value, limit: int): """Recursively truncate every string in a nested structure.""" if isinstance(value, str): return value[:limit] if isinstance(value, dict): return {k: _truncate_field(v, limit) for k, v in value.items()} if isinstance(value, list): return [_truncate_field(v, limit) for v in value] return value def _extract_json_object(text: str) -> dict | None: """Extract the first {...} JSON object from text (tolerates fences/prose).""" start = text.find("{") end = text.rfind("}") if start == -1 or end == -1 or end <= start: return None try: obj = json.loads(text[start:end + 1]) except json.JSONDecodeError: return None return obj if isinstance(obj, dict) else None def _short_reason(e: Exception, default: str = "请求失败") -> str: """Short single-line failure reason (≤80 chars, no newlines).""" msg = str(e).strip().replace("\n", " ").replace("\r", " ") if not msg: msg = default return msg[:80] def summarize_with_llm(project_key: str, collected_data: dict) -> dict: # 数据截断: release body ≤LLM_BODY_LIMIT, 其余字段各 ≤LLM_FIELD_LIMIT, # 总数据部分 ≤LLM_TOTAL_LIMIT (12000 chars ≈ 3000 tokens, 见 config.py) trimmed: dict = {} for key, value in collected_data.items(): if value is None: continue if key == "github_release" and isinstance(value, dict): rel = dict(value) rel["body"] = (rel.get("body") or "")[:LLM_BODY_LIMIT] trimmed[key] = _truncate_field(rel, LLM_FIELD_LIMIT) trimmed[key]["body"] = (value.get("body") or "")[:LLM_BODY_LIMIT] else: trimmed[key] = _truncate_field(value, LLM_FIELD_LIMIT) data_json = json.dumps(trimmed, ensure_ascii=False, default=str) if len(data_json) > LLM_TOTAL_LIMIT: # 极端情况下继续收缩: 砍掉 exa_results 再砍 commits trimmed.pop("exa_results", None) data_json = json.dumps(trimmed, ensure_ascii=False, default=str) if len(data_json) > LLM_TOTAL_LIMIT and isinstance(trimmed.get("github_commits"), list): trimmed["github_commits"] = trimmed["github_commits"][:3] data_json = json.dumps(trimmed, ensure_ascii=False, default=str) prompt = ( "你是 AI 编程助手项目的升级监控分析师。下面是项目 " f"{project_key} 的最新抓取数据 (JSON):\n\n{data_json}\n\n" "请分析这些变更, 严格只返回一个 JSON 对象, 不要输出其他内容:\n" "{\n" ' "summary_zh": "不少于50字的中文摘要, 概括本次更新的核心内容, 2-3句话",\n' ' "breaking": true 或 false, 表示是否存在不向后兼容的破坏性变更,\n' ' "breaking_reason": "若有 breaking 说明具体内容; 若无则写 \'未发现破坏性变更\'",\n' ' "relevance_note": "对本地已安装该工具用户的实际意义, 1-2句话"\n' "}" ) messages = [ {"role": "system", "content": "你是严谨的 AI 工具升级监控分析师, 只输出 JSON。"}, {"role": "user", "content": prompt}, ] try: resp = requests.post( LLM_URL, json={"model": LLM_MODEL, "messages": messages}, timeout=LLM_TIMEOUT, ) resp.raise_for_status() content = ( resp.json() .get("choices", [{}])[0] .get("message", {}) .get("content", "") ) if not content: log.error("LLM empty content for %s", project_key) return _llm_failure("空响应") obj = _extract_json_object(content) if obj is None: log.error("LLM JSON parse failed for %s: %.200s", project_key, content) return _llm_failure("JSON 解析失败") summary = str(obj.get("summary_zh", "")).strip() if not summary: log.error("LLM empty summary_zh for %s", project_key) return _llm_failure("summary_zh 为空") breaking = obj.get("breaking") if not isinstance(breaking, bool): breaking = None return { "summary_zh": summary, "breaking": breaking, "breaking_reason": str(obj.get("breaking_reason", "")).strip() or "未发现破坏性变更", "relevance_note": str(obj.get("relevance_note", "")).strip(), } except Exception as e: log.error("LLM call failed for %s: %s", project_key, e) return _llm_failure(str(e)) def gh_headers(): return { "User-Agent": UA, "Accept": "application/vnd.github+json", } def fetch_github_release(github_repo: str) -> tuple[dict | None, str | None]: url = f"https://api.github.com/repos/{github_repo}/releases/latest" try: r = requests.get(url, headers=gh_headers(), timeout=GITHUB_TIMEOUT) r.raise_for_status() d = r.json() return { "tag_name": d.get("tag_name", ""), "published_at": d.get("published_at", ""), "name": d.get("name", ""), "body": (d.get("body") or "")[:LLM_BODY_LIMIT], }, None except Exception as e: log.error("GitHub release failed for %s: %s", github_repo, e) return None, _short_reason(e) def fetch_github_commits(github_repo: str) -> tuple[list[dict] | None, str | None]: url = f"https://api.github.com/repos/{github_repo}/commits?per_page=5" try: r = requests.get(url, headers=gh_headers(), timeout=GITHUB_TIMEOUT) r.raise_for_status() items = [] for c in r.json(): msg = c.get("commit", {}).get("message", "") first_line = msg.split("\n")[0][:LLM_MESSAGE_LIMIT] date = c.get("commit", {}).get("author", {}).get("date", "") items.append({"message": first_line, "date": date}) return items, None except Exception as e: log.error("GitHub commits failed for %s: %s", github_repo, e) return None, _short_reason(e) def fetch_npm(pkg: str) -> tuple[dict | None, str | None]: if pkg.startswith("@"): scope, name = pkg.split("/", 1) encoded = f"{scope}%2F{name}" else: encoded = pkg url = f"https://registry.npmjs.org/{encoded}/latest" try: r = requests.get(url, headers={"User-Agent": UA}, timeout=HTTP_TIMEOUT) r.raise_for_status() d = r.json() return { "version": d.get("version", ""), "description": d.get("description", ""), }, None except Exception as e: log.error("npm fetch failed for %s: %s", pkg, e) return None, _short_reason(e) def fetch_releases_atom(github_repo: str) -> tuple[dict | None, str | None]: url = f"https://github.com/{github_repo}/releases.atom" last_err: str | None = None for attempt in range(2): # 首次 + 1 次重试 (仅 Timeout) try: r = requests.get( url, headers={"User-Agent": UA}, timeout=ATOM_TIMEOUT ) r.raise_for_status() root = ET.fromstring(r.text) entries = root.findall(f"{ATOM_NS}entry") if not entries: return None, "feed 无 entry" entry = entries[0] title = entry.findtext(f"{ATOM_NS}title", default="") updated = entry.findtext(f"{ATOM_NS}updated", default="") return {"title": title, "updated": updated}, None except requests.exceptions.Timeout: last_err = "超时 (github.com 限流/网络)" log.warning( "Atom feed timeout for %s (attempt %d/2)", github_repo, attempt + 1 ) if attempt == 0: time.sleep(5) except Exception as e: last_err = _short_reason(e) log.error("Atom feed failed for %s: %s", github_repo, e) break return None, last_err def _parse_exa_text_block(text: str) -> list[dict]: results = [] blocks = re.split(r"\n---\n", text) for block in blocks: block = block.strip() if not block: continue title = "" url = "" highlights = [] in_highlights = False for line in block.split("\n"): if line.startswith("Title:"): title = line[6:].strip() in_highlights = False elif line.startswith("URL:"): url = line[4:].strip() in_highlights = False elif line.startswith("Published:") or line.startswith("Author:"): in_highlights = False elif line.startswith("Highlights:"): in_highlights = True elif in_highlights and line.strip() and not line.startswith("..."): highlights.append(line.strip()) if title and url: summary = " ".join(highlights)[:LLM_FIELD_LIMIT] results.append({"title": title, "url": url, "text": summary}) return results class ExaClient: def __init__(self): self.url = "https://mcp.exa.ai/mcp?tools=web_search_exa" self.headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", } self.session_id: str | None = None self._initialized = False def initialize(self) -> bool: try: body = { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "daily-watchdog", "version": "1.0"}, }, } r = requests.post( self.url, json=body, headers=self.headers, timeout=EXA_TIMEOUT ) r.raise_for_status() self.session_id = r.headers.get("Mcp-Session-Id", "") notify = { "jsonrpc": "2.0", "method": "notifications/initialized", } h = {**self.headers} if self.session_id: h["Mcp-Session-Id"] = self.session_id requests.post( self.url, json=notify, headers=h, timeout=HTTP_TIMEOUT ) self._initialized = True log.info("Exa MCP initialized, session=%s", self.session_id) return True except Exception as e: log.error("Exa init failed: %s", e) return False def search(self, query: str, num_results: int = 3) -> list[dict] | None: if not self._initialized: return None try: body = { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "web_search_exa", "arguments": {"query": query, "numResults": num_results}, }, } h = {**self.headers} if self.session_id: h["Mcp-Session-Id"] = self.session_id r = requests.post( self.url, json=body, headers=h, timeout=EXA_TIMEOUT ) r.raise_for_status() text = r.text results = [] for line in text.split("\n"): line = line.strip() if not line.startswith("data:"): continue payload = line[5:].strip() if not payload: continue try: obj = json.loads(payload) except json.JSONDecodeError: continue if "result" not in obj: continue content = obj["result"].get("content", []) for c in content: raw = c.get("text", "") parsed = _parse_exa_text_block(raw) results.extend(parsed) return results if results else None except Exception as e: log.error("Exa search failed for '%s': %s", query, e) return None def check_npm_relevance(pkg: str, npm_data: dict) -> bool: desc = (npm_data.get("description") or "").lower() keywords = [ "vectorize", "hindsight", "replay", "debugging", "browser", "record", "session", ] return any(kw in desc for kw in keywords) def _collect_one_project(proj: dict, exa: ExaClient | None) -> dict: """Fetch all sources for one project and summarize with the LLM. Runs inside a worker thread. Every fetcher already catches its own exceptions, so one failing source (or a failing LLM call) never raises out of here and never affects other projects. `exa` is None when the shared MCP session failed to initialize. """ log.info("Collecting data for %s", proj["label"]) data: dict = { "key": proj["key"], "label": proj["label"], "star": proj["star"], "installed": proj["installed"], "github_release": None, "github_commits": None, "npm": None, "atom": None, "exa_results": None, "source_errors": [], "sources_used": [], "llm_summary": None, } rel, rel_err = fetch_github_release(proj["github"]) if rel: data["github_release"] = rel data["sources_used"].append("GitHub Releases API") else: data["source_errors"].append( f"GitHub Releases API: {rel_err or '请求失败'}" ) commits, commits_err = fetch_github_commits(proj["github"]) if commits: data["github_commits"] = commits data["sources_used"].append("GitHub commits API") else: data["source_errors"].append( f"GitHub commits API: {commits_err or '请求失败'}" ) npm_data, npm_err = fetch_npm(proj["npm"]) if npm_data: if proj.get("npm_relevance_check"): if check_npm_relevance(proj["npm"], npm_data): data["npm"] = npm_data data["sources_used"].append("npm registry") else: log.info( "npm package '%s' not relevant, skipping", proj["npm"] ) data["source_errors"].append( "npm registry: 包存在但与本 repo 不相关, 已跳过" ) else: data["npm"] = npm_data data["sources_used"].append("npm registry") else: data["source_errors"].append(f"npm registry: {npm_err or '请求失败'}") atom, atom_err = fetch_releases_atom(proj["github"]) if atom: data["atom"] = atom data["sources_used"].append("GitHub releases.atom") else: data["source_errors"].append( f"GitHub releases.atom: {atom_err or '请求失败'}" ) if exa is not None: query = f"{proj['label']} latest release changelog" exa_res = exa.search(query) if exa_res: data["exa_results"] = exa_res data["sources_used"].append("Exa web search") else: data["source_errors"].append("Exa web search: 无结果或查询失败") else: data["source_errors"].append("Exa web search: MCP 初始化失败") collected_for_llm = { "github_release": data["github_release"], "github_commits": data["github_commits"], "npm": data["npm"], "atom": data["atom"], "exa_results": data["exa_results"], } data["llm_summary"] = summarize_with_llm(proj["key"], collected_for_llm) return data def collect_all() -> tuple[list[dict], dict[str, int], list[str]]: exa = ExaClient() exa_ok = exa.initialize() if not exa_ok: log.warning("Exa MCP init failed — all projects will note it") # I/O bound (HTTP + LLM), so threads sidestep the GIL. One hanging # project cannot stall the run: its future is abandoned after # COLLECT_PROJECT_TIMEOUT and the thread is left to die on its own. with futures.ThreadPoolExecutor(max_workers=COLLECT_MAX_WORKERS) as pool: future_by_index = { i: pool.submit(_collect_one_project, proj, exa if exa_ok else None) for i, proj in enumerate(PROJECTS) } results_by_index: dict[int, dict] = {} for i, fut in future_by_index.items(): proj = PROJECTS[i] try: results_by_index[i] = fut.result(timeout=COLLECT_PROJECT_TIMEOUT) except Exception as e: log.error( "collect timed out or crashed for %s: %s", proj["label"], e ) results_by_index[i] = { "key": proj["key"], "label": proj["label"], "star": proj["star"], "installed": proj["installed"], "github_release": None, "github_commits": None, "npm": None, "atom": None, "exa_results": None, "source_errors": [f"并行抓取超时或异常: {_short_reason(e)}"], "sources_used": [], "llm_summary": None, } # Preserve PROJECTS order; aggregate per-source stats from the results # produced by the worker threads (counts are merged here, single-threaded). project_results = [results_by_index[i] for i in range(len(PROJECTS))] source_counts: dict[str, int] = {s: 0 for s in CANONICAL_SOURCES} sources_failed: set[str] = set() if not exa_ok: sources_failed.add("Exa web search") for data in project_results: for src in data["sources_used"]: if src in source_counts: source_counts[src] += 1 for err in data["source_errors"]: src, _sep, _reason = err.partition(": ") if src in source_counts: sources_failed.add(src) return project_results, source_counts, sorted(sources_failed) def determine_project_status(data: dict) -> str: has_any = any( [ data["github_release"], data["github_commits"], data["npm"], data["atom"], ] ) if has_any and not data["source_errors"]: return "ok" if has_any: return "partial" return "failed" def _split_source_error(err: str) -> tuple[str, str]: src, sep, reason = err.partition(": ") if not sep: return err, "未知原因" return src, reason def render_report( date_str: str, project_results: list[dict], source_counts: dict[str, int], ) -> str: lines = [] lines.append(f"# AI Agent 升级迭代日报 - {date_str}") lines.append("") lines.append("## 数据源") lines.append("") total = len(PROJECTS) for src in CANONICAL_SOURCES: n = source_counts.get(src, 0) lines.append(f"- {src} ({n}/{total})") lines.append("") all_failed = all(determine_project_status(d) == "failed" for d in project_results) if all_failed: lines.append("> ⚠️ 所有项目全部数据源抓取失败") lines.append("") for data in project_results: status = determine_project_status(data) star_mark = " ★" if data["star"] else "" install_mark = f" (已装 {data['installed']})" if data["installed"] else " (未装)" lines.append(f"## {data['label']}{star_mark}{install_mark}") lines.append("") if status == "failed": lines.append("⚠️ 全部数据源抓取失败:") lines.append("") for err in data["source_errors"]: src, reason = _split_source_error(err) lines.append(f"⚠️ 抓取失败: {src} — {reason}") lines.append("") continue if data["github_release"]: rel = data["github_release"] lines.append(f"**Latest Release:** `{rel['tag_name']}`") lines.append(f"**发布日期:** {rel['published_at']}") if rel["name"]: lines.append(f"**Release 名称:** {rel['name']}") lines.append("") if rel["body"]: lines.append("**Changelog 摘要:**") lines.append(f"> {rel['body'][:LLM_FIELD_LIMIT]}") lines.append("") elif data["github_commits"]: lines.append("**最近 Commits (无 release):**") for c in data["github_commits"]: lines.append(f"- [{c['date'][:10]}] {c['message']}") lines.append("") else: lines.append("⚠️ GitHub 数据不可用") for err in data["source_errors"]: if err.startswith("GitHub Releases API:") or err.startswith( "GitHub commits API:" ): src, reason = _split_source_error(err) lines.append(f"⚠️ 抓取失败: {src} — {reason}") lines.append("") llm = data["llm_summary"] or {} if llm.get("summary_zh"): lines.append(f"**LLM 总结:** {llm['summary_zh']}") lines.append("") lines.append( f"**Breaking Changes:** {llm.get('breaking_reason', 'LLM 总结失败')}" ) lines.append("") if data["npm"]: npm = data["npm"] lines.append(f"**npm latest:** `{npm['version']}` — {npm['description']}") lines.append("") elif any( e.startswith("npm registry: 包存在但与本 repo 不相关") for e in data["source_errors"] ): lines.append("**npm:** ⚠️ 包存在但与本 repo 不相关, 已跳过") lines.append("") elif any(e.startswith("npm registry:") for e in data["source_errors"]): err = next( e for e in data["source_errors"] if e.startswith("npm registry:") ) src, reason = _split_source_error(err) lines.append(f"**npm:** ⚠️ 抓取失败: {src} — {reason}") lines.append("") if data["atom"]: atom = data["atom"] lines.append(f"**releases.atom 最新:** {atom['title']} ({atom['updated']})") lines.append("") elif any( e.startswith("GitHub releases.atom:") for e in data["source_errors"] ): err = next( e for e in data["source_errors"] if e.startswith("GitHub releases.atom:") ) src, reason = _split_source_error(err) lines.append(f"**releases.atom:** ⚠️ 抓取失败: {src} — {reason}") lines.append("") llm = data["llm_summary"] or {} relevance = llm.get("relevance_note", "") if relevance: lines.append(f"★ **相关性:** {relevance}") lines.append("") else: lines.append("★ **相关性:** LLM 总结失败, 相关性待评估") lines.append("") if data["exa_results"]: lines.append("**Exa 搜索结果:**") lines.append("") for item in data["exa_results"][:3]: title = item.get("title", "无标题") url = item.get("url", "") text = item.get("text", "")[:LLM_MESSAGE_LIMIT] lines.append(f"> [{title}]({url}): {text}") lines.append("") elif any(e.startswith("Exa web search:") for e in data["source_errors"]): err = next( e for e in data["source_errors"] if e.startswith("Exa web search:") ) src, reason = _split_source_error(err) lines.append(f"**Exa 搜索:** ⚠️ 抓取失败: {src} — {reason}") lines.append("") lines.append("---") lines.append("") return "\n".join(lines) def save_to_sqlite(date_str: str, markdown: str, meta: dict) -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(DB_PATH)) try: conn.execute( """CREATE TABLE IF NOT EXISTS reports ( date TEXT PRIMARY KEY, markdown TEXT NOT NULL, meta_json TEXT NOT NULL, created_at TEXT NOT NULL )""" ) now = datetime.now().isoformat() conn.execute( "INSERT OR REPLACE INTO reports (date, markdown, meta_json, created_at) VALUES (?, ?, ?, ?)", (date_str, markdown, json.dumps(meta, ensure_ascii=False), now), ) conn.commit() finally: conn.close() def save_markdown(date_str: str, markdown: str) -> Path: LOGS_DIR.mkdir(parents=True, exist_ok=True) path = LOGS_DIR / f"daily-agent-watchdog-{date_str}.md" path.write_text(markdown, encoding="utf-8") return path def _watchdog_already_running() -> bool: try: out = subprocess.check_output( ["pgrep", "-f", "daily_watchdog.py"], text=True, timeout=10 ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): return False own = str(os.getpid()) for pid in out.split(): pid = pid.strip() if not pid or pid == own: continue # 只认真正的 python 脚本进程, 排除命令行里恰好提到该文件名的进程 try: with open(f"/proc/{pid}/cmdline", "rb") as f: argv = f.read().split(b"\x00") except OSError: continue if not argv or not argv[0]: continue exe = os.path.basename(argv[0].decode(errors="replace")) if exe.startswith("python"): return True return False def _today_report_ok(date_str: str) -> bool: if not DB_PATH.exists(): return False try: conn = sqlite3.connect(str(DB_PATH)) try: row = conn.execute( "SELECT meta_json FROM reports WHERE date = ?", (date_str,) ).fetchone() finally: conn.close() except Exception as e: log.warning("fix-if-missing check failed: %s", e) return False if not row: return False try: meta = json.loads(row[0]) except (json.JSONDecodeError, TypeError): return False projects = meta.get("projects", {}) ok_count = sum( 1 for st in projects.values() if isinstance(st, dict) and st.get("status") != "failed" ) return ok_count >= 2 def main() -> int: fix_if_missing = "--fix-if-missing" in sys.argv[1:] date_str = datetime.now().strftime("%Y-%m-%d") if _watchdog_already_running(): log.warning("Another daily_watchdog instance is already running — skip") return 0 if fix_if_missing and _today_report_ok(date_str): log.info("today's report already OK — skip full run (--fix-if-missing)") return 0 log.info("Starting daily watchdog for %s", date_str) project_results, source_counts, sources_failed = collect_all() all_failed = all(determine_project_status(d) == "failed" for d in project_results) markdown = render_report(date_str, project_results, source_counts) meta = { "sources_ok": sorted(k for k, v in source_counts.items() if v > 0), "sources_failed": sources_failed, "source_counts": source_counts, "projects": { d["key"]: { "status": determine_project_status(d), "sources_used": d["sources_used"], } for d in project_results }, } save_to_sqlite(date_str, markdown, meta) md_path = save_markdown(date_str, markdown) log.info("Report saved to %s", md_path) log.info("SQLite record saved to %s", DB_PATH) if all_failed: log.error("All projects failed — exit code 1") return 1 log.info("Daily watchdog completed successfully") return 0 if __name__ == "__main__": sys.exit(main())