Files
AirPlan-V2/lib/air_runtime/events.py
AirPlan Team 2c4b3340bf AirPlan V2 initial release — unified scheduler with 12 sub-modes
Merges 8 V1 plugins into 1 unified plugin, adds 4 new components (dep, tst, sec, rvr).
12 sub-modes: arc, eng, do, dbg, xdb, sdb, ndb, ctx, dep, tst, sec, rvr.
L1 code-level guarantees: atomic writes, file locks, evidence gating, forced debug routing,
3-phase arc gate, evidence-first debug gate, Chinese language lock, scheduler boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:24:26 +08:00

103 lines
3.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
事件日志模块 — 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"
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