""" 事件日志模块 — V2 可观测性基础设施。 JSONL 格式无限流式追加,与 state.json 互补:state.json 是当前快照,事件日志是完整时间线。 """ from __future__ import annotations import contextlib import json import logging import os import tempfile from datetime import datetime, timezone from pathlib import Path logger = logging.getLogger(__name__) # 事件类型常量 TASK_DISPATCHED = "task.dispatched" TASK_COMPLETED = "task.completed" TASK_BLOCKED = "task.blocked" MERGE_STARTED = "merge.started" MERGE_COMPLETED = "merge.completed" REPAIR_CREATED = "repair.created" REPAIR_RESOLVED = "repair.resolved" INTERVENTION_STALL = "intervention.stall" XDB_CAPTURED = "xdb.captured" DEBUG_SESSION = "debug.session" CONTEXT_COMPACTED = "context.compacted" DEPLOY_COMPLETED = "deploy.completed" TEST_RUN = "test.run" SEC_SCAN = "sec.scan" REVIEW_SESSION = "review.session" ENGINE_CYCLE = "engine.cycle" WORKER_TIMEOUT = "worker.timeout" ADR_CHANGE_DETECTED = "adr.change.detected" ADR_INVALIDATION = "adr.invalidation" ADR_UNFREEZED = "adr.unfreezed" LOCK_ACQUIRED = "lock.acquired" LOCK_RELEASED = "lock.released" STALE_LOCK_CLEANED = "stale_lock.cleaned" def now_iso() -> str: return datetime.now(timezone.utc).isoformat() class EventLog: """结构化事件日志(JSONL 格式),支持轮转截断。""" MAX_LINES = 10000 def __init__(self, path: Path, max_lines: int = MAX_LINES): self._path = path self._max_lines = max_lines self._pending_merge_complete = None def emit(self, event_type: str, payload: dict | None = None) -> None: entry = { "ts": now_iso(), "type": event_type, **(payload or {}), } self._path.parent.mkdir(parents=True, exist_ok=True) with open(self._path, "a") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") self._maybe_rotate() def _emit_with_completion(self, event_type: str, payload: dict) -> None: """emit 并确保 MERGE_STARTED / MERGE_COMPLETED 成对。""" self.emit(event_type, payload) # 自动补全配对事件 if event_type == "merge.started": self._pending_merge_complete = payload.get("taskId") elif event_type == "merge.completed": self._pending_merge_complete = None def _maybe_rotate(self) -> None: """原子轮转:先写临时文件,再 os.replace。""" if not self._path.exists(): return try: with open(self._path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() if len(lines) <= self._max_lines: return keep_count = self._max_lines // 2 kept_lines = lines[-keep_count:] if len(lines) > keep_count else lines fd, tmp_path = tempfile.mkstemp(dir=self._path.parent, suffix=".tmp") os.close(fd) try: with open(tmp_path, "w", encoding="utf-8") as f: f.writelines(kept_lines) os.replace(tmp_path, self._path) logger.info("rotated event log %s, kept %d/%d lines", self._path, len(kept_lines), len(lines)) except Exception: with contextlib.suppress(Exception): os.unlink(tmp_path) raise except OSError: pass