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>
280 lines
9.7 KiB
Python
280 lines
9.7 KiB
Python
"""
|
||
AirDbg mode — V2 调试器。
|
||
V2 改进:7步工作流强制追踪(L1 代码级),修复前自动 git snapshot 回滚。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import subprocess
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from air_runtime.io import atomic_json_write, safe_json_load
|
||
from air_runtime.paths import airplan_root, event_log_path
|
||
from air_runtime.events import EventLog, DEBUG_SESSION
|
||
from air_runtime.utils import now_iso, session_stamp
|
||
|
||
DBG_STEPS = [
|
||
"confirm_symptoms",
|
||
"load_context",
|
||
"reproduce",
|
||
"locate_root_cause",
|
||
"fix",
|
||
"verify",
|
||
"close_out",
|
||
]
|
||
|
||
|
||
class EvidenceFirstGate:
|
||
"""先读后写门控:未执行任何取证行为前,禁止代码修改。"""
|
||
|
||
EVIDENCE_TYPES = [
|
||
"screenshot",
|
||
"packet_capture",
|
||
"static_analysis",
|
||
"log_analysis",
|
||
"code_trace",
|
||
"reproduction",
|
||
]
|
||
|
||
def __init__(self, session_id: str):
|
||
self._session_id = session_id
|
||
self._collected_evidence: list[str] = []
|
||
|
||
def record_evidence(self, evidence_type: str, detail: str = "") -> None:
|
||
if evidence_type not in self.EVIDENCE_TYPES:
|
||
raise ValueError(f"unknown evidence type: {evidence_type!r}")
|
||
self._collected_evidence.append(evidence_type)
|
||
|
||
def can_modify_code(self) -> bool:
|
||
return len(self._collected_evidence) > 0
|
||
|
||
def gate_check(self) -> None:
|
||
if not self.can_modify_code():
|
||
raise WorkflowViolation(
|
||
"未执行任何取证行为,禁止修改代码。"
|
||
"请先至少完成以下一项:截图、抓包、静态分析、日志分析、代码追踪、复现步骤。"
|
||
)
|
||
|
||
|
||
class WorkflowViolation(Exception):
|
||
"""调试工作流违规。"""
|
||
|
||
|
||
def _paths(project_root: Path) -> dict[str, Path]:
|
||
root = airplan_root(project_root) / "state" / "airdbg"
|
||
return {
|
||
"root": root,
|
||
"state": root / "state.json",
|
||
"sessions_dir": root / "sessions",
|
||
"snapshots_dir": root / "snapshots",
|
||
}
|
||
|
||
|
||
def _ensure_dirs(paths: dict[str, Path]) -> None:
|
||
for key in ("sessions_dir", "snapshots_dir"):
|
||
paths[key].mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def start_session(project_root: Path, task_id: str) -> dict:
|
||
paths = _paths(project_root)
|
||
_ensure_dirs(paths)
|
||
|
||
session_id = f"{task_id}-{session_stamp()}"
|
||
session_state = {
|
||
"sessionId": session_id, "taskId": task_id,
|
||
"currentStep": "confirm_symptoms",
|
||
"startedAt": now_iso(),
|
||
"stepsCompleted": [],
|
||
"collectedEvidence": [],
|
||
"evidence": {},
|
||
"result": None,
|
||
}
|
||
session_path = paths["sessions_dir"] / f"{session_id}.json"
|
||
atomic_json_write(session_path, session_state)
|
||
|
||
log = EventLog(event_log_path(project_root))
|
||
log.emit(DEBUG_SESSION, {"sessionId": session_id, "taskId": task_id, "action": "started"})
|
||
|
||
return {
|
||
"sessionId": session_id, "sessionPath": str(session_path),
|
||
"currentStep": "confirm_symptoms",
|
||
"steps": DBG_STEPS,
|
||
}
|
||
|
||
|
||
def get_step(session_path: Path) -> str:
|
||
session = safe_json_load(session_path)
|
||
if not session or not isinstance(session, dict):
|
||
return "confirm_symptoms"
|
||
return session.get("currentStep", "confirm_symptoms")
|
||
|
||
|
||
def advance_step(session_path: Path, evidence: dict) -> str:
|
||
session = safe_json_load(session_path)
|
||
if not session or not isinstance(session, dict):
|
||
raise ValueError("invalid session")
|
||
|
||
current = session.get("currentStep", "confirm_symptoms")
|
||
current_idx = DBG_STEPS.index(current) if current in DBG_STEPS else 0
|
||
|
||
# 验证当前步骤需要的证据
|
||
required_evidence = _required_evidence_for_step(current)
|
||
if required_evidence:
|
||
missing = [k for k in required_evidence if k not in evidence]
|
||
if missing:
|
||
raise ValueError(f"step '{current}' requires evidence: {missing}")
|
||
|
||
# 先读后写门控:fix 步骤前必须已有取证记录
|
||
if current == "fix":
|
||
collected = session.get("collectedEvidence", [])
|
||
if not collected:
|
||
raise WorkflowViolation(
|
||
"未执行任何取证行为,禁止修改代码。"
|
||
"请先至少完成以下一项:截图、抓包、静态分析、日志分析、代码追踪、复现步骤。"
|
||
)
|
||
|
||
session["stepsCompleted"].append({"step": current, "evidence": evidence, "completedAt": now_iso()})
|
||
|
||
# 累积取证记录(confirm_symptoms, load_context, reproduce, locate_root_cause 都是取证步骤)
|
||
evidence_steps = {"confirm_symptoms", "load_context", "reproduce", "locate_root_cause"}
|
||
if current in evidence_steps:
|
||
session.setdefault("collectedEvidence", []).append(current)
|
||
next_idx = current_idx + 1
|
||
if next_idx < len(DBG_STEPS):
|
||
session["currentStep"] = DBG_STEPS[next_idx]
|
||
|
||
atomic_json_write(session_path, session)
|
||
return session["currentStep"]
|
||
|
||
|
||
def skip_reproduce(session_path: Path, reason: str) -> str:
|
||
session = safe_json_load(session_path)
|
||
if not session or not isinstance(session, dict):
|
||
raise ValueError("invalid session")
|
||
if session.get("currentStep") != "reproduce":
|
||
raise ValueError("can only skip from reproduce step")
|
||
session["currentStep"] = "locate_root_cause"
|
||
session["stepsCompleted"].append({"step": "reproduce", "evidence": {"skipped": True, "reason": reason}})
|
||
atomic_json_write(session_path, session)
|
||
return "locate_root_cause"
|
||
|
||
|
||
def pre_fix_snapshot(project_root: Path, task_id: str) -> str:
|
||
"""
|
||
修复前创建 git tag 作为回滚点。
|
||
V2 改进:只提交当前 task 写集范围内的文件(在 result.filesChanged 中声明)。
|
||
"""
|
||
# 1. 读取 worker result 获取 filesChanged
|
||
result_path = project_root / "AirPlan" / "state" / "airdo" / "tasks" / task_id / "result.json"
|
||
if not result_path.exists():
|
||
# 无 result 文件,回退到全量提交(但加 warning)
|
||
return _snapshot_full(project_root, task_id)
|
||
|
||
result = safe_json_load(result_path) or {}
|
||
files_changed = result.get("filesChanged", [])
|
||
|
||
if not files_changed:
|
||
# 无写集声明,回退到当前工作目录中的已跟踪文件
|
||
files_changed = None
|
||
|
||
# 2. 只 add 这些文件,然后 commit
|
||
return _snapshot_selective(project_root, task_id, files_changed)
|
||
|
||
|
||
def _snapshot_selective(project_root: Path, task_id: str, files: list[str] | None) -> str:
|
||
"""只提交指定的文件列表"""
|
||
ref = f"airdbg-prefix-{task_id}-{session_stamp()}"
|
||
|
||
try:
|
||
# git add <files>
|
||
if files:
|
||
for f in files:
|
||
fp = project_root / f
|
||
if fp.exists():
|
||
subprocess.run(["git", "-C", str(project_root), "add", str(fp)],
|
||
check=True, capture_output=True, timeout=10)
|
||
|
||
# 如果有 staging 的内容则 commit,否则跳过(避免空 commit)
|
||
result = subprocess.run(
|
||
["git", "-C", str(project_root), "commit", "-m",
|
||
f"AirDbg pre-fix snapshot: {task_id}", "--allow-empty"],
|
||
capture_output=True, text=True, timeout=30,
|
||
)
|
||
|
||
if result.returncode == 0:
|
||
subprocess.run(
|
||
["git", "-C", str(project_root), "tag", ref],
|
||
check=True, capture_output=True, text=True, timeout=10,
|
||
)
|
||
return ref
|
||
else:
|
||
# 没有 staged 内容或 commit 失败
|
||
return ""
|
||
|
||
except subprocess.CalledProcessError:
|
||
return ""
|
||
|
||
|
||
def _snapshot_full(project_root: Path, task_id: str) -> str:
|
||
"""全量提交(仅在无 filesChanged 信息时的 fallback,加 warning)"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.warning("pre_fix_snapshot: no filesChanged info, falling back to git commit -am")
|
||
|
||
# 这里保留原逻辑但加 comment 说明这是 fallback
|
||
return _do_git_commit_am(project_root, task_id)
|
||
|
||
|
||
def _do_git_commit_am(project_root: Path, task_id: str) -> str:
|
||
"""原始实现,保留用于 fallback"""
|
||
ref = f"airdbg-prefix-{task_id}-{session_stamp()}"
|
||
try:
|
||
subprocess.run(
|
||
["git", "-C", str(project_root), "commit", "-am",
|
||
f"AirDbg pre-fix snapshot: {task_id}", "--allow-empty"],
|
||
check=True, capture_output=True, text=True, timeout=30,
|
||
)
|
||
subprocess.run(
|
||
["git", "-C", str(project_root), "tag", ref],
|
||
check=True, capture_output=True, text=True, timeout=10,
|
||
)
|
||
except subprocess.CalledProcessError:
|
||
return ""
|
||
return ref
|
||
|
||
|
||
def _required_evidence_for_step(step: str) -> list[str]:
|
||
evidence_map = {
|
||
"confirm_symptoms": ["symptom", "expected", "actual"],
|
||
"load_context": [],
|
||
"reproduce": ["reproduction_steps"],
|
||
"locate_root_cause": ["root_cause_analysis"],
|
||
"fix": ["fix_description", "files_changed"],
|
||
"verify": ["validation_result"],
|
||
"close_out": ["residual_risk", "adr_updates"],
|
||
}
|
||
return evidence_map.get(step, [])
|
||
|
||
|
||
def main(args) -> None:
|
||
project_root = Path(args.project).expanduser().resolve()
|
||
sub = args.sub or "status"
|
||
paths = _paths(project_root)
|
||
_ensure_dirs(paths)
|
||
|
||
if sub == "start":
|
||
result = start_session(project_root, args.task_id)
|
||
print("airplan_mode=dbg")
|
||
print(f"session_id={result['sessionId']}")
|
||
print(f"current_step={result['currentStep']}")
|
||
print(f"steps={','.join(result['steps'])}")
|
||
elif sub == "snapshot":
|
||
ref = pre_fix_snapshot(project_root, args.task_id)
|
||
print("airplan_mode=dbg")
|
||
print(f"snapshot_ref={ref}")
|
||
else:
|
||
state = safe_json_load(paths["state"]) or {}
|
||
print("airplan_mode=dbg")
|
||
print(f"enabled={state.get('enabled', False)}")
|