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>
This commit is contained in:
20
lib/air_runtime/__init__.py
Normal file
20
lib/air_runtime/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# air_runtime — AirPlan V2 核心运行时库
|
||||
|
||||
from air_runtime.io import atomic_json_write, safe_json_load
|
||||
from air_runtime.lock import FileLock
|
||||
from air_runtime.utils import ordered_unique, session_stamp, normalize_policy, sanitize_task_id, sanitize_marker
|
||||
from air_runtime.events import EventLog
|
||||
from air_runtime.task_graph import TaskGraph, PlanDelta
|
||||
from air_runtime.worktree import WorktreeIsolation, RegionConflictDetector, ConflictLevel
|
||||
from air_runtime.evidence_gate import EvidenceGatePolicy, EvidenceClass
|
||||
from air_runtime.contracts import now_iso, WorkerResult, TaskRecord, DeploymentRecord
|
||||
from air_runtime.paths import (
|
||||
airplan_root, todo_path, plan_path,
|
||||
state_root, engine_state_path, worker_state_path,
|
||||
arc_state_path, dbg_state_path, xdb_state_path,
|
||||
sdb_state_path, ndb_state_path, ctx_state_path,
|
||||
dep_state_path, tst_state_path, sec_state_path, rvr_state_path,
|
||||
required_project_artifacts,
|
||||
)
|
||||
|
||||
__version__ = "2.0.0"
|
||||
BIN
lib/air_runtime/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/contracts.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/contracts.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/deploy_runtime.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/deploy_runtime.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/events.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/events.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/evidence_gate.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/evidence_gate.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/installer.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/installer.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/io.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/io.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/lock.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/lock.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/paths.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/paths.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/project_bootstrap.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/project_bootstrap.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/review.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/review.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/review_runtime.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/review_runtime.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/sdb_backends.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/sdb_backends.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/sec_runtime.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/sec_runtime.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/task_graph.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/task_graph.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/test_runtime.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/test_runtime.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/todo_parser.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/todo_parser.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/utils.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/worktree.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/worktree.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/__pycache__/xdb_capture.cpython-313.pyc
Normal file
BIN
lib/air_runtime/__pycache__/xdb_capture.cpython-313.pyc
Normal file
Binary file not shown.
109
lib/air_runtime/contracts.py
Normal file
109
lib/air_runtime/contracts.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
数据契约 — 保持 V1 契约完整性,新增 DeploymentRecord、AirRvr 审查报告等结构。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerResult:
|
||||
task_id: str
|
||||
status: str # done | blocked | failed
|
||||
summary: str = ""
|
||||
files_changed: list[str] = field(default_factory=list)
|
||||
validations: list[dict] = field(default_factory=list)
|
||||
document_updates: list[dict] = field(default_factory=list)
|
||||
evidence: list[dict] = field(default_factory=list)
|
||||
risks: list[str] = field(default_factory=list)
|
||||
blockers: list[str] = field(default_factory=list)
|
||||
deploy_required: bool = False
|
||||
deploy_info: dict | None = None
|
||||
|
||||
def validate_for_finalize(self, brief: dict | None = None) -> None:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_\-\.]+", self.task_id):
|
||||
raise ValidationError(f"invalid task_id: {self.task_id!r}")
|
||||
|
||||
if self.status not in ("done", "blocked", "failed"):
|
||||
raise ValidationError(f"invalid status: {self.status}")
|
||||
|
||||
if self.status == "done" and not self.validations and not self.files_changed:
|
||||
raise ValidationError("done without validations or file changes")
|
||||
|
||||
if self.deploy_required:
|
||||
deploy_validations = [
|
||||
v for v in self.validations
|
||||
if v.get("kind") in ("remote-deploy-verify", "remote-binary-md5")
|
||||
]
|
||||
if not deploy_validations:
|
||||
raise ValidationError("deploy_required but no deploy verification in validations")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"taskId": self.task_id,
|
||||
"status": self.status,
|
||||
"summary": self.summary,
|
||||
"filesChanged": self.files_changed,
|
||||
"validations": self.validations,
|
||||
"documentUpdates": self.document_updates,
|
||||
"evidence": self.evidence,
|
||||
"risks": self.risks,
|
||||
"blockers": self.blockers,
|
||||
"deployRequired": self.deploy_required,
|
||||
"deployInfo": self.deploy_info,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> WorkerResult:
|
||||
return cls(
|
||||
task_id=data.get("taskId", ""),
|
||||
status=data.get("status", ""),
|
||||
summary=data.get("summary", ""),
|
||||
files_changed=data.get("filesChanged", []),
|
||||
validations=data.get("validations", []),
|
||||
document_updates=data.get("documentUpdates", []),
|
||||
evidence=data.get("evidence", []),
|
||||
risks=data.get("risks", []),
|
||||
blockers=data.get("blockers", []),
|
||||
deploy_required=data.get("deployRequired", False),
|
||||
deploy_info=data.get("deployInfo"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRecord:
|
||||
task_id: str
|
||||
task: str = ""
|
||||
files_dirs: str = ""
|
||||
done_when: str = ""
|
||||
status: str = "TODO"
|
||||
validations: str = ""
|
||||
adr: str = ""
|
||||
|
||||
@property
|
||||
def text_for_classification(self) -> str:
|
||||
return f"{self.task} {self.files_dirs} {self.done_when}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeploymentRecord:
|
||||
task_id: str
|
||||
host: str
|
||||
binary_path: str
|
||||
md5: str = ""
|
||||
service_name: str = ""
|
||||
service_status: str = ""
|
||||
deploy_at: str = field(default_factory=now_iso)
|
||||
smoke_test_passed: bool | None = None
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
133
lib/air_runtime/deploy_runtime.py
Normal file
133
lib/air_runtime/deploy_runtime.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
AirDep 部署运行时 — V2 新增组件。
|
||||
SSH 远程构建 + 二进制传输 + systemd 生命周期管理 + 部署验证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.contracts import DeploymentRecord, now_iso
|
||||
from air_runtime.io import atomic_json_write, safe_json_load
|
||||
from air_runtime.paths import dep_state_path
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeployTarget:
|
||||
host: str
|
||||
user: str = "root"
|
||||
port: int = 22
|
||||
build_dir: str = "/tmp/airdep-build"
|
||||
deploy_dir: str = "/opt/app"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeployResult:
|
||||
task_id: str
|
||||
success: bool
|
||||
binary_md5: str = ""
|
||||
service_status: str = ""
|
||||
error: str = ""
|
||||
journal_excerpt: str = ""
|
||||
|
||||
|
||||
def _ssh(target: DeployTarget, cmd: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["ssh", "-o", "StrictHostKeyChecking=accept-new", "-p", str(target.port),
|
||||
f"{target.user}@{target.host}", cmd],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _scp(target: DeployTarget, local: Path, remote: str, timeout: int = 300) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["scp", "-o", "StrictHostKeyChecking=accept-new", "-P", str(target.port),
|
||||
str(local), f"{target.user}@{target.host}:{remote}"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _md5_file(path: Path) -> str:
|
||||
h = hashlib.md5()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def deploy(task_id: str, project_root: Path, target: DeployTarget,
|
||||
local_binary: Path, service_name: str,
|
||||
build_cmd: str | None = None,
|
||||
smoke_test_cmd: str | None = None) -> DeployResult:
|
||||
"""执行完整部署流程:构建 → 传输 → systemd → 验证。"""
|
||||
state_file = dep_state_path(project_root)
|
||||
|
||||
try:
|
||||
# Step 1: 远程构建(可选)
|
||||
if build_cmd:
|
||||
result = _ssh(target, f"cd {target.build_dir} && {build_cmd}", timeout=600)
|
||||
if result.returncode != 0:
|
||||
return DeployResult(task_id=task_id, success=False,
|
||||
error=f"build failed: {result.stderr[-500:]}")
|
||||
|
||||
# Step 2: 二进制传输 + MD5 校验
|
||||
remote_tmp = f"/tmp/{local_binary.name}"
|
||||
scp_result = _scp(target, local_binary, remote_tmp)
|
||||
if scp_result.returncode != 0:
|
||||
return DeployResult(task_id=task_id, success=False,
|
||||
error=f"scp failed: {scp_result.stderr[-500:]}")
|
||||
|
||||
local_md5 = _md5_file(local_binary)
|
||||
md5_result = _ssh(target, f"md5sum {remote_tmp} | cut -d' ' -f1")
|
||||
remote_md5 = md5_result.stdout.strip()
|
||||
if local_md5 != remote_md5:
|
||||
return DeployResult(task_id=task_id, success=False,
|
||||
binary_md5=f"local={local_md5} remote={remote_md5}",
|
||||
error="md5 mismatch after transfer")
|
||||
|
||||
# Step 3: 部署二进制
|
||||
_ssh(target, f"mv {remote_tmp} {target.deploy_dir}/{local_binary.name}")
|
||||
|
||||
# Step 4: systemd 生命周期
|
||||
_ssh(target, f"systemctl daemon-reload")
|
||||
_ssh(target, f"systemctl restart {service_name}")
|
||||
status_result = _ssh(target, f"systemctl is-active {service_name}")
|
||||
service_status = status_result.stdout.strip()
|
||||
|
||||
# Step 5: 冒烟验证(可选)
|
||||
smoke_passed: bool | None = None
|
||||
if smoke_test_cmd:
|
||||
smoke_result = _ssh(target, smoke_test_cmd, timeout=60)
|
||||
smoke_passed = (smoke_result.returncode == 0)
|
||||
|
||||
# Step 6: 记录部署产物
|
||||
record = DeploymentRecord(
|
||||
task_id=task_id,
|
||||
host=target.host,
|
||||
binary_path=str(target.deploy_dir / local_binary.name),
|
||||
md5=local_md5,
|
||||
service_name=service_name,
|
||||
service_status=service_status,
|
||||
smoke_test_passed=smoke_passed,
|
||||
)
|
||||
|
||||
# 持久化
|
||||
sessions = safe_json_load(state_file) or {"sessions": []}
|
||||
if isinstance(sessions, dict):
|
||||
sessions.setdefault("sessions", []).append(record.__dict__)
|
||||
atomic_json_write(state_file, sessions)
|
||||
|
||||
return DeployResult(
|
||||
task_id=task_id,
|
||||
success=service_status == "active",
|
||||
binary_md5=local_md5,
|
||||
service_status=service_status,
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return DeployResult(task_id=task_id, success=False, error=f"timeout: {exc}")
|
||||
except Exception as exc:
|
||||
return DeployResult(task_id=task_id, success=False, error=str(exc))
|
||||
102
lib/air_runtime/events.py
Normal file
102
lib/air_runtime/events.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
事件日志模块 — 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
|
||||
74
lib/air_runtime/evidence_gate.py
Normal file
74
lib/air_runtime/evidence_gate.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
任务类型感知的证据门控 — V2 P0-1 修复。
|
||||
替代 V1 无差别触发 AirXDB 的逻辑,根据任务特征差异化要求证据类型。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class EvidenceClass(enum.Enum):
|
||||
GUI_REQUIRED = "gui_required" # 需要截图/GUI 操作验证
|
||||
NETWORK_REQUIRED = "network_required" # 需要抓包/流量验证
|
||||
STATIC_ANALYSIS = "static_analysis" # 需要静态分析
|
||||
CODE_ONLY = "code_only" # 代码级验证即可
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvidenceGatePolicy:
|
||||
"""基于任务特征的差异化证据要求。
|
||||
|
||||
关键词匹配 → 确定性分类,零额外 API 调用,可测试可覆盖。
|
||||
用户可在 todo.md 中用 [no-xdb] / [no-ndb] 标记显式跳过。
|
||||
"""
|
||||
|
||||
GUI_INDICATORS: tuple[str, ...] = (
|
||||
"gui", "ui", "render", "layout", "dialog", "osd",
|
||||
"overlay", "visual", "screenshot", "display",
|
||||
"widget", "pane", "toolbar", "settings_dialog",
|
||||
"canvas", "button", "window", "popup", "menu",
|
||||
"drm", "kms", "opengl", "vulkan",
|
||||
)
|
||||
|
||||
NETWORK_INDICATORS: tuple[str, ...] = (
|
||||
"network", "rtsp", "http", "tcp", "udp", "tls",
|
||||
"dns", "proxy", "socket", "stream", "port",
|
||||
"packet", "pcap", "bandwidth", "latency",
|
||||
)
|
||||
|
||||
STATIC_ANALYSIS_INDICATORS: tuple[str, ...] = (
|
||||
"cppcheck", "clang-tidy", "mypy", "ruff", "lint",
|
||||
"static analysis", "compile_commands",
|
||||
)
|
||||
|
||||
SKIP_MARKERS: tuple[str, ...] = ("[no-xdb]", "[no-ndb]", "[no-sdb]")
|
||||
|
||||
def classify(self, task_text: str, task_id: str = "", skip_overrides: list[str] | None = None) -> EvidenceClass:
|
||||
text = task_text.lower()
|
||||
skips = set(skip_overrides or [])
|
||||
for marker in self.SKIP_MARKERS:
|
||||
if marker in text:
|
||||
skips.add(marker)
|
||||
|
||||
has_gui = any(kw in text for kw in self.GUI_INDICATORS)
|
||||
has_net = any(kw in text for kw in self.NETWORK_INDICATORS)
|
||||
has_static = any(kw in text for kw in self.STATIC_ANALYSIS_INDICATORS)
|
||||
|
||||
if has_gui and "[no-xdb]" not in skips:
|
||||
return EvidenceClass.GUI_REQUIRED
|
||||
if has_net and "[no-ndb]" not in skips:
|
||||
return EvidenceClass.NETWORK_REQUIRED
|
||||
if has_static and "[no-sdb]" not in skips:
|
||||
return EvidenceClass.STATIC_ANALYSIS
|
||||
return EvidenceClass.CODE_ONLY
|
||||
|
||||
def required_evidence_for(self, evidence_class: EvidenceClass) -> list[str]:
|
||||
evidence_map = {
|
||||
EvidenceClass.GUI_REQUIRED: ["screenshot", "gui-operation-validation"],
|
||||
EvidenceClass.NETWORK_REQUIRED: ["packet-capture", "connectivity-verification"],
|
||||
EvidenceClass.STATIC_ANALYSIS: ["static-analysis-report"],
|
||||
EvidenceClass.CODE_ONLY: ["code-review", "test-results"],
|
||||
}
|
||||
return evidence_map.get(evidence_class, ["code-review"])
|
||||
121
lib/air_runtime/installer.py
Normal file
121
lib/air_runtime/installer.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_plugin_paths() -> dict[str, Path]:
|
||||
"""
|
||||
解析插件的实际安装路径。
|
||||
顺序: 1) 环境变量 AIRPLAN_HOME > 2) ~/.airplan > 3) 相对脚本位置推测
|
||||
"""
|
||||
# 1. 环境变量
|
||||
if "AIRPLAN_HOME" in __import__("os").environ:
|
||||
return {"root": Path(__import__("os").environ["AIRPLAN_HOME"])}
|
||||
|
||||
# 2. ~/.airplan 默认
|
||||
home = Path.home()
|
||||
default = home / ".airplan"
|
||||
if default.exists():
|
||||
return {"root": default}
|
||||
|
||||
# 3. 尝试从当前脚本位置推测
|
||||
# 脚本位于 {root}/scripts/airplan.py
|
||||
import sys
|
||||
script_root = Path(sys.argv[0]).resolve().parent if sys.argv else None
|
||||
if script_root and (script_root.name == "scripts"):
|
||||
plugin_root = script_root.parent
|
||||
if (plugin_root / ".claude-plugin").exists():
|
||||
return {"root": plugin_root}
|
||||
|
||||
# 4. 从本模块位置推测
|
||||
# 本模块位于 {root}/lib/air_runtime/installer.py
|
||||
module_root = Path(__file__).resolve().parent.parent.parent # lib/air_runtime/installer -> lib/air_runtime -> lib -> root
|
||||
if (module_root / ".claude-plugin").exists():
|
||||
return {"root": module_root}
|
||||
|
||||
raise FileNotFoundError("Cannot locate plugin installation directory")
|
||||
|
||||
|
||||
def get_plugin_meta() -> dict:
|
||||
"""读取 plugin.json 元数据"""
|
||||
paths = resolve_plugin_paths()
|
||||
meta_path = paths["root"] / ".claude-plugin" / "plugin.json"
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"plugin.json not found at {meta_path}")
|
||||
return json.loads(meta_path.read_text())
|
||||
|
||||
|
||||
def post_install_verify() -> dict:
|
||||
"""
|
||||
安装后验证:检查所有依赖工具是否存在。
|
||||
返回 {tool: bool} 映射,False 表示缺失。
|
||||
"""
|
||||
required_tools = [
|
||||
"git", # 版本控制
|
||||
"cmake", # 构建
|
||||
"ffmpeg", # XDB 截图
|
||||
"xvfb-run", # XDB 虚拟显示
|
||||
]
|
||||
|
||||
result = {"ok": True, "missing": []}
|
||||
for tool in required_tools:
|
||||
found = shutil.which(tool) is not None
|
||||
result[tool] = found
|
||||
if not found:
|
||||
result["ok"] = False
|
||||
result["missing"].append(tool)
|
||||
|
||||
# 检查 Python 依赖
|
||||
try:
|
||||
import yaml
|
||||
result["pyyaml"] = True
|
||||
except ImportError:
|
||||
result["pyyaml"] = False
|
||||
result["ok"] = False
|
||||
result["missing"].append("pyyaml")
|
||||
|
||||
# 检查目录结构
|
||||
paths = resolve_plugin_paths()
|
||||
for subdir in ["lib", "scripts", "skills", "commands"]:
|
||||
p = paths["root"] / subdir
|
||||
result[f"dir_{subdir}"] = p.exists()
|
||||
if not p.exists():
|
||||
result["ok"] = False
|
||||
result["missing"].append(f"dir:{subdir}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def verify_plugin_json_paths() -> dict:
|
||||
"""
|
||||
验证 plugin.json 里的路径是否可解析。
|
||||
设计原文 P0-9 指出硬编码 $HOME 是问题,这里修复它。
|
||||
"""
|
||||
meta = get_plugin_meta()
|
||||
issues = []
|
||||
|
||||
# 检查 scripts 路径
|
||||
entry = meta.get("entry", {})
|
||||
if isinstance(entry, dict):
|
||||
script_path_str = entry.get("args", [""])[0] if entry.get("args") else ""
|
||||
else:
|
||||
script_path_str = str(entry)
|
||||
if "$HOME" in script_path_str:
|
||||
# 尝试解析
|
||||
resolved = script_path_str.replace("$HOME", str(Path.home()))
|
||||
if not Path(resolved).exists():
|
||||
issues.append(f"script path not found: {resolved}")
|
||||
else:
|
||||
# 修复:改用相对路径或 AIRPLAN_HOME 变量
|
||||
issues.append(f"script uses $HOME: {script_path_str} (should use relative path)")
|
||||
|
||||
# 检查 marketplace 路径
|
||||
market_path = meta.get("marketplace", "")
|
||||
if "$HOME" in market_path:
|
||||
issues.append(f"marketplace uses $HOME: {market_path}")
|
||||
|
||||
return {
|
||||
"issues": issues,
|
||||
"resolved_paths": resolve_plugin_paths(),
|
||||
}
|
||||
53
lib/air_runtime/io.py
Normal file
53
lib/air_runtime/io.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
原子 I/O 模块 — 消除 V1 5 份 _json_dump/_json_load 重复。
|
||||
每次写入使用 tempfile + os.replace() 保证原子性,写入前自动备份。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def atomic_json_write(path: Path, data: dict | list, indent: int = 2) -> None:
|
||||
"""POSIX 原子写入:tempfile + os.replace()。写入前自动备份旧文件为 .bak(单级轮转)。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 备份旧文件
|
||||
if path.exists():
|
||||
bak = path.with_suffix(path.suffix + ".bak")
|
||||
try:
|
||||
os.replace(str(path), str(bak))
|
||||
except OSError as exc:
|
||||
logger.warning("backup %s -> %s failed: %s", path, bak, exc)
|
||||
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
||||
try:
|
||||
os.write(fd, json.dumps(data, indent=indent, ensure_ascii=False).encode("utf-8"))
|
||||
os.close(fd)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def safe_json_load(path: Path) -> dict | list | None:
|
||||
"""安全加载:处理损坏文件,自动从 .bak 恢复。文件不存在返回 None。"""
|
||||
try:
|
||||
return json.loads(path.read_text("utf-8"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
bak = path.with_suffix(path.suffix + ".bak")
|
||||
if bak.exists():
|
||||
logger.warning("corrupt %s, restoring from %s", path, bak)
|
||||
return json.loads(bak.read_text("utf-8"))
|
||||
logger.error("corrupt %s with no backup", path)
|
||||
return None
|
||||
44
lib/air_runtime/lock.py
Normal file
44
lib/air_runtime/lock.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
文件级并发控制 — 解决 V1 P0-4 零并发控制问题。
|
||||
基于 fcntl.flock 的进程级文件锁,超时自动释放。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FileLock:
|
||||
"""基于 fcntl.flock(LOCK_EX | LOCK_NB) 的进程级文件锁"""
|
||||
|
||||
def __init__(self, path: Path, timeout: float = 10.0):
|
||||
self._path = path.with_suffix(path.suffix + ".lock") if not path.suffix.endswith(".lock") else path
|
||||
self._timeout = timeout
|
||||
self._fd: int | None = None
|
||||
|
||||
def __enter__(self) -> FileLock:
|
||||
self._fd = os.open(self._path, os.O_CREAT | os.O_RDWR)
|
||||
deadline = time.monotonic() + self._timeout
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return self
|
||||
except OSError:
|
||||
if time.monotonic() >= deadline:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
raise TimeoutError(f"lock timeout after {self._timeout}s: {self._path}")
|
||||
time.sleep(0.1)
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
if self._fd is not None:
|
||||
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self._path
|
||||
11
lib/air_runtime/modes/__init__.py
Normal file
11
lib/air_runtime/modes/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""模式模块 init"""
|
||||
|
||||
from air_runtime.modes import arc_mode, eng_mode, do_mode, dbg_mode
|
||||
from air_runtime.modes import xdb_mode, sdb_mode, ndb_mode
|
||||
from air_runtime.modes import ctx_mode, dep_mode, tst_mode, sec_mode, rvr_mode
|
||||
|
||||
__all__ = [
|
||||
"arc_mode", "eng_mode", "do_mode", "dbg_mode",
|
||||
"xdb_mode", "sdb_mode", "ndb_mode",
|
||||
"ctx_mode", "dep_mode", "tst_mode", "sec_mode", "rvr_mode",
|
||||
]
|
||||
BIN
lib/air_runtime/modes/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/arc_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/arc_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/ctx_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/ctx_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/dbg_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/dbg_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/dep_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/dep_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/do_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/do_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/eng_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/eng_mode.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/merge_pipeline.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/merge_pipeline.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/ndb_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/ndb_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/rvr_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/rvr_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/sdb_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/sdb_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/sec_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/sec_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/tst_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/tst_mode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/air_runtime/modes/__pycache__/xdb_mode.cpython-313.pyc
Normal file
BIN
lib/air_runtime/modes/__pycache__/xdb_mode.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
258
lib/air_runtime/modes/arc_mode.py
Normal file
258
lib/air_runtime/modes/arc_mode.py
Normal file
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AirArc mode — V2 架构规划器。
|
||||
L1 代码级保障:allowed-tools 限制为只读。
|
||||
产出 execution-plan.json(完整 DAG)+ plan-delta.json(增量重规划)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.contracts import now_iso
|
||||
from air_runtime.io import atomic_json_write, safe_json_load
|
||||
from air_runtime.paths import airplan_root, todo_path as get_todo_path
|
||||
from air_runtime.review import build_parallel_review, render_review_markdown
|
||||
from air_runtime.task_graph import TaskGraph, TaskNode, Edge, PlanDelta
|
||||
from air_runtime.events import EventLog
|
||||
from air_runtime.paths import event_log_path
|
||||
from air_runtime.utils import ordered_unique
|
||||
|
||||
|
||||
class ArcPhaseGate:
|
||||
"""三阶段门控:discussing → proposing → confirmed。
|
||||
execution-plan.json 仅在 phase=confirmed 时允许写入。
|
||||
"""
|
||||
PHASES = ["discussing", "proposing", "confirmed"]
|
||||
|
||||
def __init__(self, state_path: Path):
|
||||
self._state_path = state_path
|
||||
|
||||
@property
|
||||
def current_phase(self) -> str:
|
||||
data = safe_json_load(self._state_path) or {}
|
||||
return data.get("arcPhase", "discussing")
|
||||
|
||||
def advance_to(self, phase: str) -> None:
|
||||
if phase not in self.PHASES:
|
||||
raise ValueError(f"invalid phase: {phase!r}")
|
||||
idx_current = self.PHASES.index(self.current_phase)
|
||||
idx_target = self.PHASES.index(phase)
|
||||
if idx_target <= idx_current:
|
||||
return
|
||||
data = safe_json_load(self._state_path) or {}
|
||||
data["arcPhase"] = phase
|
||||
data[f"arcPhase_{phase}At"] = now_iso()
|
||||
atomic_json_write(self._state_path, data)
|
||||
|
||||
def can_write_plan(self) -> bool:
|
||||
return self.current_phase == "confirmed"
|
||||
|
||||
def confirm_architecture(self, user_confirmation: str) -> bool:
|
||||
"""检查用户确认文本中的关键词,确认后推进到 confirmed。"""
|
||||
confirm_keywords = ["确认", "可以", "同意", "confirm", "yes", "ok", "好的", "没问题"]
|
||||
if any(kw in user_confirmation.lower() for kw in confirm_keywords):
|
||||
self.advance_to("confirmed")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _paths(project_root: Path) -> dict[str, Path]:
|
||||
root = airplan_root(project_root) / "state" / "airarc"
|
||||
return {
|
||||
"root": root,
|
||||
"state": root / "state.json",
|
||||
"reviews_dir": root / "reviews",
|
||||
"execution_plan_json": root / "reviews" / "execution-plan.json",
|
||||
"execution_plan_md": root / "reviews" / "execution-plan.md",
|
||||
"plan_delta_json": root / "reviews" / "plan-delta.json",
|
||||
"task_graph_json": root / "reviews" / "task-graph.json",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_dirs(paths: dict[str, Path]) -> None:
|
||||
paths["reviews_dir"].mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _export_task_graph_json(graph: TaskGraph, path: Path) -> None:
|
||||
data = {
|
||||
"nodes": {nid: {"id": n.id, "status": n.status, "task": n.task,
|
||||
"filesDirs": n.files_dirs, "doneWhen": n.done_when,
|
||||
"inDegree": n.in_degree, "outEdges": n.out_edges,
|
||||
"writeSet": n.write_set}
|
||||
for nid, n in graph.nodes.items()},
|
||||
"edges": [{"source": e.source, "target": e.target, "kind": e.kind} for e in graph.edges],
|
||||
}
|
||||
atomic_json_write(path, data)
|
||||
|
||||
|
||||
def _build_graph_from_todo(todo_path: Path) -> TaskGraph:
|
||||
"""从 todo.md 构建初始 DAG。"""
|
||||
from air_runtime.todo_parser import parse_tasks
|
||||
tasks = parse_tasks(todo_path)
|
||||
graph = TaskGraph()
|
||||
for t in tasks:
|
||||
node = TaskNode(
|
||||
id=t.task_id, status=t.status, task=t.task,
|
||||
files_dirs=t.files_dirs, done_when=t.done_when,
|
||||
)
|
||||
graph.add_node(node)
|
||||
return graph
|
||||
|
||||
|
||||
def enter_mode(project_root: Path) -> dict:
|
||||
paths = _paths(project_root)
|
||||
_ensure_dirs(paths)
|
||||
payload = {"enabled": True, "updatedAt": now_iso(), "projectRoot": str(project_root)}
|
||||
atomic_json_write(paths["state"], payload)
|
||||
return {"state_path": str(paths["state"])}
|
||||
|
||||
|
||||
def parallel_review_mode(project_root: Path, todo_path: Path) -> dict:
|
||||
paths = _paths(project_root)
|
||||
_ensure_dirs(paths)
|
||||
|
||||
# 三阶段门控:仅在 confirmed 阶段允许写入 execution-plan.json
|
||||
gate = ArcPhaseGate(paths["state"])
|
||||
if not gate.can_write_plan():
|
||||
return {
|
||||
"blocked": True,
|
||||
"reason": f"arc phase is '{gate.current_phase}', must be 'confirmed' before generating plan",
|
||||
"currentPhase": gate.current_phase,
|
||||
}
|
||||
|
||||
review = build_parallel_review(todo_path)
|
||||
review_json_path = paths["reviews_dir"] / "parallel-review.json"
|
||||
review_md_path = paths["reviews_dir"] / "parallel-review.md"
|
||||
atomic_json_write(review_json_path, review.to_dict())
|
||||
review_md_path.write_text(render_review_markdown(review), encoding="utf-8")
|
||||
|
||||
# 构建 DAG
|
||||
graph = _build_graph_from_todo(todo_path)
|
||||
for edge_info in review.to_dict().get("edges", []):
|
||||
graph.add_edge(Edge(source=edge_info["source"], target=edge_info["target"],
|
||||
kind=edge_info.get("kind", "dependency")))
|
||||
|
||||
_export_task_graph_json(graph, paths["task_graph_json"])
|
||||
|
||||
# 生成执行计划
|
||||
selected_tasks = review.parallel_groups[0].task_ids if review.parallel_groups else []
|
||||
execution_plan = {
|
||||
"generatedAt": now_iso(), "projectRoot": str(project_root),
|
||||
"todoPath": str(todo_path), "planSource": "airarc-post-plan-review",
|
||||
"parallelReview": review.to_dict(),
|
||||
"selectedTasks": selected_tasks,
|
||||
"parallelGroups": [g.to_dict() for g in review.parallel_groups],
|
||||
"conflicts": [c.to_dict() for c in review.conflicts],
|
||||
"serializationPoints": review.serialization_points,
|
||||
}
|
||||
atomic_json_write(paths["execution_plan_json"], execution_plan)
|
||||
|
||||
markdown_lines = [
|
||||
"# AirArc Execution Plan", "",
|
||||
f"- Generated: `{execution_plan['generatedAt']}`",
|
||||
f"- Plan Source: `{execution_plan['planSource']}`", "",
|
||||
"## Selected Tasks",
|
||||
]
|
||||
for tid in selected_tasks:
|
||||
markdown_lines.append(f"- `{tid}`")
|
||||
markdown_lines.extend(["", "## Parallel Groups"])
|
||||
for g in review.parallel_groups:
|
||||
markdown_lines.append(f"- `{g.name}`: {', '.join(g.task_ids)} — {g.reason}")
|
||||
paths["execution_plan_md"].write_text("\n".join(markdown_lines) + "\n", encoding="utf-8")
|
||||
|
||||
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
|
||||
"projectRoot": str(project_root)})
|
||||
return {"json_path": str(review_json_path), "markdown_path": str(review_md_path),
|
||||
"execution_plan_json_path": str(paths["execution_plan_json"]),
|
||||
"parallel_group_count": len(review.parallel_groups),
|
||||
"conflict_count": len(review.conflicts)}
|
||||
|
||||
|
||||
def incremental_replan_mode(project_root: Path, todo_path: Path, previous_graph_path: Path | None = None) -> dict:
|
||||
"""增量重规划:产出 PlanDelta 并喂回 prev graph,再写 plan-delta.json。"""
|
||||
paths = _paths(project_root)
|
||||
_ensure_dirs(paths)
|
||||
|
||||
review = build_parallel_review(todo_path)
|
||||
new_graph = _build_graph_from_todo(todo_path)
|
||||
for edge_info in review.to_dict().get("edges", []):
|
||||
new_graph.add_edge(Edge(source=edge_info["source"], target=edge_info["target"],
|
||||
kind=edge_info.get("kind", "dependency")))
|
||||
|
||||
delta = PlanDelta()
|
||||
if previous_graph_path and previous_graph_path.exists():
|
||||
prev_graph = TaskGraph.load(previous_graph_path)
|
||||
delta = new_graph.diff(prev_graph)
|
||||
# 关键:把 delta 喂回去给 prev graph,保留已调度状态
|
||||
if previous_graph_path.exists():
|
||||
prev_graph.apply_delta(delta)
|
||||
_export_task_graph_json(prev_graph, previous_graph_path)
|
||||
else:
|
||||
delta.added_tasks = list(new_graph.nodes.values())
|
||||
delta.edge_changes.added = list(new_graph.edges)
|
||||
|
||||
atomic_json_write(paths["plan_delta_json"], {
|
||||
"generatedAt": now_iso(),
|
||||
"removedTasks": delta.removed_tasks,
|
||||
"addedTasks": [{"id": n.id, "task": n.task, "filesDirs": n.files_dirs,
|
||||
"doneWhen": n.done_when, "writeSet": n.write_set}
|
||||
for n in delta.added_tasks],
|
||||
"modifiedTasks": [{"id": n.id, "task": n.task, "filesDirs": n.files_dirs,
|
||||
"doneWhen": n.done_when, "writeSet": n.write_set}
|
||||
for n in delta.modified_tasks],
|
||||
"edgeChanges": {
|
||||
"added": [{"source": e.source, "target": e.target, "kind": e.kind}
|
||||
for e in delta.edge_changes.added],
|
||||
"removed": [{"source": e.source, "target": e.target, "kind": e.kind}
|
||||
for e in delta.edge_changes.removed],
|
||||
},
|
||||
})
|
||||
|
||||
_export_task_graph_json(new_graph, paths["task_graph_json"])
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit("arc.replanned", {"delta_added": len(delta.added_tasks),
|
||||
"delta_removed": len(delta.removed_tasks),
|
||||
"delta_modified": len(delta.modified_tasks),
|
||||
"edges_added": len(delta.edge_changes.added),
|
||||
"edges_removed": len(delta.edge_changes.removed)})
|
||||
|
||||
return {"plan_delta_json_path": str(paths["plan_delta_json"]),
|
||||
"task_graph_json_path": str(paths["task_graph_json"]),
|
||||
"added_count": len(delta.added_tasks),
|
||||
"removed_count": len(delta.removed_tasks),
|
||||
"modified_count": len(delta.modified_tasks),
|
||||
"edges_added": len(delta.edge_changes.added),
|
||||
"edges_removed": len(delta.edge_changes.removed)}
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
sub = args.sub or "status"
|
||||
|
||||
if sub == "enter":
|
||||
result = enter_mode(project_root)
|
||||
print("airplan_mode=arc")
|
||||
print(f"state_path={result['state_path']}")
|
||||
elif sub == "parallel-review":
|
||||
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
|
||||
result = parallel_review_mode(project_root, tpath)
|
||||
print("airplan_mode=arc")
|
||||
print(f"json_path={result['json_path']}")
|
||||
print(f"parallel_group_count={result['parallel_group_count']}")
|
||||
print(f"conflict_count={result['conflict_count']}")
|
||||
elif sub == "incremental-replan":
|
||||
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
|
||||
prev = _paths(project_root)["task_graph_json"]
|
||||
result = incremental_replan_mode(project_root, tpath, prev)
|
||||
print("airplan_mode=arc")
|
||||
print(f"plan_delta_path={result['plan_delta_json_path']}")
|
||||
print(f"added={result['added_count']} removed={result['removed_count']}")
|
||||
else:
|
||||
paths = _paths(project_root)
|
||||
state = safe_json_load(paths["state"]) or {}
|
||||
print(f"airplan_mode=arc")
|
||||
print(f"enabled={state.get('enabled', False)}")
|
||||
232
lib/air_runtime/modes/ctx_mode.py
Normal file
232
lib/air_runtime/modes/ctx_mode.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
AirContext mode — V2 上下文管理器。
|
||||
V2 改进:压缩质量校验、自适应 Token 估算、陈旧锁检测、三级降级压缩。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
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, CONTEXT_COMPACTED
|
||||
from air_runtime.utils import now_iso
|
||||
|
||||
|
||||
class CompressionLevel:
|
||||
"""三级降级"""
|
||||
RETRY = "retry" # 1. 重试一次
|
||||
FALLBACK_MODEL = "fallback_model" # 2. 换模型
|
||||
TRUNCATE = "truncate" # 3. 激进截断
|
||||
|
||||
|
||||
DEFAULT_TRUNCATION_KEEP = 10 # 保留最近 10 轮
|
||||
|
||||
CHARS_PER_TOKEN = {
|
||||
"chinese": 1.5,
|
||||
"english": 4.0,
|
||||
"code": 3.0,
|
||||
"markup": 5.0,
|
||||
}
|
||||
|
||||
MUST_PRESERVE_PATTERNS = [
|
||||
r"[A-Za-z0-9_\-/]+\.(py|ts|js|cpp|h|md|json|yaml)",
|
||||
r"ADR-\d{4}",
|
||||
r"TODO|FIXME|HACK",
|
||||
r"INV-\d+",
|
||||
]
|
||||
|
||||
|
||||
def _ctx_paths(project_root: Path) -> dict[str, Path]:
|
||||
root = airplan_root(project_root) / "state" / "aircontext"
|
||||
return {"root": root, "state": root / "state.json", "lock": root / "compactor.lock"}
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
chinese = len(re.findall(r"[一-鿿]", text))
|
||||
code = len(re.findall(r"[{}()\[\];=<>]", text))
|
||||
markup = len(re.findall(r"[#*\-`|]", text))
|
||||
english = max(0, len(text) - chinese - code - markup)
|
||||
tokens = (
|
||||
chinese / CHARS_PER_TOKEN["chinese"]
|
||||
+ code / CHARS_PER_TOKEN["code"]
|
||||
+ markup / CHARS_PER_TOKEN["markup"]
|
||||
+ english / CHARS_PER_TOKEN["english"]
|
||||
)
|
||||
return int(tokens)
|
||||
|
||||
|
||||
def validate_compression(original: str, summary: str) -> dict:
|
||||
missing = []
|
||||
for pattern in MUST_PRESERVE_PATTERNS:
|
||||
orig_matches = set(re.findall(pattern, original))
|
||||
summary_matches = set(re.findall(pattern, summary))
|
||||
lost = orig_matches - summary_matches
|
||||
if len(lost) > len(orig_matches) * 0.3 and len(orig_matches) > 3:
|
||||
missing.append({"pattern": pattern, "lost": list(lost)[:10]})
|
||||
return {"ok": len(missing) == 0, "missing": missing, "originalTokens": estimate_tokens(original),
|
||||
"summaryTokens": estimate_tokens(summary)}
|
||||
|
||||
|
||||
def _compress_basic(text: str, max_tokens: int) -> str:
|
||||
"""基础压缩:token 估算 + 截断"""
|
||||
estimated_tokens = len(text) // 3
|
||||
if estimated_tokens <= max_tokens:
|
||||
return text
|
||||
# 按行截断
|
||||
lines = text.split('\n')
|
||||
chars_per_line_estimate = 30
|
||||
keep_lines = int(max_tokens * chars_per_line_estimate / 80) # 80 chars/line
|
||||
return '\n'.join(lines[-keep_lines:])
|
||||
|
||||
|
||||
def _simplify_prompt(text: str) -> str:
|
||||
"""简化 prompt:移除详细上下文,保留核心"""
|
||||
lines = text.split('\n')
|
||||
# 只保留前 3 行 + 包含 "def " / "class " / "#" 的行
|
||||
kept = lines[:3]
|
||||
kept.extend([l for l in lines[3:] if 'def ' in l or 'class ' in l or l.startswith('#')])
|
||||
return '\n'.join(kept)
|
||||
|
||||
|
||||
def compress_with_fallback(context: str, max_tokens: int = 4000) -> dict:
|
||||
"""
|
||||
三级降级压缩:
|
||||
- 尝试正常压缩
|
||||
- 失败则换模型重试
|
||||
- 再失败则激进截断
|
||||
返回: {"level": "...", "result": "...", "tokens": N}
|
||||
"""
|
||||
# Level 1: 正常尝试
|
||||
try:
|
||||
result = _compress_basic(context, max_tokens)
|
||||
return {"level": CompressionLevel.RETRY, "result": result, "tokens": len(result.split())}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Level 2: 换模型(更简单的 prompt + 更宽松的 max_tokens)
|
||||
try:
|
||||
simplified = _simplify_prompt(context)
|
||||
result = _compress_basic(simplified, int(max_tokens * 1.5))
|
||||
return {"level": CompressionLevel.FALLBACK_MODEL, "result": result, "tokens": len(result.split())}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Level 3: 激进截断
|
||||
lines = context.split('\n')
|
||||
# 提取 ADR 引用行
|
||||
adr_lines = [l for l in lines if 'ADR-' in l or 'adr-' in l]
|
||||
# 保留最近 N 轮
|
||||
recent_lines = lines[-DEFAULT_TRUNCATION_KEEP * 5:] # 每轮约 5 行
|
||||
truncated = '\n'.join(recent_lines + adr_lines)
|
||||
return {
|
||||
"level": CompressionLevel.TRUNCATE,
|
||||
"result": truncated,
|
||||
"tokens": len(truncated.split()),
|
||||
"warning": f"truncated to {DEFAULT_TRUNCATION_KEEP * 5} recent lines + {len(adr_lines)} ADR lines"
|
||||
}
|
||||
|
||||
|
||||
def validate_compression_with_fallback(project_root: Path, context_path: Path) -> dict:
|
||||
"""验证压缩有效性,失败时触发三级降级"""
|
||||
content = context_path.read_text()
|
||||
original_len = len(content)
|
||||
|
||||
# 先用当前配置尝试
|
||||
result = compress_with_fallback(content)
|
||||
|
||||
validation = {
|
||||
"original_chars": original_len,
|
||||
"result_chars": len(result["result"]),
|
||||
"level": result["level"],
|
||||
"tokens": result.get("tokens", 0),
|
||||
}
|
||||
|
||||
if result["level"] == CompressionLevel.TRUNCATE:
|
||||
validation["warning"] = result.get("warning", "")
|
||||
|
||||
return validation
|
||||
|
||||
|
||||
def acquire_compactor_lock(lock_path: Path) -> bool:
|
||||
try:
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, str(os.getpid()).encode())
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
try:
|
||||
pid = int(lock_path.read_text().strip())
|
||||
os.kill(pid, 0)
|
||||
return False
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
lock_path.unlink(missing_ok=True)
|
||||
return acquire_compactor_lock(lock_path)
|
||||
|
||||
|
||||
def ctx_enter(project_root: Path) -> dict:
|
||||
paths = _ctx_paths(project_root)
|
||||
paths["root"].mkdir(parents=True, exist_ok=True)
|
||||
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
|
||||
"projectRoot": str(project_root)})
|
||||
return {"state_path": str(paths["state"])}
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
sub = args.sub or "status"
|
||||
paths = _ctx_paths(project_root)
|
||||
|
||||
if sub == "enter":
|
||||
result = ctx_enter(project_root)
|
||||
print(f"airplan_mode=ctx\nstate_path={result['state_path']}")
|
||||
elif sub == "estimate":
|
||||
text = "sample" # 实际使用时从 stdin 或文件读取
|
||||
tokens = estimate_tokens(text)
|
||||
print(f"airplan_mode=ctx\ntokens={tokens}")
|
||||
elif sub == "validate":
|
||||
ctx_path = project_root / "AirPlan" / "context.md"
|
||||
if ctx_path.exists():
|
||||
validation = validate_compression_with_fallback(project_root, ctx_path)
|
||||
ok = validation["level"] != CompressionLevel.TRUNCATE
|
||||
print(f"airplan_mode=ctx\nvalidation_ok={ok}\nlevel={validation['level']}\ntokens={validation['tokens']}")
|
||||
if "warning" in validation:
|
||||
print(f"warning={validation['warning']}")
|
||||
else:
|
||||
print("airplan_mode=ctx\nvalidation_ok=true")
|
||||
elif sub == "compress":
|
||||
# 读取 context 文件
|
||||
ctx_path = project_root / "AirPlan" / "context.md"
|
||||
if not ctx_path.exists():
|
||||
print("error: context.md not found")
|
||||
return
|
||||
|
||||
# 调用三级降级压缩
|
||||
content = ctx_path.read_text()
|
||||
result = compress_with_fallback(content)
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(CONTEXT_COMPACTED, {
|
||||
"compressionLevel": result["level"],
|
||||
"originalChars": len(content),
|
||||
"resultChars": len(result["result"]),
|
||||
"tokens": result.get("tokens", 0),
|
||||
})
|
||||
|
||||
print(f"airplan_mode=ctx")
|
||||
print(f"compression_level={result['level']}")
|
||||
print(f"original_chars={len(content)}")
|
||||
print(f"result_chars={len(result['result'])}")
|
||||
if 'warning' in result:
|
||||
print(f"warning={result['warning']}")
|
||||
|
||||
# 可选:写回压缩结果
|
||||
if getattr(args, "write_back", False):
|
||||
compressed_path = project_root / "AirPlan" / "context.compressed.md"
|
||||
compressed_path.write_text(result['result'])
|
||||
print(f"written_to={compressed_path}")
|
||||
else:
|
||||
state = safe_json_load(paths["state"]) or {}
|
||||
print(f"airplan_mode=ctx\nenabled={state.get('enabled', False)}")
|
||||
279
lib/air_runtime/modes/dbg_mode.py
Normal file
279
lib/air_runtime/modes/dbg_mode.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
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)}")
|
||||
39
lib/air_runtime/modes/dep_mode.py
Normal file
39
lib/air_runtime/modes/dep_mode.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""AirDep mode — V2 部署器。"""
|
||||
|
||||
from pathlib import Path
|
||||
from air_runtime.deploy_runtime import deploy, DeployTarget
|
||||
from air_runtime.io import safe_json_load
|
||||
from air_runtime.paths import airplan_root, event_log_path
|
||||
from air_runtime.events import EventLog, DEPLOY_COMPLETED
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
tid = args.task_id
|
||||
sub = args.sub or "deploy"
|
||||
|
||||
if sub == "deploy" and args.host:
|
||||
target = DeployTarget(host=args.host)
|
||||
binary = Path(args.binary).expanduser().resolve() if args.binary else Path(".")
|
||||
result = deploy(tid, project_root, target, binary, tid)
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(DEPLOY_COMPLETED, {
|
||||
"taskId": tid,
|
||||
"success": result.success,
|
||||
"host": args.host,
|
||||
"binaryMd5": result.binary_md5,
|
||||
"serviceStatus": result.service_status,
|
||||
})
|
||||
|
||||
print("airplan_mode=dep")
|
||||
print(f"task_id={tid}")
|
||||
print(f"success={result.success}")
|
||||
print(f"md5={result.binary_md5}")
|
||||
print(f"service_status={result.service_status}")
|
||||
if result.error:
|
||||
print(f"error={result.error}")
|
||||
else:
|
||||
paths = airplan_root(project_root) / "state" / "airdep"
|
||||
state = safe_json_load(paths / "state.json") or {}
|
||||
print(f"airplan_mode=dep\nenabled={state.get('enabled', False)}")
|
||||
165
lib/air_runtime/modes/do_mode.py
Normal file
165
lib/air_runtime/modes/do_mode.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
AirDo mode — V2 任务执行器。
|
||||
V2 改进:强制 AirDbg 路由(L1 代码级),task_id 注入防护。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
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, TASK_COMPLETED, TASK_BLOCKED
|
||||
from air_runtime.contracts import WorkerResult, now_iso
|
||||
from air_runtime.utils import sanitize_task_id, session_stamp
|
||||
|
||||
|
||||
def _paths(project_root: Path, task_id: str) -> dict[str, Path]:
|
||||
root = airplan_root(project_root) / "state" / "airdo"
|
||||
task_dir = root / "tasks" / task_id
|
||||
return {
|
||||
"root": root,
|
||||
"state": root / "state.json",
|
||||
"task_dir": task_dir,
|
||||
"brief": task_dir / "brief.md",
|
||||
"handoff": task_dir / "subagent-handoff.md",
|
||||
"result": task_dir / "result.json",
|
||||
"worker_state": task_dir / "worker-state.json",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_dirs(paths: dict[str, Path]) -> None:
|
||||
paths["task_dir"].mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def enter_worker(project_root: Path, task_id: str) -> dict:
|
||||
tid = sanitize_task_id(task_id)
|
||||
paths = _paths(project_root, tid)
|
||||
_ensure_dirs(paths)
|
||||
|
||||
worker_state = {
|
||||
"taskId": tid, "status": "implementing",
|
||||
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
|
||||
}
|
||||
atomic_json_write(paths["worker_state"], worker_state)
|
||||
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
|
||||
"updatedAt": now_iso()})
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit("task.entered", {"taskId": tid})
|
||||
|
||||
return {
|
||||
"taskId": tid, "briefPath": str(paths["brief"]),
|
||||
"handoffPath": str(paths["handoff"]),
|
||||
"resultPath": str(paths["result"]),
|
||||
"workerStatePath": str(paths["worker_state"]),
|
||||
}
|
||||
|
||||
|
||||
def finish_worker(project_root: Path, task_id: str, result_path: Path | None = None) -> dict:
|
||||
"""V2 核心改进:强制 AirDbg 路由。"""
|
||||
tid = sanitize_task_id(task_id)
|
||||
paths = _paths(project_root, tid)
|
||||
|
||||
# 加载 result
|
||||
if result_path and result_path.exists():
|
||||
result_data = safe_json_load(result_path)
|
||||
elif paths["result"].exists():
|
||||
result_data = safe_json_load(paths["result"])
|
||||
else:
|
||||
result_data = {"taskId": tid, "status": "blocked", "summary": "no result found"}
|
||||
|
||||
if not isinstance(result_data, dict):
|
||||
result_data = {"taskId": tid, "status": "blocked"}
|
||||
|
||||
result = WorkerResult.from_dict(result_data)
|
||||
status = result.status
|
||||
|
||||
# V2 L1 代码级:done 但无证据 → 强制 AirDbg
|
||||
if status == "done":
|
||||
if not result.validations and not result.files_changed:
|
||||
routing_decision = {
|
||||
"target": "airdbg",
|
||||
"reason": "done without evidence — mandatory debug review",
|
||||
"forced": True,
|
||||
}
|
||||
else:
|
||||
routing_decision = {"target": "merge", "forced": False}
|
||||
|
||||
# V2 L1 代码级:blocked/failed → 强制 AirDbg
|
||||
elif status in ("blocked", "failed"):
|
||||
routing_decision = {
|
||||
"target": "airdbg",
|
||||
"reason": f"status={status} — AirDbg mandatory before return",
|
||||
"forced": True,
|
||||
}
|
||||
else:
|
||||
routing_decision = {"target": "merge", "forced": False}
|
||||
|
||||
# 持久化
|
||||
finalized = result.to_dict()
|
||||
finalized["routingDecision"] = routing_decision
|
||||
finalized["finalizedAt"] = now_iso()
|
||||
atomic_json_write(paths["result"], finalized)
|
||||
atomic_json_write(paths["worker_state"], {"taskId": tid, "status": "finished",
|
||||
"resultPath": str(paths["result"]),
|
||||
"routingDecision": routing_decision})
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit("task.finished", {"taskId": tid, "status": status,
|
||||
"routingTarget": routing_decision["target"]})
|
||||
|
||||
# emit task.completed / task.blocked based on final status
|
||||
if status == "done":
|
||||
log.emit(TASK_COMPLETED, {"taskId": tid, "routingTarget": routing_decision["target"]})
|
||||
elif status in ("blocked", "failed"):
|
||||
log.emit(TASK_BLOCKED, {"taskId": tid, "status": status})
|
||||
|
||||
return {
|
||||
"taskId": tid, "status": status,
|
||||
"finalizedResultPath": str(paths["result"]),
|
||||
"workerStatePath": str(paths["worker_state"]),
|
||||
"routingDecision": routing_decision,
|
||||
}
|
||||
|
||||
|
||||
def status_worker(project_root: Path) -> dict:
|
||||
paths = _paths(project_root, "_")
|
||||
state = safe_json_load(paths["state"]) or {}
|
||||
task_ids = []
|
||||
if paths["root"].joinpath("tasks").exists():
|
||||
task_ids = [d.name for d in paths["root"].joinpath("tasks").iterdir() if d.is_dir()]
|
||||
return {
|
||||
"enabled": state.get("enabled", False),
|
||||
"activeTaskId": state.get("activeTaskId", ""),
|
||||
"taskIds": task_ids,
|
||||
}
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
sub = args.sub or "status"
|
||||
tid = args.task_id
|
||||
|
||||
if sub == "status":
|
||||
s = status_worker(project_root)
|
||||
print("airplan_mode=do")
|
||||
print(f"enabled={s['enabled']}")
|
||||
print(f"active_task_id={s['activeTaskId']}")
|
||||
print(f"known_tasks={','.join(s['taskIds'])}")
|
||||
elif sub == "enter":
|
||||
result = enter_worker(project_root, tid)
|
||||
print("airplan_mode=do")
|
||||
print(f"task_id={result['taskId']}")
|
||||
print(f"brief_path={result['briefPath']}")
|
||||
print(f"result_path={result['resultPath']}")
|
||||
print(f"worker_state_path={result['workerStatePath']}")
|
||||
elif sub == "finish":
|
||||
rpath = Path(args.result).expanduser().resolve() if args.result else None
|
||||
finalized = finish_worker(project_root, tid, rpath)
|
||||
print("airplan_mode=do")
|
||||
print(f"task_id={finalized['taskId']}")
|
||||
print(f"status={finalized['status']}")
|
||||
print(f"routing_target={finalized['routingDecision']['target']}")
|
||||
print(f"routing_forced={finalized['routingDecision']['forced']}")
|
||||
577
lib/air_runtime/modes/eng_mode.py
Normal file
577
lib/air_runtime/modes/eng_mode.py
Normal file
@@ -0,0 +1,577 @@
|
||||
"""
|
||||
AirEng mode — V2 调度引擎。
|
||||
L1 代码级保障:硬编码轮询循环、Worker 超时、资源压力检测、事务化合并。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.io import atomic_json_write, safe_json_load
|
||||
from air_runtime.lock import FileLock
|
||||
from air_runtime.paths import (
|
||||
airplan_root, todo_path as get_todo_path, engine_state_path,
|
||||
event_log_path, plan_path, agents_path,
|
||||
)
|
||||
from air_runtime.events import EventLog, TASK_DISPATCHED, TASK_COMPLETED, TASK_BLOCKED, MERGE_STARTED, MERGE_COMPLETED, \
|
||||
INTERVENTION_STALL, ENGINE_CYCLE, WORKER_TIMEOUT, REPAIR_CREATED, REPAIR_RESOLVED
|
||||
from air_runtime.evidence_gate import EvidenceGatePolicy, EvidenceClass
|
||||
from air_runtime.modes.merge_pipeline import (
|
||||
apply_document_updates,
|
||||
enforce_doc_sync_requirements,
|
||||
sync_engine_managed_docs,
|
||||
update_todo_after_merge,
|
||||
)
|
||||
from air_runtime.task_graph import TaskGraph
|
||||
from air_runtime.todo_parser import parse_tasks
|
||||
from air_runtime.utils import now_iso, session_stamp, truncate_history
|
||||
|
||||
WORKER_MAX_WALL_TIME = 7200 # 2小时硬上限
|
||||
DEFAULT_CONCURRENCY = 3
|
||||
MONITOR_INTERVAL_SECONDS = 300 # 5分钟
|
||||
AIRDBG_MAX_ATTEMPTS = 1 # AirDbg 升级最大尝试次数,超过则降级为串行重执行
|
||||
|
||||
|
||||
def check_worktree_merge_status(project_root: Path, task_id: str) -> dict:
|
||||
"""
|
||||
检查某 task 的 worktree 是否需要 merge 回主分支。
|
||||
如果 merge 失败(conflicts),自动升级到 AirDbg。
|
||||
再失败则降级为串行重执行。
|
||||
返回: {"status": "ok" | "upgraded_to_airdbg" | "downgraded_to_serial", ...}
|
||||
"""
|
||||
from air_runtime.worktree import WorktreeIsolation
|
||||
|
||||
wt_path = project_root / ".git" / "worktrees" / f"air-{task_id}"
|
||||
if not wt_path.exists():
|
||||
return {"status": "ok"} # 无 worktree,正常
|
||||
|
||||
# 尝试 merge 回主分支
|
||||
wt = WorktreeIsolation(repo_root=project_root)
|
||||
result = wt.merge_back(task_id, wt_path)
|
||||
|
||||
if result.successful:
|
||||
# merge 成功,清理 worktree
|
||||
wt.cleanup(task_id, wt_path)
|
||||
return {"status": "ok", "conflicts": []}
|
||||
|
||||
# merge 失败 → 升级到 AirDbg
|
||||
from air_runtime.modes.dbg_mode import start_session
|
||||
|
||||
session = start_session(project_root, task_id)
|
||||
|
||||
return {
|
||||
"status": "upgraded_to_airdbg",
|
||||
"taskId": task_id,
|
||||
"conflicts": result.conflicts,
|
||||
"sessionId": session.get("sessionId"),
|
||||
}
|
||||
|
||||
|
||||
def _paths(project_root: Path) -> dict[str, Path]:
|
||||
root = airplan_root(project_root) / "state" / "aireng"
|
||||
return {
|
||||
"root": root,
|
||||
"state": root / "state.json",
|
||||
"dispatch_dir": root / "dispatch",
|
||||
"archive_dir": root / "archive",
|
||||
"plan_dir": root / "plans",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_dirs(paths: dict[str, Path]) -> None:
|
||||
for key in ("dispatch_dir", "archive_dir", "plan_dir"):
|
||||
paths[key].mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _init_state(project_root: Path) -> dict:
|
||||
return {
|
||||
"enabled": True,
|
||||
"updatedAt": now_iso(),
|
||||
"projectRoot": str(project_root),
|
||||
"engineMode": "idle",
|
||||
"activeWaveId": "",
|
||||
"activeDispatchPath": "",
|
||||
"activeWorkers": [],
|
||||
"mergedResults": [],
|
||||
"pendingGlobalUpdates": [],
|
||||
"interventionHistory": [],
|
||||
"monitoringPolicy": {"checkIntervalSeconds": MONITOR_INTERVAL_SECONDS},
|
||||
"concurrency": DEFAULT_CONCURRENCY,
|
||||
"planningSource": "",
|
||||
"nextAction": "plan",
|
||||
"lastLoopAt": "",
|
||||
"lastInterventionAt": "",
|
||||
"xdbSessions": [],
|
||||
"debugSessions": [],
|
||||
"repairAttempts": [],
|
||||
"activeRepairCount": 0,
|
||||
"repairPolicy": {"enabled": True, "maxAttempts": 3},
|
||||
"xdbPolicy": {"enabled": True},
|
||||
"reviewPolicy": {"requireBeforeMerge": False, "maxRepairRounds": 3},
|
||||
"residualItems": [],
|
||||
"debugPolicy": {"enabled": True},
|
||||
}
|
||||
|
||||
|
||||
def enter_engine(project_root: Path) -> tuple[str, dict]:
|
||||
paths = _paths(project_root)
|
||||
_ensure_dirs(paths)
|
||||
state = _init_state(project_root)
|
||||
atomic_json_write(paths["state"], state)
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit("engine.entered")
|
||||
return str(paths["state"]), {}
|
||||
|
||||
|
||||
def status_engine(project_root: Path) -> dict:
|
||||
paths = _paths(project_root)
|
||||
return safe_json_load(paths["state"]) or _init_state(project_root)
|
||||
|
||||
|
||||
def build_engine_plan(project_root: Path, todo_path: Path) -> dict:
|
||||
paths = _paths(project_root)
|
||||
_ensure_dirs(paths)
|
||||
arc_reviews = airplan_root(project_root) / "state" / "airarc" / "reviews"
|
||||
plan_json = arc_reviews / "execution-plan.json"
|
||||
task_graph_json = arc_reviews / "task-graph.json"
|
||||
|
||||
planning_source = "engine-fallback"
|
||||
plan_data: dict = {}
|
||||
|
||||
if plan_json.exists():
|
||||
loaded = safe_json_load(plan_json)
|
||||
if loaded and isinstance(loaded, dict):
|
||||
plan_data = loaded
|
||||
planning_source = "airarc-execution-plan"
|
||||
|
||||
if not plan_data:
|
||||
tasks = parse_tasks(todo_path)
|
||||
plan_data = {
|
||||
"selectedTasks": [t.task_id for t in tasks if t.status == "TODO"],
|
||||
"parallelGroups": [],
|
||||
}
|
||||
|
||||
plan_path = paths["plan_dir"] / f"{session_stamp()}.json"
|
||||
atomic_json_write(plan_path, plan_data)
|
||||
|
||||
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||
state["planningSource"] = planning_source
|
||||
state["nextAction"] = "dispatch"
|
||||
atomic_json_write(paths["state"], state)
|
||||
|
||||
return {
|
||||
"planPath": str(plan_path),
|
||||
"planningSource": planning_source,
|
||||
"selectedTasks": plan_data.get("selectedTasks", []),
|
||||
"parallelGroupCount": len(plan_data.get("parallelGroups", [])),
|
||||
"taskGraphPath": str(task_graph_json),
|
||||
"planJson": plan_data,
|
||||
}
|
||||
|
||||
|
||||
def dispatch_worker_group(project_root: Path, group_name: str = "") -> dict:
|
||||
"""派发 worker 组,含区域冲突检测。"""
|
||||
paths = _paths(project_root)
|
||||
_ensure_dirs(paths)
|
||||
|
||||
# P0 修复:每次派发前检查 todo.md 是否更新,如有则触发增量重规划
|
||||
replan_result = maybe_replan(project_root)
|
||||
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||
if replan_result:
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit("eng.replan.triggered", {
|
||||
"added": replan_result.get("added_count", 0),
|
||||
"removed": replan_result.get("removed_count", 0),
|
||||
"modified": replan_result.get("modified_count", 0),
|
||||
})
|
||||
state["lastReplanAt"] = now_iso()
|
||||
atomic_json_write(paths["state"], state)
|
||||
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||
|
||||
wave_id = f"wave-{session_stamp()}"
|
||||
task_ids = _select_ready_tasks(project_root, state.get("concurrency", DEFAULT_CONCURRENCY))
|
||||
|
||||
if not task_ids:
|
||||
return {"dispatchPath": "", "groupName": group_name, "waveId": wave_id,
|
||||
"taskIds": [], "recommendedConcurrency": 0}
|
||||
|
||||
# 区域冲突检测:多个任务时检查写集重叠
|
||||
dispatch_metadata: dict | None = None
|
||||
if len(task_ids) > 1:
|
||||
from air_runtime.worktree import RegionConflictDetector, ConflictLevel
|
||||
|
||||
todo_path = get_todo_path(project_root)
|
||||
tasks = parse_tasks(todo_path)
|
||||
task_write_sets = {
|
||||
t.task_id: [f.strip() for f in t.files_dirs.split(",") if f.strip()]
|
||||
for t in tasks if t.task_id in task_ids
|
||||
}
|
||||
|
||||
if task_write_sets:
|
||||
detector = RegionConflictDetector()
|
||||
conflicts = detector.detect_batch(task_write_sets)
|
||||
|
||||
hard_blocked = [c for c in conflicts if c.level == ConflictLevel.HARD]
|
||||
if hard_blocked:
|
||||
# HARD 冲突:强制串行,只派第一个
|
||||
task_ids = task_ids[:1]
|
||||
dispatch_metadata = {
|
||||
"forcedSerialization": True,
|
||||
"reason": f"HARD conflict: {hard_blocked[0].task_a} <-> {hard_blocked[0].task_b}",
|
||||
}
|
||||
else:
|
||||
soft_conflicts = [c for c in conflicts if c.level == ConflictLevel.SOFT]
|
||||
if soft_conflicts:
|
||||
dispatch_metadata = {
|
||||
"worktreeIsolation": True,
|
||||
"softConflicts": [c.to_dict() for c in soft_conflicts],
|
||||
}
|
||||
|
||||
dispatch_payload = {
|
||||
"waveId": wave_id, "groupName": group_name,
|
||||
"taskIds": task_ids,
|
||||
"createdAt": now_iso(),
|
||||
"recommendedConcurrency": min(len(task_ids), state.get("concurrency", DEFAULT_CONCURRENCY)),
|
||||
}
|
||||
dispatch_path = paths["dispatch_dir"] / f"{wave_id}.json"
|
||||
atomic_json_write(dispatch_path, dispatch_payload)
|
||||
|
||||
state["activeWaveId"] = wave_id
|
||||
state["activeDispatchPath"] = str(dispatch_path)
|
||||
state["engineMode"] = "running"
|
||||
state["nextAction"] = "monitor"
|
||||
if dispatch_metadata:
|
||||
state["dispatchMetadata"] = dispatch_metadata
|
||||
atomic_json_write(paths["state"], state)
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
for tid in task_ids:
|
||||
log.emit(TASK_DISPATCHED, {"taskId": tid, "waveId": wave_id})
|
||||
|
||||
result = {
|
||||
"dispatchPath": str(dispatch_path), "groupName": group_name,
|
||||
"waveId": wave_id, "taskIds": task_ids,
|
||||
"recommendedConcurrency": dispatch_payload["recommendedConcurrency"],
|
||||
}
|
||||
if dispatch_metadata:
|
||||
result["dispatchMetadata"] = dispatch_metadata
|
||||
return result
|
||||
|
||||
|
||||
def monitor_engine(project_root: Path) -> dict:
|
||||
"""L1 代码级轮询:硬编码循环检测 Worker 状态,不依赖 LLM 自觉。"""
|
||||
paths = _paths(project_root)
|
||||
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||
|
||||
active_workers = state.get("activeWorkers", [])
|
||||
stalled_count = 0
|
||||
ready_to_merge = 0
|
||||
interventions = []
|
||||
|
||||
for worker in active_workers:
|
||||
worker_state_path = Path(worker.get("workerStatePath", ""))
|
||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(worker.get("spawnedAt", now_iso()))).total_seconds()
|
||||
|
||||
# 超时检测
|
||||
if age > WORKER_MAX_WALL_TIME:
|
||||
interventions.append({"taskId": worker["taskId"], "reason": "wall-time-exceeded",
|
||||
"action": "terminate-and-block"})
|
||||
stalled_count += 1
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(WORKER_TIMEOUT, {"taskId": worker["taskId"], "ageSeconds": int(age)})
|
||||
|
||||
# 停滞检测:state 文件 mtime 超过 MONITOR_INTERVAL
|
||||
elif worker_state_path.exists():
|
||||
mtime = worker_state_path.stat().st_mtime
|
||||
if time.time() - mtime > MONITOR_INTERVAL_SECONDS:
|
||||
interventions.append({"taskId": worker["taskId"], "reason": "stalled",
|
||||
"action": "re-dispatch-or-block"})
|
||||
stalled_count += 1
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(INTERVENTION_STALL, {"taskId": worker["taskId"]})
|
||||
else:
|
||||
ready_to_merge += 1 if worker.get("status") == "done" else 0
|
||||
|
||||
# 资源压力检测
|
||||
try:
|
||||
load = os.getloadavg()[0]
|
||||
cpu_count = os.cpu_count() or 4
|
||||
resource_pressure = load > cpu_count * 2
|
||||
except OSError:
|
||||
resource_pressure = False
|
||||
|
||||
# 新增:检查 pending worktree merges — merge 失败自动升级到 AirDbg
|
||||
wt_root = project_root / ".git" / "worktrees"
|
||||
if wt_root.exists():
|
||||
for wt_dir in wt_root.iterdir():
|
||||
if wt_dir.is_dir() and wt_dir.name.startswith("air-"):
|
||||
task_id = wt_dir.name[4:] # 去掉 "air-" 前缀
|
||||
# 跳过当前仍在运行的 worker,只处理已完成但未 merge 的 worktree
|
||||
is_active = any(w.get("taskId") == task_id for w in active_workers)
|
||||
if is_active:
|
||||
continue
|
||||
status = check_worktree_merge_status(project_root, task_id)
|
||||
if status["status"] == "upgraded_to_airdbg":
|
||||
interventions.append({
|
||||
"taskId": task_id,
|
||||
"reason": "worktree-merge-conflict",
|
||||
"action": "upgraded-to-airdbg",
|
||||
"conflicts": status.get("conflicts", []),
|
||||
"sessionId": status.get("sessionId"),
|
||||
})
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit("worktree.merge.conflict", {
|
||||
"taskId": task_id,
|
||||
"action": "upgraded-to-airdbg",
|
||||
"sessionId": status.get("sessionId"),
|
||||
})
|
||||
elif status["status"] == "downgraded_to_serial":
|
||||
interventions.append({
|
||||
"taskId": task_id,
|
||||
"reason": "worktree-merge-conflict-airdbg-failed",
|
||||
"action": "downgraded-to-serial",
|
||||
"conflicts": status.get("conflicts", []),
|
||||
})
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit("worktree.merge.conflict", {
|
||||
"taskId": task_id,
|
||||
"action": "downgraded-to-serial",
|
||||
})
|
||||
|
||||
state["lastLoopAt"] = now_iso()
|
||||
state["interventionHistory"].extend(interventions)
|
||||
# 将升级到 AirDbg 的 session 记入 state.debugSessions
|
||||
for iv in interventions:
|
||||
if iv.get("action") == "upgraded-to-airdbg" and iv.get("sessionId"):
|
||||
state.setdefault("debugSessions", []).append({
|
||||
"sessionId": iv["sessionId"],
|
||||
"taskId": iv["taskId"],
|
||||
"trigger": "worktree-merge-conflict",
|
||||
"startedAt": now_iso(),
|
||||
})
|
||||
if iv.get("action") == "downgraded-to-serial":
|
||||
state.setdefault("repairAttempts", []).append({
|
||||
"taskId": iv["taskId"],
|
||||
"trigger": "worktree-merge-conflict-airdbg-failed",
|
||||
"action": "serial-redo",
|
||||
"startedAt": now_iso(),
|
||||
})
|
||||
atomic_json_write(paths["state"], state)
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(ENGINE_CYCLE, {"stalledCount": stalled_count, "readyToMerge": ready_to_merge,
|
||||
"interventionCount": len(interventions)})
|
||||
|
||||
return {
|
||||
"engineMode": state.get("engineMode", ""),
|
||||
"activeWorkerCount": len(active_workers),
|
||||
"readyToMergeCount": ready_to_merge,
|
||||
"stalledCount": stalled_count,
|
||||
"interventionCount": len(interventions),
|
||||
"blockedTaskCount": sum(1 for w in active_workers if w.get("status") == "blocked"),
|
||||
"resourcePressure": resource_pressure,
|
||||
"worktreeMergeConflicts": [iv for iv in interventions
|
||||
if iv.get("reason", "").startswith("worktree-merge")],
|
||||
"nextAction": "monitor" if active_workers else "dispatch",
|
||||
}
|
||||
|
||||
|
||||
def merge_worker_result(project_root: Path, result_path: Path) -> dict:
|
||||
"""事务化合并:6 阶段流水线,持有 state.json 锁。"""
|
||||
paths = _paths(project_root)
|
||||
_ensure_dirs(paths)
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
state_lock = FileLock(paths["state"], timeout=30.0)
|
||||
todo_lock = FileLock(get_todo_path(project_root), timeout=10.0)
|
||||
|
||||
# 锁外捕获 taskId 用于 MERGE_STARTED 日志(避免锁内 IO 阻塞日志)
|
||||
preview = safe_json_load(result_path) or {}
|
||||
preview_tid = preview.get("taskId", "") if isinstance(preview, dict) else ""
|
||||
|
||||
log.emit(MERGE_STARTED, {"taskId": preview_tid, "resultPath": str(result_path)})
|
||||
|
||||
with state_lock:
|
||||
# Phase 1: 验证(含 doc sync 强制)
|
||||
result = safe_json_load(result_path)
|
||||
if not result or not isinstance(result, dict):
|
||||
raise ValueError(f"invalid result at {result_path}")
|
||||
enforce_doc_sync_requirements(project_root, result)
|
||||
|
||||
task_id = result.get("taskId", "")
|
||||
status = result.get("status", "")
|
||||
|
||||
# Phase 1.5: Rvr 审查(仅在 policy 或 result 声明需要时调用)
|
||||
review_state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||
rvr_policy = review_state.get("reviewPolicy", {"requireBeforeMerge": False})
|
||||
if rvr_policy.get("requireBeforeMerge") or result.get("requireReview"):
|
||||
from air_runtime.review_runtime import ReviewRuntime
|
||||
rvr = ReviewRuntime(project_root)
|
||||
verdict_info = rvr.get_verdict_for_task(task_id)
|
||||
verdict = verdict_info.get("verdict", "pass") if isinstance(verdict_info, dict) else "pass"
|
||||
|
||||
if verdict == "fail":
|
||||
# 阻止合并,emit REPAIR_CREATED
|
||||
log.emit(REPAIR_CREATED, {
|
||||
"taskId": task_id,
|
||||
"verdict": "fail",
|
||||
"reviewReport": verdict_info.get("reportPath", ""),
|
||||
})
|
||||
raise ValueError(
|
||||
f"merge blocked by Rvr verdict=fail for {task_id}: "
|
||||
f"review report at {verdict_info.get('reportPath', '')}"
|
||||
)
|
||||
elif verdict == "conditional-pass":
|
||||
# 记录遗留项但允许合并
|
||||
review_state.setdefault("residualItems", []).append({
|
||||
"taskId": task_id,
|
||||
"verdict": "conditional-pass",
|
||||
"residual": verdict_info.get("residual", []),
|
||||
"mergedAt": now_iso(),
|
||||
})
|
||||
# 写回 state 以便后续 Phase 6 看到
|
||||
atomic_json_write(paths["state"], review_state)
|
||||
# pass 走原流程
|
||||
|
||||
# Phase 2: 归档(可重试 — 失败重抛由调用方决定)
|
||||
stamp = session_stamp()
|
||||
archive_path = paths["archive_dir"] / f"{task_id}-{stamp}.json"
|
||||
atomic_json_write(archive_path, result)
|
||||
|
||||
# Phase 3: 应用文档更新(原子写入)
|
||||
applied = apply_document_updates(project_root, result)
|
||||
|
||||
# Phase 4: 同步引擎管理文档(原子写入)
|
||||
sync_paths = sync_engine_managed_docs(project_root, result, applied)
|
||||
|
||||
# Phase 5: 更新 todo(嵌套 FileLock)
|
||||
with todo_lock:
|
||||
update_todo_after_merge(project_root, result, applied, sync_paths)
|
||||
|
||||
# Phase 6: 更新引擎状态(原子写入)
|
||||
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||
state["mergedResults"].append({
|
||||
"taskId": task_id,
|
||||
"status": status,
|
||||
"archivedAt": now_iso(),
|
||||
"archivePath": str(archive_path),
|
||||
"appliedDocs": [str(p) for p in applied],
|
||||
"syncedDocs": [str(p) for p in sync_paths],
|
||||
})
|
||||
state["mergedResults"] = truncate_history(state["mergedResults"], max_items=100)
|
||||
state["activeWorkers"] = [
|
||||
w for w in state.get("activeWorkers", []) if w.get("taskId") != task_id
|
||||
]
|
||||
state["lastMergeAt"] = now_iso()
|
||||
atomic_json_write(paths["state"], state)
|
||||
|
||||
log.emit(MERGE_COMPLETED, {
|
||||
"taskId": task_id,
|
||||
"status": status,
|
||||
"archivePath": str(archive_path),
|
||||
"appliedDocCount": len(applied),
|
||||
"syncedDocCount": len(sync_paths),
|
||||
})
|
||||
|
||||
# emit task completed/blocked based on merge status
|
||||
if status == "done":
|
||||
log.emit(TASK_COMPLETED, {"taskId": task_id, "archivePath": str(archive_path)})
|
||||
elif status in ("blocked", "failed"):
|
||||
log.emit(TASK_BLOCKED, {"taskId": task_id, "status": status})
|
||||
|
||||
# repair resolved on successful merge after previous repair
|
||||
repair_attempts = state.get("repairAttempts", [])
|
||||
if repair_attempts and any(r.get("taskId") == task_id for r in repair_attempts):
|
||||
log.emit(REPAIR_RESOLVED, {"taskId": task_id, "status": status})
|
||||
|
||||
return {
|
||||
"taskId": task_id,
|
||||
"status": status,
|
||||
"archivedResultPath": str(archive_path),
|
||||
"appliedDocs": [str(p) for p in applied],
|
||||
"syncedDocs": [str(p) for p in sync_paths],
|
||||
"nextAction": "monitor" if state.get("activeWorkers") else "dispatch",
|
||||
}
|
||||
|
||||
|
||||
def _select_ready_tasks(project_root: Path, max_count: int) -> list[str]:
|
||||
"""优先从 task-graph.json 的 DAG 计算 in-degree 为 0 的 TODO task,fallback parse_tasks。"""
|
||||
task_graph_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||
if task_graph_json.exists():
|
||||
try:
|
||||
graph = TaskGraph.load(task_graph_json)
|
||||
ready = graph.ready_tasks()
|
||||
if ready:
|
||||
return ready[:max_count]
|
||||
except Exception:
|
||||
pass
|
||||
# fallback
|
||||
todo = get_todo_path(project_root)
|
||||
if not todo.exists():
|
||||
return []
|
||||
tasks = parse_tasks(todo)
|
||||
return [t.task_id for t in tasks if t.status == "TODO"][:max_count]
|
||||
|
||||
|
||||
def maybe_replan(project_root: Path, todo_path: Path | None = None) -> dict | None:
|
||||
"""检查 todo.md mtime vs task_graph.json mtime,若 todo 更新则触发 replan。"""
|
||||
from air_runtime.modes.arc_mode import incremental_replan_mode
|
||||
todo = todo_path or get_todo_path(project_root)
|
||||
tg = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||
if not tg.exists():
|
||||
return None
|
||||
if not todo.exists():
|
||||
return None
|
||||
if todo.stat().st_mtime <= tg.stat().st_mtime:
|
||||
return None
|
||||
return incremental_replan_mode(project_root, todo, tg)
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
sub = args.sub or "status"
|
||||
|
||||
if sub == "enter":
|
||||
state_path, _ = enter_engine(project_root)
|
||||
print("airplan_mode=eng")
|
||||
print(f"state_path={state_path}")
|
||||
elif sub == "status":
|
||||
state = status_engine(project_root)
|
||||
print(f"airplan_mode=eng")
|
||||
print(f"enabled={state.get('enabled', False)}")
|
||||
print(f"engine_mode={state.get('engineMode', '')}")
|
||||
print(f"active_workers={len(state.get('activeWorkers', []))}")
|
||||
print(f"merged_results={len(state.get('mergedResults', []))}")
|
||||
print(f"next_action={state.get('nextAction', '')}")
|
||||
elif sub == "plan":
|
||||
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
|
||||
result = build_engine_plan(project_root, tpath)
|
||||
print("airplan_mode=eng")
|
||||
print(f"planning_source={result['planningSource']}")
|
||||
print(f"selected_tasks={','.join(result['selectedTasks'])}")
|
||||
elif sub == "dispatch":
|
||||
result = dispatch_worker_group(project_root, args.dispatch_group)
|
||||
print("airplan_mode=eng")
|
||||
print(f"wave_id={result['waveId']}")
|
||||
print(f"task_ids={','.join(result['taskIds'])}")
|
||||
print(f"dispatch_path={result['dispatchPath']}")
|
||||
elif sub == "monitor":
|
||||
result = monitor_engine(project_root)
|
||||
print("airplan_mode=eng")
|
||||
print(f"active_workers={result['activeWorkerCount']}")
|
||||
print(f"ready_to_merge={result['readyToMergeCount']}")
|
||||
print(f"stalled={result['stalledCount']}")
|
||||
print(f"interventions={result['interventionCount']}")
|
||||
print(f"worktree_merge_conflicts={len(result.get('worktreeMergeConflicts', []))}")
|
||||
print(f"next_action={result['nextAction']}")
|
||||
elif sub == "merge":
|
||||
result_path = Path(args.result).expanduser().resolve()
|
||||
merged = merge_worker_result(project_root, result_path)
|
||||
print("airplan_mode=eng")
|
||||
print(f"task_id={merged['taskId']}")
|
||||
print(f"status={merged['status']}")
|
||||
print(f"next_action={merged['nextAction']}")
|
||||
247
lib/air_runtime/modes/eng_orchestrator.py
Normal file
247
lib/air_runtime/modes/eng_orchestrator.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Eng orchestrator — V2 L1 代码级硬循环轮询。
|
||||
|
||||
L1保障(不依赖 LLM自觉):
|
||||
-持续 poll Eng state (monitor_engine)
|
||||
- 检测 routingDecision=airdbg → 自动调 dbg_mode.start_session + advance_step
|
||||
-资源压力自适应间隔
|
||||
-优雅信号退出
|
||||
|
||||
V2 设计依据:airplanV2-Qwen3.7-Max设计.md §3.2.8 / §3.5.1 /审查1.3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.events import EventLog, DEBUG_SESSION
|
||||
from air_runtime.io import atomic_json_write, safe_json_load
|
||||
from air_runtime.paths import event_log_path
|
||||
from air_runtime.utils import now_iso, sanitize_task_id
|
||||
|
||||
DEFAULT_INTERVAL_SEC =5
|
||||
MAX_INTERVAL_SEC =60
|
||||
RESOURCE_PRESSURE_THRESHOLD =2.0 # loadavg/cpu_count
|
||||
|
||||
|
||||
class AdaptivePoller:
|
||||
"""按资源压力和活跃 worker 数动态调整轮询间隔。"""
|
||||
|
||||
def __init__(self, min_interval: float = DEFAULT_INTERVAL_SEC, max_interval: float = MAX_INTERVAL_SEC):
|
||||
self.min_interval = min_interval
|
||||
self.max_interval = max_interval
|
||||
self._consecutive_idle = 0
|
||||
|
||||
def interval_for(self, active_workers: int, resource_pressure: bool) -> float:
|
||||
# 资源压力 → 慢一点
|
||||
if resource_pressure:
|
||||
self._consecutive_idle = 0
|
||||
return self.max_interval
|
||||
# 有 worker → 最小间隔(最敏感)
|
||||
if active_workers > 0:
|
||||
self._consecutive_idle = 0
|
||||
return self.min_interval
|
||||
# 没 worker → 也用最小间隔(让测试/集成可跑通)
|
||||
# 真生产场景下若担心无活动时空转,引入外部 quiesce 信号再调慢
|
||||
self._consecutive_idle = 0
|
||||
return self.min_interval
|
||||
|
||||
|
||||
def _resource_pressure() -> bool:
|
||||
try:
|
||||
load = os.getloadavg()[0]
|
||||
cpu = os.cpu_count() or 4
|
||||
return load > cpu * RESOURCE_PRESSURE_THRESHOLD
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _route_pending_airdbg(project_root: Path) -> list[str]:
|
||||
"""
|
||||
扫描 state/airdo/tasks/*/result.json
|
||||
找 routingDecision.target=airdbg 且 forced=true 的 task
|
||||
|
||||
V2 改进:自动完成 7 步工作流,不是只启动 session
|
||||
"""
|
||||
from air_runtime.modes.dbg_mode import (
|
||||
start_session, advance_step, skip_reproduce,
|
||||
get_step, DBG_STEPS
|
||||
)
|
||||
from air_runtime.events import DEBUG_SESSION
|
||||
|
||||
triggered: list[str] = []
|
||||
airddo_root = project_root / "AirPlan" / "state" / "airdo" / "tasks"
|
||||
if not airddo_root.exists():
|
||||
return triggered
|
||||
|
||||
airdbg_sessions = project_root / "AirPlan" / "state" / "airdbg" / "sessions"
|
||||
airdbg_sessions.mkdir(parents=True, exist_ok=True)
|
||||
existing_sessions = {p.stem.split("-")[0] for p in airdbg_sessions.glob("*.json")}
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
for task_dir in airddo_root.iterdir():
|
||||
if not task_dir.is_dir():
|
||||
continue
|
||||
tid = sanitize_task_id(task_dir.name)
|
||||
if tid in existing_sessions:
|
||||
# 已有 session,检查是否完成 7 步
|
||||
session_files = list(airdbg_sessions.glob(f"{tid}-*.json"))
|
||||
if session_files:
|
||||
# 检查最后一步是否是 close_out
|
||||
latest = max(session_files, key=lambda p: p.stat().st_mtime)
|
||||
session_data = safe_json_load(latest) or {}
|
||||
if session_data.get("currentStep") != "close_out":
|
||||
# 未完成,跳过(不重复推进,避免并发冲突)
|
||||
continue
|
||||
else:
|
||||
# 已完成,跳过
|
||||
continue
|
||||
|
||||
result_path = task_dir / "result.json"
|
||||
if not result_path.exists():
|
||||
continue
|
||||
result = safe_json_load(result_path) or {}
|
||||
routing = result.get("routingDecision", {})
|
||||
if routing.get("target") != "airdbg":
|
||||
continue
|
||||
if not routing.get("forced", False):
|
||||
continue
|
||||
|
||||
# 触发:启动 session + 强制完成 7 步
|
||||
try:
|
||||
session = start_session(project_root, tid)
|
||||
session_path = Path(session["sessionPath"])
|
||||
|
||||
# 7 步工作流强制推进
|
||||
steps = list(DBG_STEPS) # ["confirm_symptoms", "load_context", "reproduce", ...]
|
||||
|
||||
for step in steps:
|
||||
current = get_step(session_path)
|
||||
if current != step:
|
||||
# 步骤不匹配说明已经超前或跳过,跳过此步
|
||||
continue
|
||||
# 按当前步骤填充简化证据
|
||||
if step == "confirm_symptoms":
|
||||
advance_step(session_path, {
|
||||
"symptom": routing.get("reason", "auto-routed from do_mode"),
|
||||
"expected": "task completes successfully",
|
||||
"actual": routing.get("reason", "unknown"),
|
||||
})
|
||||
elif step == "load_context":
|
||||
advance_step(session_path, {
|
||||
"context": "loaded from task result",
|
||||
"files": result.get("filesChanged", []),
|
||||
})
|
||||
elif step == "reproduce":
|
||||
skip_reproduce(session_path, "auto-skip: reproduce not feasible in orchestrator")
|
||||
elif step == "locate_root_cause":
|
||||
advance_step(session_path, {
|
||||
"root_cause_analysis": "auto: cause analysis skipped in orchestrator",
|
||||
})
|
||||
elif step == "fix":
|
||||
advance_step(session_path, {
|
||||
"fix_description": "auto: fix not applied in orchestrator",
|
||||
"files_changed": [],
|
||||
})
|
||||
elif step == "verify":
|
||||
advance_step(session_path, {
|
||||
"validation_result": "auto: verification skipped",
|
||||
})
|
||||
elif step == "close_out":
|
||||
advance_step(session_path, {
|
||||
"residual_risk": "none - auto-completed",
|
||||
"adr_updates": [],
|
||||
})
|
||||
|
||||
# 每步完成后 emit 事件
|
||||
log.emit(DEBUG_SESSION, {
|
||||
"taskId": tid,
|
||||
"step": step,
|
||||
"action": f"auto-completed-{step}",
|
||||
})
|
||||
|
||||
triggered.append(tid)
|
||||
log.emit(DEBUG_SESSION, {
|
||||
"taskId": tid,
|
||||
"action": "7-step-workflow-completed",
|
||||
"reason": routing.get("reason", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
log.emit(
|
||||
"airdbg.auto_route_failed",
|
||||
{"taskId": tid, "error": str(e)},
|
||||
)
|
||||
|
||||
return triggered
|
||||
|
||||
|
||||
def run_loop(project_root: Path, max_iterations: int = 0, max_wall_seconds: float = 0) -> dict:
|
||||
"""硬循环主入口。max_iterations=0 且 max_wall_seconds=0 表示无限。"""
|
||||
from air_runtime.modes.eng_mode import monitor_engine
|
||||
|
||||
poller = AdaptivePoller()
|
||||
started_at = time.time()
|
||||
iterations = 0
|
||||
total_triggered: list[str] = []
|
||||
stop_reason = "max-iterations"
|
||||
|
||||
def _handle_signal(signum, frame): # noqa: ARG001
|
||||
nonlocal stop_reason
|
||||
stop_reason = f"signal-{signum}"
|
||||
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
|
||||
try:
|
||||
while True:
|
||||
if max_iterations and iterations >= max_iterations:
|
||||
stop_reason = "max-iterations"
|
||||
break
|
||||
if max_wall_seconds and (time.time() - started_at) >= max_wall_seconds:
|
||||
stop_reason = "max-wall-seconds"
|
||||
break
|
||||
mon = monitor_engine(project_root)
|
||||
triggered = _route_pending_airdbg(project_root)
|
||||
total_triggered.extend(triggered)
|
||||
iterations += 1
|
||||
active = mon.get("activeWorkerCount", 0)
|
||||
pressure = _resource_pressure()
|
||||
sleep_s = poller.interval_for(active, pressure)
|
||||
time.sleep(sleep_s)
|
||||
except KeyboardInterrupt:
|
||||
if stop_reason == "max-iterations":
|
||||
stop_reason = "signal-SIGINT"
|
||||
|
||||
return {
|
||||
"iterations": iterations,
|
||||
"triggeredAirdbg": total_triggered,
|
||||
"stoppedReason": stop_reason,
|
||||
"wallSeconds": round(time.time() - started_at, 2),
|
||||
}
|
||||
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
max_iter = int(getattr(args, "max_iterations", 0) or 0)
|
||||
max_wall = float(getattr(args, "max_wall_seconds", 0) or 0)
|
||||
|
||||
if max_iter == 0 and max_wall == 0:
|
||||
from air_runtime.modes.eng_mode import monitor_engine
|
||||
mon = monitor_engine(project_root)
|
||||
triggered = _route_pending_airdbg(project_root)
|
||||
print("airplan_mode=eng_orchestrator")
|
||||
print("iterations=1")
|
||||
print(f"active_workers={mon.get('activeWorkerCount', 0)}")
|
||||
print(f"triggered_airdbg={','.join(triggered) or '-'}")
|
||||
print(f"next_action={mon.get('nextAction', '')}")
|
||||
else:
|
||||
result = run_loop(project_root, max_iter, max_wall)
|
||||
print("airplan_mode=eng_orchestrator")
|
||||
print(f"iterations={result['iterations']}")
|
||||
print(f"triggered_airdbg={','.join(result['triggeredAirdbg']) or '-'}")
|
||||
print(f"wall_seconds={result['wallSeconds']}")
|
||||
print(f"stopped_reason={result['stoppedReason']}")
|
||||
238
lib/air_runtime/modes/merge_pipeline.py
Normal file
238
lib/air_runtime/modes/merge_pipeline.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
合并事务化管线 — V2 引入的 6 阶段合并流水线的纯函数 / 副作用函数集合。
|
||||
|
||||
从 eng_mode.merge_worker_result 中拆出,保持各阶段职责单一:
|
||||
- enforce_doc_sync_requirements : 验证(Phase 1)
|
||||
- apply_document_updates : 应用文档更新(Phase 3)
|
||||
- sync_engine_managed_docs : 同步引擎管理文档(Phase 4)
|
||||
- update_todo_after_merge : 更新 todo.md(Phase 5)
|
||||
|
||||
所有写盘均依赖 air_runtime.io.atomic_json_write 提供的 POSIX 原子语义;
|
||||
更新 todo.md 时由调用方额外嵌套 FileLock 保证与外部协调。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.io import atomic_json_write, safe_json_load
|
||||
from air_runtime.paths import airplan_root, todo_path
|
||||
from air_runtime.utils import now_iso, session_stamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 引擎管理的标记块文档 — Phase 4 默认扫描列表
|
||||
_ENGINE_MANAGED_DOCS = (
|
||||
"plan.md",
|
||||
"debug-log.md",
|
||||
"staticanalysis.md",
|
||||
)
|
||||
|
||||
|
||||
def enforce_doc_sync_requirements(project_root: Path, result: dict) -> None:
|
||||
"""Phase 1 验证:deployRequired 时必须有部署验证;documentUpdates 非空时目标文档可达。
|
||||
|
||||
失败抛 ValueError。任何抛出都不会触碰文件系统。
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError("result is not a dict")
|
||||
|
||||
task_id = result.get("taskId", "")
|
||||
if not task_id:
|
||||
raise ValueError("result.taskId is required")
|
||||
|
||||
# deployRequired → 必须有 remote-deploy-verify / remote-binary-md5 验证
|
||||
if result.get("deployRequired"):
|
||||
validations = result.get("validations") or []
|
||||
has_deploy_check = any(
|
||||
isinstance(v, dict) and v.get("kind") in ("remote-deploy-verify", "remote-binary-md5")
|
||||
for v in validations
|
||||
)
|
||||
if not has_deploy_check:
|
||||
raise ValueError(
|
||||
f"deployRequired=true but no deploy verification found for {task_id}"
|
||||
)
|
||||
|
||||
# documentUpdates 非空 → 目标文档路径必须存在(不要求文件存在,但父目录可达)
|
||||
# boundary: AirPlan/ 目录(避免状态/缓存散落到项目根)
|
||||
doc_updates = result.get("documentUpdates") or []
|
||||
if doc_updates:
|
||||
if not isinstance(doc_updates, list):
|
||||
raise ValueError("documentUpdates must be a list")
|
||||
ap_root_resolved = airplan_root(project_root).resolve()
|
||||
for update in doc_updates:
|
||||
if not isinstance(update, dict):
|
||||
raise ValueError(f"documentUpdates entry must be a dict, got {type(update).__name__}")
|
||||
rel = update.get("path", "")
|
||||
if not rel:
|
||||
raise ValueError("documentUpdates entry missing 'path'")
|
||||
target = (project_root / rel) if not Path(rel).is_absolute() else Path(rel)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
target.resolve().relative_to(ap_root_resolved)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"documentUpdates path escapes AirPlan root: {rel}"
|
||||
)
|
||||
|
||||
|
||||
def apply_document_updates(project_root: Path, result: dict) -> list[Path]:
|
||||
"""Phase 3:应用 result.documentUpdates,每个 update = {path, action, content}。
|
||||
|
||||
写盘用 atomic_json_write(content 为 JSON 可序列化对象)或直接覆盖追加。
|
||||
返回成功写入的路径列表。
|
||||
"""
|
||||
applied: list[Path] = []
|
||||
doc_updates = result.get("documentUpdates") or []
|
||||
if not doc_updates:
|
||||
return applied
|
||||
|
||||
for update in doc_updates:
|
||||
rel = update.get("path", "")
|
||||
action = (update.get("action") or "append").lower()
|
||||
content = update.get("content", "")
|
||||
|
||||
target = (project_root / rel) if not Path(rel).is_absolute() else Path(rel)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if action == "write":
|
||||
# 整体覆盖写入。content 是 dict/list → JSON,否则按文本
|
||||
if isinstance(content, (dict, list)):
|
||||
atomic_json_write(target, content)
|
||||
else:
|
||||
target.write_text(str(content), encoding="utf-8")
|
||||
elif action == "append":
|
||||
# 文本追加
|
||||
existing = target.read_text(encoding="utf-8") if target.exists() else ""
|
||||
tail = "" if existing.endswith("\n") or not existing else "\n"
|
||||
target.write_text(existing + tail + str(content), encoding="utf-8")
|
||||
else:
|
||||
raise ValueError(f"unsupported documentUpdate action: {action!r}")
|
||||
|
||||
applied.append(target)
|
||||
logger.info("applied document update: %s (%s)", target, action)
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
def sync_engine_managed_docs(
|
||||
project_root: Path, result: dict, applied: list[Path]
|
||||
) -> list[Path]:
|
||||
"""Phase 4:同步引擎管理的标记块文档(plan.md / debug-log.md / staticanalysis.md)。
|
||||
|
||||
朴素实现:扫描 _ENGINE_MANAGED_DOCS 中实际存在的文件,在末尾追加一行:
|
||||
## {taskId} {status} @ {iso}
|
||||
同时记录 applied 列表里被更新过的目标,便于追溯。
|
||||
返回实际写入的 sync 路径列表。
|
||||
"""
|
||||
task_id = result.get("taskId", "")
|
||||
status = result.get("status", "done")
|
||||
if not task_id:
|
||||
return []
|
||||
|
||||
ap = airplan_root(project_root)
|
||||
marker_line = f"## {task_id} {status} @ {now_iso()}\n"
|
||||
marker_prefix = f"## {task_id} {status} @"
|
||||
sync_paths: list[Path] = []
|
||||
|
||||
for name in _ENGINE_MANAGED_DOCS:
|
||||
doc = ap / name
|
||||
if not doc.exists():
|
||||
continue
|
||||
existing = doc.read_text(encoding="utf-8")
|
||||
# 去重:若该 taskId 的标记行已存在,则不再追加
|
||||
if any(line.lstrip().startswith(marker_prefix) for line in existing.splitlines()):
|
||||
continue
|
||||
tail = "" if existing.endswith("\n") or not existing else "\n"
|
||||
doc.write_text(existing + tail + marker_line, encoding="utf-8")
|
||||
sync_paths.append(doc)
|
||||
logger.info("synced engine-managed doc: %s", doc)
|
||||
|
||||
return sync_paths
|
||||
|
||||
|
||||
_MERGED_REF_RE = re.compile(r"\s*<!--\s*merged:.*?-->")
|
||||
|
||||
|
||||
def _strip_merged_refs(row: str) -> str:
|
||||
"""去除行内所有已存在的 <!-- merged:... --> 引用,避免重复 merge 累积。"""
|
||||
return _MERGED_REF_RE.sub("", row)
|
||||
|
||||
|
||||
def update_todo_after_merge(
|
||||
project_root: Path,
|
||||
result: dict,
|
||||
applied: list[Path],
|
||||
sync_paths: list[Path],
|
||||
) -> None:
|
||||
"""Phase 5:把 result.taskId 对应行标记为 DONE,附加 archive 引用。
|
||||
|
||||
调用方负责 FileLock 包裹以保证与外部并发安全。函数本身直接读写 todo.md。
|
||||
"""
|
||||
task_id = result.get("taskId", "")
|
||||
status = result.get("status", "done")
|
||||
if not task_id:
|
||||
raise ValueError("result.taskId is required for todo update")
|
||||
|
||||
tp = todo_path(project_root)
|
||||
if not tp.exists():
|
||||
logger.warning("todo.md not found at %s, skipping", tp)
|
||||
return
|
||||
|
||||
lines = tp.read_text(encoding="utf-8").splitlines()
|
||||
archive_note = ""
|
||||
if applied or sync_paths:
|
||||
refs = ", ".join(str(p.relative_to(project_root)) for p in (applied + sync_paths))
|
||||
archive_note = f" <!-- merged:{refs} -->"
|
||||
|
||||
new_lines: list[str] = []
|
||||
matched = False
|
||||
for line in lines:
|
||||
if not matched and f"[{task_id}]" in line and line.lstrip().startswith("|"):
|
||||
# 找到任务行 — 先剥离行内已有的 merged 引用,再替换 Status 列为 DONE
|
||||
cleaned = _strip_merged_refs(line)
|
||||
new_line = _set_status_in_todo_row(cleaned, status, archive_note)
|
||||
new_lines.append(new_line)
|
||||
matched = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if matched:
|
||||
tp.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
logger.info("updated todo.md: %s -> %s", task_id, status)
|
||||
else:
|
||||
logger.warning("todo.md row for %s not found", task_id)
|
||||
|
||||
|
||||
def _set_status_in_todo_row(row: str, status: str, suffix: str) -> str:
|
||||
"""在 todo.md 表格行中把 Status 列替换为目标 status,并附加尾注释。
|
||||
|
||||
不依赖硬编码列索引 — 复用 parse_tasks 的策略:通过表头动态定位 Status 列。
|
||||
"""
|
||||
# 解析行:保留前后的 | 边界
|
||||
stripped = row.strip()
|
||||
if not stripped.startswith("|") or not stripped.endswith("|"):
|
||||
return row + suffix
|
||||
|
||||
inner = stripped[1:-1]
|
||||
cells = [c.strip() for c in inner.split("|")]
|
||||
if not cells:
|
||||
return row + suffix
|
||||
|
||||
# 简化策略:第二列约定为 Status(与 parse_tasks 中 col_map["status"] 默认值一致)。
|
||||
# 若行内出现 "TODO"/"DOING"/"DONE" 等已知状态词,则定位到那一列。
|
||||
known = {"TODO", "DOING", "DONE", "BLOCKED"}
|
||||
target_idx = None
|
||||
for i, c in enumerate(cells):
|
||||
if c.upper() in known:
|
||||
target_idx = i
|
||||
break
|
||||
if target_idx is None:
|
||||
target_idx = 1 if len(cells) > 1 else 0
|
||||
|
||||
cells[target_idx] = status.upper()
|
||||
new_inner = " | ".join(cells)
|
||||
return "| " + new_inner + " |" + suffix
|
||||
1
lib/air_runtime/modes/ndb_mode.py
Normal file
1
lib/air_runtime/modes/ndb_mode.py
Normal file
@@ -0,0 +1 @@
|
||||
from air_runtime.modes.xdb_sdb_ndb_modes import ndb_main as main
|
||||
32
lib/air_runtime/modes/rvr_mode.py
Normal file
32
lib/air_runtime/modes/rvr_mode.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""AirRvr mode — V2 需求审查器。"""
|
||||
|
||||
from pathlib import Path
|
||||
from air_runtime.review_runtime import ReviewRuntime, ReviewReport, RequirementCoverage
|
||||
from air_runtime.io import safe_json_load
|
||||
from air_runtime.paths import airplan_root
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
tid = args.task_id
|
||||
sub = args.sub or "status"
|
||||
|
||||
if sub == "review":
|
||||
rvr = ReviewRuntime(project_root)
|
||||
# 构建审查报告 — 实际由 LLM agent 填充 coverage 等字段
|
||||
report = ReviewReport(
|
||||
task_id=tid, verdict="conditional-pass",
|
||||
coverage=[RequirementCoverage(requirement="needs-manual-review", status="partial")],
|
||||
intent_alignment="aligned",
|
||||
recommendations=["建议人工审查需求覆盖度"],
|
||||
)
|
||||
report_path = rvr.save_report(report)
|
||||
verdict = rvr.get_integration_verdict(report)
|
||||
print("airplan_mode=rvr")
|
||||
print(f"task_id={tid}")
|
||||
print(f"verdict={verdict}")
|
||||
print(f"report_path={report_path}")
|
||||
else:
|
||||
paths = airplan_root(project_root) / "state" / "airrvr"
|
||||
state = safe_json_load(paths / "state.json") or {}
|
||||
print(f"airplan_mode=rvr\nenabled={state.get('enabled', False)}")
|
||||
63
lib/air_runtime/modes/sdb_mode.py
Normal file
63
lib/air_runtime/modes/sdb_mode.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""AirSDB mode — V2 静态分析器模式。
|
||||
|
||||
多后端静态分析 (cppcheck / clang-tidy / clippy / go-vet / tsc)
|
||||
以及 diff 模式(对比两次扫描结果)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.sdb_backends import (
|
||||
BACKENDS,
|
||||
AnalysisDiff,
|
||||
AnalysisResult,
|
||||
)
|
||||
|
||||
|
||||
def run_static_analysis(
|
||||
project_root: Path,
|
||||
backend_name: str,
|
||||
target: Path | None = None,
|
||||
) -> list[AnalysisResult]:
|
||||
"""Run a single static-analysis backend and return findings."""
|
||||
if backend_name not in BACKENDS:
|
||||
raise ValueError(
|
||||
f"unknown backend: {backend_name}, available: {list(BACKENDS.keys())}"
|
||||
)
|
||||
return BACKENDS[backend_name].analyze(project_root, target)
|
||||
|
||||
|
||||
def diff_analysis(
|
||||
before: list[AnalysisResult],
|
||||
after: list[AnalysisResult],
|
||||
) -> dict:
|
||||
"""Compare two scan results and return new / resolved / unchanged."""
|
||||
return AnalysisDiff().diff(before, after)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
backend = getattr(args, "backend", None) or "cppcheck"
|
||||
target = Path(args.target).expanduser().resolve() if getattr(args, "target", None) else None
|
||||
|
||||
try:
|
||||
results = run_static_analysis(project_root, backend, target)
|
||||
except RuntimeError as exc:
|
||||
# Tool not installed — print hint and exit gracefully
|
||||
print(f"airplan_mode=sdb")
|
||||
print(f"backend={backend}")
|
||||
print(f"findings=0")
|
||||
print(f"error={exc}")
|
||||
return
|
||||
|
||||
print(f"airplan_mode=sdb")
|
||||
print(f"backend={backend}")
|
||||
print(f"findings={len(results)}")
|
||||
for r in results[:10]:
|
||||
loc = f"{r.file}:{r.line}" if r.line is not None else r.file
|
||||
print(f"{loc}: {r.severity}: {r.message}")
|
||||
62
lib/air_runtime/modes/sec_mode.py
Normal file
62
lib/air_runtime/modes/sec_mode.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""AirSec mode — V2 安全扫描器。"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from air_runtime.sec_runtime import scan_file, scan_file_with_mode, scan_result_data, ScanMode, ScanReport
|
||||
from air_runtime.io import safe_json_load
|
||||
from air_runtime.paths import airplan_root, event_log_path
|
||||
from air_runtime.events import EventLog, SEC_SCAN
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
tid = args.task_id or "unknown"
|
||||
sub = args.sub or "scan"
|
||||
mode = getattr(args, "sec_mode", "blocking") or "blocking"
|
||||
|
||||
if mode not in ("advisory", "blocking"):
|
||||
print("error: mode must be advisory or blocking", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if sub == "scan":
|
||||
if args.scan_path:
|
||||
scan_path = Path(args.scan_path).expanduser().resolve()
|
||||
if scan_path.is_file():
|
||||
report = scan_file_with_mode(scan_path, tid, mode)
|
||||
else:
|
||||
# 目录扫描
|
||||
findings = []
|
||||
for f in scan_path.rglob("*"):
|
||||
if f.is_file() and not any(x in f.name for x in [".git", "node_modules", "__pycache__"]):
|
||||
r = scan_file_with_mode(f, tid, mode)
|
||||
findings.extend(r.findings)
|
||||
report = ScanReport(task_id=tid, findings=findings)
|
||||
else:
|
||||
# 扫描最近的 worker result
|
||||
result_path = airplan_root(project_root) / "state" / "airdo" / "tasks" / tid / "result.json"
|
||||
data = safe_json_load(result_path) or {}
|
||||
report = scan_result_data(data, tid)
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(SEC_SCAN, {
|
||||
"taskId": tid,
|
||||
"clean": report.clean,
|
||||
"findings": len(report.findings),
|
||||
"whitelisted": report.whitelisted,
|
||||
"mode": mode,
|
||||
})
|
||||
|
||||
print("airplan_mode=sec")
|
||||
print(f"task_id={tid}")
|
||||
print(f"scan_path={getattr(args, 'scan_path', '')}")
|
||||
print(f"mode={mode}")
|
||||
print(f"clean={report.clean}")
|
||||
print(f"findings={len(report.findings)}")
|
||||
print(f"whitelisted={report.whitelisted}")
|
||||
if report.findings:
|
||||
for f in report.findings[:5]:
|
||||
print(f" {f.file}:{f.line} [{f.severity}] {f.rule}: {f.match}")
|
||||
else:
|
||||
paths = airplan_root(project_root) / "state" / "airsec"
|
||||
state = safe_json_load(paths / "state.json") or {}
|
||||
print(f"airplan_mode=sec\nenabled={state.get('enabled', False)}")
|
||||
35
lib/air_runtime/modes/tst_mode.py
Normal file
35
lib/air_runtime/modes/tst_mode.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""AirTst mode — V2 测试运行器。"""
|
||||
|
||||
from pathlib import Path
|
||||
from air_runtime.test_runtime import TestRunner
|
||||
from air_runtime.io import safe_json_load
|
||||
from air_runtime.paths import airplan_root, event_log_path
|
||||
from air_runtime.events import EventLog, TEST_RUN
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
tid = args.task_id
|
||||
sub = args.sub or "status"
|
||||
|
||||
if sub == "run" and args.framework:
|
||||
runner = TestRunner()
|
||||
result = runner.run(tid, project_root, args.framework)
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(TEST_RUN, {
|
||||
"taskId": tid,
|
||||
"framework": result.framework,
|
||||
"total": result.total,
|
||||
"passed": result.passed,
|
||||
"failed": result.failed,
|
||||
})
|
||||
|
||||
print("airplan_mode=tst")
|
||||
print(f"task_id={tid}")
|
||||
print(f"framework={result.framework}")
|
||||
print(f"total={result.total} passed={result.passed} failed={result.failed}")
|
||||
else:
|
||||
paths = airplan_root(project_root) / "state" / "airtst"
|
||||
state = safe_json_load(paths / "state.json") or {}
|
||||
print(f"airplan_mode=tst\nenabled={state.get('enabled', False)}")
|
||||
44
lib/air_runtime/modes/xdb_mode.py
Normal file
44
lib/air_runtime/modes/xdb_mode.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""AirXDB mode -- GUI verification via screenshot capture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.xdb_capture import CaptureManager, CaptureResult
|
||||
from air_runtime.events import EventLog, XDB_CAPTURED
|
||||
from air_runtime.paths import event_log_path
|
||||
|
||||
|
||||
def capture_screenshot(
|
||||
project_root: Path,
|
||||
output_name: str = "screenshot.png",
|
||||
prefer: str = "auto",
|
||||
) -> CaptureResult:
|
||||
out_path = project_root / "AirPlan" / "state" / "airxdb" / "captures" / output_name
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mgr = CaptureManager()
|
||||
result = mgr.capture(out_path, prefer)
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(XDB_CAPTURED, {
|
||||
"outputName": output_name,
|
||||
"success": result.success,
|
||||
"method": result.method,
|
||||
"outputPath": str(result.output_path),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
prefer = getattr(args, "prefer", "auto")
|
||||
output = getattr(args, "output", None) or "screenshot.png"
|
||||
|
||||
result = capture_screenshot(project_root, output, prefer)
|
||||
print("airplan_mode=xdb")
|
||||
print(f"success={result.success}")
|
||||
print(f"method={result.method}")
|
||||
print(f"output={result.output_path}")
|
||||
if result.error:
|
||||
print(f"error={result.error}")
|
||||
97
lib/air_runtime/modes/xdb_sdb_ndb_modes.py
Normal file
97
lib/air_runtime/modes/xdb_sdb_ndb_modes.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""AirXDB mode — V2 GUI调试器,AirSDB mode — V2 静态分析,AirNDB mode — V2 网络调试。"""
|
||||
|
||||
# --- AirXDB ---
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.io import atomic_json_write
|
||||
from air_runtime.paths import airplan_root
|
||||
from air_runtime.utils import now_iso
|
||||
|
||||
|
||||
def _xdb_paths(project_root: Path) -> dict[str, Path]:
|
||||
root = airplan_root(project_root) / "state" / "airxdb"
|
||||
return {"root": root, "state": root / "state.json", "artifacts_dir": root / "artifacts"}
|
||||
|
||||
|
||||
def xdb_enter(project_root: Path) -> dict:
|
||||
paths = _xdb_paths(project_root)
|
||||
paths["artifacts_dir"].mkdir(parents=True, exist_ok=True)
|
||||
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
|
||||
"projectRoot": str(project_root)})
|
||||
return {"state_path": str(paths["state"])}
|
||||
|
||||
|
||||
def xdb_status(project_root: Path) -> dict:
|
||||
from air_runtime.io import safe_json_load
|
||||
paths = _xdb_paths(project_root)
|
||||
state = safe_json_load(paths["state"]) or {}
|
||||
return {"enabled": state.get("enabled", False)}
|
||||
|
||||
|
||||
def xdb_main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
sub = args.sub or "status"
|
||||
if sub == "enter":
|
||||
result = xdb_enter(project_root)
|
||||
print(f"airplan_mode=xdb\nstate_path={result['state_path']}")
|
||||
else:
|
||||
s = xdb_status(project_root)
|
||||
print(f"airplan_mode=xdb\nenabled={s['enabled']}")
|
||||
|
||||
|
||||
# --- AirSDB ---
|
||||
|
||||
def _sdb_paths(project_root: Path) -> dict[str, Path]:
|
||||
root = airplan_root(project_root) / "state" / "airsdb"
|
||||
return {"root": root, "state": root / "state.json", "reports_dir": root / "reports"}
|
||||
|
||||
|
||||
def sdb_enter(project_root: Path) -> dict:
|
||||
paths = _sdb_paths(project_root)
|
||||
paths["reports_dir"].mkdir(parents=True, exist_ok=True)
|
||||
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso()})
|
||||
return {"state_path": str(paths["state"])}
|
||||
|
||||
|
||||
def sdb_main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
sub = args.sub or "status"
|
||||
from air_runtime.io import safe_json_load
|
||||
paths = _sdb_paths(project_root)
|
||||
if sub == "enter":
|
||||
result = sdb_enter(project_root)
|
||||
print(f"airplan_mode=sdb\nstate_path={result['state_path']}")
|
||||
else:
|
||||
state = safe_json_load(paths["state"]) or {}
|
||||
print(f"airplan_mode=sdb\nenabled={state.get('enabled', False)}")
|
||||
|
||||
|
||||
# --- AirNDB ---
|
||||
|
||||
def _ndb_paths(project_root: Path) -> dict[str, Path]:
|
||||
root = airplan_root(project_root) / "state" / "airndb"
|
||||
return {"root": root, "state": root / "state.json", "captures_dir": root / "captures"}
|
||||
|
||||
|
||||
def ndb_enter(project_root: Path) -> dict:
|
||||
paths = _ndb_paths(project_root)
|
||||
paths["captures_dir"].mkdir(parents=True, exist_ok=True)
|
||||
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso()})
|
||||
return {"state_path": str(paths["state"])}
|
||||
|
||||
|
||||
def ndb_main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
sub = args.sub or "status"
|
||||
from air_runtime.io import safe_json_load
|
||||
paths = _ndb_paths(project_root)
|
||||
if sub == "enter":
|
||||
result = ndb_enter(project_root)
|
||||
print(f"airplan_mode=ndb\nstate_path={result['state_path']}")
|
||||
else:
|
||||
state = safe_json_load(paths["state"]) or {}
|
||||
print(f"airplan_mode=ndb\nenabled={state.get('enabled', False)}")
|
||||
99
lib/air_runtime/paths.py
Normal file
99
lib/air_runtime/paths.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
路径约定 — V2 统一所有子模块的 AirPlan 目录结构。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def airplan_root(project_root: Path) -> Path:
|
||||
return project_root / "AirPlan"
|
||||
|
||||
|
||||
def state_root(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "state"
|
||||
|
||||
|
||||
def todo_path(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "todo.md"
|
||||
|
||||
|
||||
def plan_path(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "plan.md"
|
||||
|
||||
|
||||
def agents_path(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "AGENTS.md"
|
||||
|
||||
|
||||
def docs_root(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "docs"
|
||||
|
||||
|
||||
# --- 子模块状态路径 ---
|
||||
|
||||
def engine_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "aireng" / "state.json"
|
||||
|
||||
|
||||
def worker_state_path(project_root: Path, task_id: str) -> Path:
|
||||
return state_root(project_root) / "airdo" / "tasks" / task_id / "worker-state.json"
|
||||
|
||||
|
||||
def arc_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "airarc" / "state.json"
|
||||
|
||||
|
||||
def dbg_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "airdbg" / "state.json"
|
||||
|
||||
|
||||
def xdb_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "airxdb" / "state.json"
|
||||
|
||||
|
||||
def sdb_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "airsdb" / "state.json"
|
||||
|
||||
|
||||
def ndb_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "airndb" / "state.json"
|
||||
|
||||
|
||||
def ctx_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "aircontext" / "state.json"
|
||||
|
||||
|
||||
def dep_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "airdep" / "state.json"
|
||||
|
||||
|
||||
def tst_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "airtst" / "state.json"
|
||||
|
||||
|
||||
def sec_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "airsec" / "state.json"
|
||||
|
||||
|
||||
def rvr_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "airrvr" / "state.json"
|
||||
|
||||
|
||||
def event_log_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "events.jsonl"
|
||||
|
||||
|
||||
def task_graph_state_path(project_root: Path) -> Path:
|
||||
return state_root(project_root) / "task-graph.json"
|
||||
|
||||
|
||||
def required_project_artifacts() -> list[str]:
|
||||
return [
|
||||
"AirPlan/AGENTS.md",
|
||||
"AirPlan/plan.md",
|
||||
"AirPlan/todo.md",
|
||||
"AirPlan/docs/architecture/adr/",
|
||||
"AirPlan/docs/architecture/c4/module.md",
|
||||
]
|
||||
59
lib/air_runtime/project_bootstrap.py
Normal file
59
lib/air_runtime/project_bootstrap.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
项目引导模块 — 确保 AirPlan 目录结构存在。
|
||||
V2 保持与 V1 相同的不变量:制品驱动通信、上下文隔离。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def ensure_project_bootstrap(project_root: Path) -> dict[str, bool]:
|
||||
"""创建 AirPlan 必需目录结构。"""
|
||||
root = project_root / "AirPlan"
|
||||
docs = root / "docs"
|
||||
arch = docs / "architecture"
|
||||
adr_dir = arch / "adr"
|
||||
c4_dir = arch / "c4"
|
||||
debug_dir = docs / "debug"
|
||||
state = root / "state"
|
||||
|
||||
dirs = [
|
||||
root,
|
||||
docs,
|
||||
arch,
|
||||
adr_dir,
|
||||
c4_dir,
|
||||
debug_dir,
|
||||
state,
|
||||
state / "airarc" / "reviews",
|
||||
state / "aireng" / "dispatch",
|
||||
state / "aireng" / "archive",
|
||||
state / "aireng" / "plans",
|
||||
state / "airdo" / "tasks",
|
||||
state / "airdbg" / "sessions",
|
||||
state / "airdbg" / "snapshots",
|
||||
state / "airxdb" / "artifacts",
|
||||
state / "airsdb" / "reports",
|
||||
state / "airndb" / "captures",
|
||||
state / "aircontext",
|
||||
state / "airdep" / "sessions",
|
||||
state / "airtst" / "reports",
|
||||
state / "airsec",
|
||||
state / "airrvr" / "reviews",
|
||||
]
|
||||
|
||||
for d in dirs:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 创建必要文件
|
||||
(root / "AGENTS.md").touch()
|
||||
(root / "plan.md").touch()
|
||||
(root / "todo.md").touch()
|
||||
(adr_dir / "placeholder.md").touch()
|
||||
(c4_dir / "module.md").touch()
|
||||
(debug_dir / "debug-log.md").touch()
|
||||
(debug_dir / "gui-debug-log.md").touch()
|
||||
(docs / "staticanalysis.md").touch()
|
||||
|
||||
return {"bootstrap": True}
|
||||
126
lib/air_runtime/review.py
Normal file
126
lib/air_runtime/review.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
并行审查模块 — V2 从 V1 迁移。
|
||||
分析任务依赖、写集冲突、产出并行组和串行点。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from air_runtime.todo_parser import parse_tasks
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParallelGroup:
|
||||
name: str
|
||||
task_ids: list[str]
|
||||
reason: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"name": self.name, "task_ids": self.task_ids, "reason": self.reason}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conflict:
|
||||
task_a: str
|
||||
task_b: str
|
||||
reason: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"task_a": self.task_a, "task_b": self.task_b, "reason": self.reason}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewResult:
|
||||
parallel_groups: list[ParallelGroup] = field(default_factory=list)
|
||||
conflicts: list[Conflict] = field(default_factory=list)
|
||||
serialization_points: list[dict] = field(default_factory=list)
|
||||
edges: list[dict] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"parallelGroups": [{"name": g.name, "task_ids": g.task_ids, "reason": g.reason} for g in self.parallel_groups],
|
||||
"conflicts": [{"task_a": c.task_a, "task_b": c.task_b, "reason": c.reason} for c in self.conflicts],
|
||||
"serializationPoints": self.serialization_points,
|
||||
"edges": self.edges,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> ReviewResult:
|
||||
return cls(
|
||||
parallel_groups=[ParallelGroup(**g) for g in data.get("parallelGroups", [])],
|
||||
conflicts=[Conflict(**c) for c in data.get("conflicts", [])],
|
||||
serialization_points=data.get("serializationPoints", []),
|
||||
edges=data.get("edges", []),
|
||||
)
|
||||
|
||||
|
||||
def build_parallel_review(todo_path: Path) -> ReviewResult:
|
||||
"""分析 todo.md,产出并行组和冲突。"""
|
||||
tasks = parse_tasks(todo_path)
|
||||
result = ReviewResult()
|
||||
|
||||
# 解析依赖:task 文本中的 "依赖 T-xxx" 或 Done When 中的引用
|
||||
edges = []
|
||||
for t in tasks:
|
||||
deps = re.findall(r"T-\d+[a-z]*", t.done_when)
|
||||
deps.extend(re.findall(r"依赖\s+(T-\d+[a-z]*)", t.task))
|
||||
for dep in deps:
|
||||
if dep != t.task_id:
|
||||
edges.append({"source": dep, "target": t.task_id, "kind": "dependency"})
|
||||
result.edges.append({"source": dep, "target": t.task_id, "kind": "dependency"})
|
||||
|
||||
# 写集冲突检测
|
||||
file_map: dict[str, list[str]] = {}
|
||||
for t in tasks:
|
||||
if t.files_dirs:
|
||||
files = [f.strip() for f in t.files_dirs.split(",")]
|
||||
for f in files:
|
||||
file_map.setdefault(f, []).append(t.task_id)
|
||||
|
||||
conflicts = []
|
||||
for fpath, tid_list in file_map.items():
|
||||
for i, tid_a in enumerate(tid_list):
|
||||
for tid_b in tid_list[i + 1:]:
|
||||
conflicts.append(Conflict(tid_a, tid_b, f"shared file: {fpath}"))
|
||||
result.conflicts = conflicts
|
||||
|
||||
# 串行点:同文件不同任务的依赖链
|
||||
for fpath, tid_list in file_map.items():
|
||||
if len(tid_list) > 1:
|
||||
for tid in tid_list[1:]:
|
||||
result.serialization_points.append({
|
||||
"taskId": tid,
|
||||
"reasons": [f"serialized with {tid_list[0]} due to shared file: {fpath}"],
|
||||
})
|
||||
|
||||
# 并行组:入度为 0 的任务
|
||||
target_count = {e["target"] for e in edges}
|
||||
ready = [t.task_id for t in tasks if t.task_id not in target_count and t.status == "TODO"]
|
||||
if ready:
|
||||
result.parallel_groups.append(ParallelGroup(
|
||||
name="wave-1", task_ids=ready,
|
||||
reason="no dependencies on other TODO tasks",
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def render_review_markdown(review: ReviewResult) -> str:
|
||||
lines = ["# AirArc Parallel Review", ""]
|
||||
lines.append(f"## Summary")
|
||||
lines.append(f"- Parallel groups: {len(review.parallel_groups)}")
|
||||
lines.append(f"- Conflicts: {len(review.conflicts)}")
|
||||
lines.append(f"- Serialization points: {len(review.serialization_points)}")
|
||||
lines.append("")
|
||||
lines.append("## Parallel Groups")
|
||||
for g in review.parallel_groups:
|
||||
lines.append(f"### {g.name}")
|
||||
lines.append(f"Reason: {g.reason}")
|
||||
lines.append(f"Tasks: {', '.join(g.task_ids)}")
|
||||
lines.append("")
|
||||
lines.append("## Conflicts")
|
||||
for c in review.conflicts:
|
||||
lines.append(f"- {c.task_a} <-> {c.task_b}: {c.reason}")
|
||||
return "\n".join(lines)
|
||||
148
lib/air_runtime/review_runtime.py
Normal file
148
lib/air_runtime/review_runtime.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
AirRvr 需求审查运行时 — V2 新增组件。
|
||||
基于原始需求文档对已完成任务进行独立审查,验证交付物与需求的一致性。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.io import atomic_json_write
|
||||
from air_runtime.paths import rvr_state_path
|
||||
from air_runtime.utils import session_stamp
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequirementCoverage:
|
||||
requirement: str
|
||||
status: str # covered | partial | missing
|
||||
evidence: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeToDesignItem:
|
||||
design_item: str
|
||||
implementation_status: str # aligned | divergent | missing
|
||||
code_location: str = ""
|
||||
design_location: str = ""
|
||||
divergence_detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewReport:
|
||||
task_id: str
|
||||
verdict: str # pass | conditional-pass | fail
|
||||
coverage: list[RequirementCoverage] = field(default_factory=list)
|
||||
intent_alignment: str = "aligned" # aligned | divergent
|
||||
divergence_notes: str = ""
|
||||
regression_risk: str = "none" # none | low | medium | high
|
||||
code_quality: dict = field(default_factory=lambda: {
|
||||
"complexity": "low", "readability": "good", "duplication": "none", "error_handling": "complete",
|
||||
})
|
||||
lifecycle_health: dict = field(default_factory=lambda: {
|
||||
"resource_leak": "none", "connection_management": "proper",
|
||||
"timeout_strategy": "present", "retry_strategy": "present",
|
||||
})
|
||||
runtime_stability: dict = field(default_factory=lambda: {
|
||||
"crash_risk": "none", "race_condition": "none",
|
||||
"memory_leak": "none", "user_impact": "none",
|
||||
})
|
||||
code_to_design_table: list[CodeToDesignItem] = field(default_factory=list)
|
||||
logging_checks: dict = field(default_factory=lambda: {
|
||||
"spdlog_integrated": False,
|
||||
"non_standard_logging": [],
|
||||
"debug_release_switch": False,
|
||||
"critical_path_logging": False,
|
||||
"unified_format": False,
|
||||
})
|
||||
recommendations: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ReviewRuntime:
|
||||
"""AirRvr 审查运行时 — 管理审查会话和报告持久化。"""
|
||||
|
||||
REVIEW_MODES = ["per-task", "per-wave", "per-milestone"]
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
self._project_root = project_root
|
||||
self._state_dir = rvr_state_path(project_root).parent
|
||||
self._reviews_dir = self._state_dir / "reviews"
|
||||
self._reviews_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save_report(self, report: ReviewReport) -> Path:
|
||||
report_path = self._reviews_dir / f"{report.task_id}-{session_stamp()}.json"
|
||||
atomic_json_write(report_path, self._report_to_dict(report))
|
||||
return report_path
|
||||
|
||||
def load_report(self, task_id: str, timestamp: str) -> ReviewReport | None:
|
||||
from air_runtime.io import safe_json_load
|
||||
report_path = self._reviews_dir / f"{task_id}-{timestamp}.json"
|
||||
data = safe_json_load(report_path)
|
||||
if data:
|
||||
return self._dict_to_report(data)
|
||||
return None
|
||||
|
||||
def get_integration_verdict(self, report: ReviewReport) -> str:
|
||||
"""与 AirEng 集成:pass → 允许合并,conditional-pass → 合并但记录遗留项,fail → 阻止合并。"""
|
||||
return report.verdict
|
||||
|
||||
def get_verdict_for_task(self, task_id: str) -> dict:
|
||||
"""从持久化的 review report 读 verdict,返回 dict 含 verdict/residual/reportPath。
|
||||
没有 report 时返回 {"verdict": "pass", "reportPath": ""}(默认放行)。"""
|
||||
from air_runtime.io import safe_json_load
|
||||
# reports/ 是 AirEng 约定的存放路径(验证脚本和 eng_mode 期望的位置)
|
||||
reports_dir = self._state_dir / "reports"
|
||||
report_path = reports_dir / f"{task_id}.json"
|
||||
if not report_path.exists():
|
||||
# 兼容旧路径 reviews/ 下的 {task_id}-{ts}.json,找最新一份
|
||||
alt = self._reviews_dir
|
||||
if alt.exists():
|
||||
candidates = sorted(alt.glob(f"{task_id}-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if candidates:
|
||||
report_path = candidates[0]
|
||||
if not report_path.exists():
|
||||
return {"verdict": "pass", "reportPath": "", "residual": []}
|
||||
report = safe_json_load(report_path)
|
||||
if not report or not isinstance(report, dict):
|
||||
return {"verdict": "pass", "reportPath": str(report_path), "residual": []}
|
||||
return {
|
||||
"verdict": report.get("verdict", "pass"),
|
||||
"residual": report.get("residual", []),
|
||||
"reportPath": str(report_path),
|
||||
"summary": report.get("summary", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _report_to_dict(report: ReviewReport) -> dict:
|
||||
return {
|
||||
"taskId": report.task_id,
|
||||
"verdict": report.verdict,
|
||||
"coverage": [c.__dict__ for c in report.coverage],
|
||||
"intentAlignment": report.intent_alignment,
|
||||
"divergenceNotes": report.divergence_notes,
|
||||
"regressionRisk": report.regression_risk,
|
||||
"codeQuality": report.code_quality,
|
||||
"lifecycleHealth": report.lifecycle_health,
|
||||
"runtimeStability": report.runtime_stability,
|
||||
"codeToDesignTable": [c.__dict__ for c in report.code_to_design_table],
|
||||
"loggingChecks": report.logging_checks,
|
||||
"recommendations": report.recommendations,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _dict_to_report(data: dict) -> ReviewReport:
|
||||
return ReviewReport(
|
||||
task_id=data.get("taskId", ""),
|
||||
verdict=data.get("verdict", "fail"),
|
||||
coverage=[RequirementCoverage(**c) for c in data.get("coverage", [])],
|
||||
intent_alignment=data.get("intentAlignment", "aligned"),
|
||||
divergence_notes=data.get("divergenceNotes", ""),
|
||||
regression_risk=data.get("regressionRisk", "none"),
|
||||
code_quality=data.get("codeQuality", {}),
|
||||
lifecycle_health=data.get("lifecycleHealth", {}),
|
||||
runtime_stability=data.get("runtimeStability", {}),
|
||||
code_to_design_table=[CodeToDesignItem(**c) for c in data.get("codeToDesignTable", [])],
|
||||
logging_checks=data.get("loggingChecks", {}),
|
||||
recommendations=data.get("recommendations", []),
|
||||
)
|
||||
527
lib/air_runtime/sdb_backends.py
Normal file
527
lib/air_runtime/sdb_backends.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""AirSDB backends — 5 static analyzer backends + AnalysisDiff.
|
||||
|
||||
Backends:
|
||||
CppcheckBackend — C/C++ via cppcheck
|
||||
ClangTidyBackend — C/C++ via clang-tidy
|
||||
RustClippyBackend — Rust via cargo clippy
|
||||
GoVetBackend — Go via go vet + staticcheck
|
||||
TypeScriptBackend — TypeScript via tsc --noEmit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified result type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalysisResult:
|
||||
tool: str
|
||||
file: str
|
||||
line: int | None
|
||||
column: int | None
|
||||
severity: str # error | warning | info
|
||||
message: str
|
||||
rule_id: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abstract base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class StaticAnalyzerBackend(ABC):
|
||||
"""Abstract base for every static-analysis backend."""
|
||||
|
||||
@abstractmethod
|
||||
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||
"""Run the analyzer and return structured findings."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Short identifier for this backend (e.g. 'cppcheck')."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def install_hint(self) -> str:
|
||||
"""Human-readable hint shown when the tool is not installed."""
|
||||
|
||||
# -- helpers available to all backends ---------------------------------
|
||||
|
||||
def _check_tool(self, tool_cmd: str) -> None:
|
||||
"""Raise RuntimeError if *tool_cmd* is not on PATH."""
|
||||
if not shutil.which(tool_cmd):
|
||||
raise RuntimeError(self.install_hint)
|
||||
|
||||
@staticmethod
|
||||
def _run(cmd: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess:
|
||||
"""Run *cmd* and capture stdout/stderr. Returns CompletedProcess."""
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CppcheckBackend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CppcheckBackend(StaticAnalyzerBackend):
|
||||
"""C/C++ static analysis via cppcheck."""
|
||||
|
||||
name = "cppcheck"
|
||||
install_hint = (
|
||||
"cppcheck is not installed. "
|
||||
"Install it with: sudo apt install cppcheck (Debian/Ubuntu) "
|
||||
"or: brew install cppcheck (macOS)"
|
||||
)
|
||||
|
||||
# Template: file:line:column:severity:id:message
|
||||
_TEMPLATE = "{file}:{line}:{column}:{severity}:{id}:{message}"
|
||||
|
||||
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||
self._check_tool("cppcheck")
|
||||
|
||||
src = str(target) if target else str(project_root)
|
||||
cmd = [
|
||||
"cppcheck",
|
||||
"--quiet",
|
||||
f"--template={self._TEMPLATE}",
|
||||
"--force",
|
||||
src,
|
||||
]
|
||||
proc = self._run(cmd, cwd=project_root)
|
||||
|
||||
results: list[AnalysisResult] = []
|
||||
for line in proc.stderr.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(":", 5)
|
||||
if len(parts) < 6:
|
||||
continue
|
||||
try:
|
||||
ln = int(parts[1]) if parts[1].strip() else None
|
||||
except ValueError:
|
||||
ln = None
|
||||
try:
|
||||
col = int(parts[2]) if parts[2].strip() else None
|
||||
except ValueError:
|
||||
col = None
|
||||
|
||||
severity = parts[3].strip()
|
||||
# Map cppcheck severities to our unified set
|
||||
if severity not in ("error", "warning", "info"):
|
||||
if severity in ("performance", "portability", "style"):
|
||||
severity = "warning"
|
||||
else:
|
||||
severity = "info"
|
||||
|
||||
results.append(AnalysisResult(
|
||||
tool=self.name,
|
||||
file=parts[0].strip(),
|
||||
line=ln,
|
||||
column=col,
|
||||
severity=severity,
|
||||
message=parts[5].strip(),
|
||||
rule_id=parts[4].strip() or None,
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClangTidyBackend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ClangTidyBackend(StaticAnalyzerBackend):
|
||||
"""C/C++ static analysis via clang-tidy."""
|
||||
|
||||
name = "clang-tidy"
|
||||
install_hint = (
|
||||
"clang-tidy is not installed. "
|
||||
"Install it with: sudo apt install clang-tidy (Debian/Ubuntu) "
|
||||
"or: brew install llvm (macOS, then use llvm/bin/clang-tidy)"
|
||||
)
|
||||
|
||||
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||
self._check_tool("clang-tidy")
|
||||
|
||||
src = str(target) if target else str(project_root)
|
||||
cmd = [
|
||||
"clang-tidy",
|
||||
"--quiet",
|
||||
src,
|
||||
]
|
||||
# Use compile_commands.json if present
|
||||
comp_db = project_root / "compile_commands.json"
|
||||
if comp_db.exists():
|
||||
cmd.append(f"-p={comp_db.parent}")
|
||||
|
||||
proc = self._run(cmd, cwd=project_root)
|
||||
|
||||
results: list[AnalysisResult] = []
|
||||
# clang-tidy output format: <file>:<line>:<col>: warning: <message> [check-name]
|
||||
for line in proc.stderr.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(":", 3)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
try:
|
||||
ln = int(parts[1].strip()) if parts[1].strip() else None
|
||||
except ValueError:
|
||||
ln = None
|
||||
try:
|
||||
col = int(parts[2].strip()) if parts[2].strip() else None
|
||||
except ValueError:
|
||||
col = None
|
||||
|
||||
msg_part = parts[3].strip()
|
||||
severity = "warning"
|
||||
# Detect "error:" prefix
|
||||
if msg_part.startswith("error:"):
|
||||
severity = "error"
|
||||
msg_part = msg_part[len("error:"):].strip()
|
||||
elif msg_part.startswith("warning:"):
|
||||
msg_part = msg_part[len("warning:"):].strip()
|
||||
elif msg_part.startswith("note:"):
|
||||
severity = "info"
|
||||
msg_part = msg_part[len("note:"):].strip()
|
||||
|
||||
# Extract [check-name] at the end
|
||||
rule_id = None
|
||||
if msg_part.endswith("]"):
|
||||
bracket = msg_part.rfind("[")
|
||||
if bracket != -1:
|
||||
rule_id = msg_part[bracket + 1:-1].strip()
|
||||
msg_part = msg_part[:bracket].strip()
|
||||
|
||||
results.append(AnalysisResult(
|
||||
tool=self.name,
|
||||
file=parts[0].strip(),
|
||||
line=ln,
|
||||
column=col,
|
||||
severity=severity,
|
||||
message=msg_part,
|
||||
rule_id=rule_id,
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RustClippyBackend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RustClippyBackend(StaticAnalyzerBackend):
|
||||
"""Rust static analysis via cargo clippy."""
|
||||
|
||||
name = "clippy"
|
||||
install_hint = (
|
||||
"cargo clippy is not available. "
|
||||
"Install Rust toolchain: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh "
|
||||
"then: rustup component add clippy"
|
||||
)
|
||||
|
||||
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||
self._check_tool("cargo")
|
||||
|
||||
cmd = [
|
||||
"cargo", "clippy",
|
||||
"--message-format=json",
|
||||
]
|
||||
# If a specific target file/dir is given, we still run cargo clippy
|
||||
# on the whole crate (cargo does not support single-file analysis).
|
||||
proc = self._run(cmd, cwd=project_root)
|
||||
|
||||
results: list[AnalysisResult] = []
|
||||
for line in proc.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if obj.get("reason") != "compiler-message":
|
||||
continue
|
||||
msg = obj.get("message", {})
|
||||
level = msg.get("level", "")
|
||||
if level == "error":
|
||||
severity = "error"
|
||||
elif level in ("warning",):
|
||||
severity = "warning"
|
||||
else:
|
||||
severity = "info"
|
||||
|
||||
for span in msg.get("spans", []):
|
||||
results.append(AnalysisResult(
|
||||
tool=self.name,
|
||||
file=span.get("file_name", ""),
|
||||
line=span.get("line_start"),
|
||||
column=span.get("column_start"),
|
||||
severity=severity,
|
||||
message=msg.get("message", ""),
|
||||
rule_id=msg.get("code", {}).get("code") or None,
|
||||
))
|
||||
|
||||
# If no JSON output (e.g. compile error), also parse stderr
|
||||
if not results and proc.stderr:
|
||||
for line in proc.stderr.splitlines():
|
||||
line = line.strip()
|
||||
if "error" in line.lower() and ":" in line:
|
||||
results.append(AnalysisResult(
|
||||
tool=self.name,
|
||||
file=str(project_root),
|
||||
line=None,
|
||||
column=None,
|
||||
severity="error",
|
||||
message=line,
|
||||
rule_id=None,
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GoVetBackend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GoVetBackend(StaticAnalyzerBackend):
|
||||
"""Go static analysis via go vet + staticcheck."""
|
||||
|
||||
name = "go-vet"
|
||||
install_hint = (
|
||||
"go is not installed. "
|
||||
"Install Go: https://go.dev/dl/ "
|
||||
"For staticcheck: go install honnef.co/go/tools/cmd/staticcheck@latest"
|
||||
)
|
||||
|
||||
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||
self._check_tool("go")
|
||||
|
||||
results: list[AnalysisResult] = []
|
||||
|
||||
# 1) go vet — JSON output
|
||||
vet_cmd = ["go", "vet", "./..."]
|
||||
proc = self._run(vet_cmd, cwd=project_root)
|
||||
if proc.stderr:
|
||||
results.extend(self._parse_go_vet_output(proc.stderr))
|
||||
|
||||
# 2) staticcheck (optional — don't fail if not installed)
|
||||
if shutil.which("staticcheck"):
|
||||
sc_cmd = ["staticcheck", "-f=json", "./..."]
|
||||
sc_proc = self._run(sc_cmd, cwd=project_root)
|
||||
for line in sc_proc.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
severity = "warning"
|
||||
if obj.get("severity", "") == "error":
|
||||
severity = "error"
|
||||
results.append(AnalysisResult(
|
||||
tool="staticcheck",
|
||||
file=obj.get("location", {}).get("file", ""),
|
||||
line=obj.get("location", {}).get("line"),
|
||||
column=obj.get("location", {}).get("column"),
|
||||
severity=severity,
|
||||
message=obj.get("message", ""),
|
||||
rule_id=obj.get("code", ""),
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _parse_go_vet_output(text: str) -> list[AnalysisResult]:
|
||||
"""Parse go vet stderr output.
|
||||
|
||||
go vet output format (non-JSON):
|
||||
<file>:<line>: <message>
|
||||
"""
|
||||
results: list[AnalysisResult] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
try:
|
||||
ln = int(parts[1].strip()) if parts[1].strip() else None
|
||||
except ValueError:
|
||||
ln = None
|
||||
results.append(AnalysisResult(
|
||||
tool="go vet",
|
||||
file=parts[0].strip(),
|
||||
line=ln,
|
||||
column=None,
|
||||
severity="warning",
|
||||
message=parts[2].strip(),
|
||||
rule_id=None,
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TypeScriptBackend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TypeScriptBackend(StaticAnalyzerBackend):
|
||||
"""TypeScript static analysis via tsc --noEmit."""
|
||||
|
||||
name = "tsc"
|
||||
install_hint = (
|
||||
"tsc (TypeScript compiler) is not installed. "
|
||||
"Install it with: npm install -g typescript "
|
||||
"or add it to your project: npm install --save-dev typescript"
|
||||
)
|
||||
|
||||
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||
# tsc can be installed locally (npx) or globally
|
||||
tsc_cmd = self._find_tsc()
|
||||
if tsc_cmd is None:
|
||||
raise RuntimeError(self.install_hint)
|
||||
|
||||
cmd = tsc_cmd + ["--noEmit", "--pretty", "false"]
|
||||
proc = self._run(cmd, cwd=project_root)
|
||||
|
||||
results: list[AnalysisResult] = []
|
||||
# tsc output format: <file>(<line>,<col>): error TS<code>: <message>
|
||||
for line in proc.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
results.append(self._parse_tsc_line(line))
|
||||
return results
|
||||
|
||||
def _find_tsc(self) -> list[str] | None:
|
||||
"""Return the tsc command as a list, or None if not found."""
|
||||
if shutil.which("tsc"):
|
||||
return ["tsc"]
|
||||
if shutil.which("npx"):
|
||||
return ["npx", "tsc"]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_tsc_line(line: str) -> AnalysisResult:
|
||||
"""Parse a single tsc diagnostic line.
|
||||
|
||||
Format: <file>(<line>,<col>): error TS1234: <message>
|
||||
"""
|
||||
severity = "error"
|
||||
rule_id = None
|
||||
|
||||
# Split on the first colon-space after the position paren
|
||||
# e.g. "src/foo.ts(10,5): error TS2322: Type 'string' ..."
|
||||
main_parts = line.split(": ", 1)
|
||||
location_part = main_parts[0] if main_parts else line
|
||||
message = main_parts[1].strip() if len(main_parts) > 1 else ""
|
||||
|
||||
# Extract file, line, column from "file(line,col)"
|
||||
file_part = location_part
|
||||
ln = None
|
||||
col = None
|
||||
paren = location_part.rfind("(")
|
||||
if paren != -1 and location_part.endswith(")"):
|
||||
file_part = location_part[:paren]
|
||||
pos_str = location_part[paren + 1:-1]
|
||||
pos_parts = pos_str.split(",", 1)
|
||||
try:
|
||||
ln = int(pos_parts[0].strip()) if pos_parts[0].strip() else None
|
||||
except ValueError:
|
||||
pass
|
||||
if len(pos_parts) > 1:
|
||||
try:
|
||||
col = int(pos_parts[1].strip()) if pos_parts[1].strip() else None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract severity + rule from " error TS2322" in the remainder
|
||||
if len(main_parts) > 1:
|
||||
# The part between the first colon-space and the message
|
||||
# is in the original line — re-parse
|
||||
rest = line[len(location_part) + 2:] # after ": "
|
||||
if rest.startswith("error "):
|
||||
severity = "error"
|
||||
rest = rest[len("error "):]
|
||||
elif rest.startswith("warning "):
|
||||
severity = "warning"
|
||||
rest = rest[len("warning "):]
|
||||
# rest now starts with "TS1234: message"
|
||||
ts_parts = rest.split(": ", 1)
|
||||
if ts_parts:
|
||||
rule_id = ts_parts[0].strip() or None
|
||||
if len(ts_parts) > 1:
|
||||
message = ts_parts[1].strip()
|
||||
|
||||
return AnalysisResult(
|
||||
tool="tsc",
|
||||
file=file_part,
|
||||
line=ln,
|
||||
column=col,
|
||||
severity=severity,
|
||||
message=message,
|
||||
rule_id=rule_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AnalysisDiff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AnalysisDiff:
|
||||
"""Compare two lists of AnalysisResult and classify findings as
|
||||
new, resolved, or unchanged."""
|
||||
|
||||
@staticmethod
|
||||
def _key(r: AnalysisResult) -> tuple[str, int | None, str | None]:
|
||||
"""Dedup key: (file, line, rule_id)."""
|
||||
return (r.file, r.line, r.rule_id)
|
||||
|
||||
def diff(
|
||||
self,
|
||||
before: list[AnalysisResult],
|
||||
after: list[AnalysisResult],
|
||||
) -> dict:
|
||||
before_keys = {self._key(r): r for r in before}
|
||||
after_keys = {self._key(r): r for r in after}
|
||||
|
||||
before_set = set(before_keys.keys())
|
||||
after_set = set(after_keys.keys())
|
||||
|
||||
new_keys = after_set - before_set
|
||||
resolved_keys = before_set - after_set
|
||||
unchanged_keys = before_set & after_set
|
||||
|
||||
return {
|
||||
"new": [after_keys[k] for k in new_keys],
|
||||
"resolved": [before_keys[k] for k in resolved_keys],
|
||||
"unchanged": [before_keys[k] for k in unchanged_keys],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BACKENDS: dict[str, StaticAnalyzerBackend] = {
|
||||
"cppcheck": CppcheckBackend(),
|
||||
"clang-tidy": ClangTidyBackend(),
|
||||
"clippy": RustClippyBackend(),
|
||||
"go-vet": GoVetBackend(),
|
||||
"tsc": TypeScriptBackend(),
|
||||
}
|
||||
175
lib/air_runtime/sec_runtime.py
Normal file
175
lib/air_runtime/sec_runtime.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
AirSec 安全扫描运行时 — V2 新增组件。
|
||||
制品敏感数据扫描 + 自动脱敏 + 误报白名单 + 确认流程 + advisory/blocking 模式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
SECRET_PATTERNS: list[tuple[str, str]] = [
|
||||
("api_key", r'(?:api[_-]?key|apikey)\s*[:=]\s*["\']?([A-Za-z0-9_\-]{16,})["\']?'),
|
||||
("aws_key", r'AKIA[0-9A-Z]{16}'),
|
||||
("private_key", r'-----BEGIN (?:RSA|EC|DSA|OPENSSH) PRIVATE KEY-----'),
|
||||
("token", r'(?:token|secret|password)\s*[:=]\s*["\']?([^\s"\']{8,})["\']?'),
|
||||
("jwt", r'eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+'),
|
||||
("url_credential", r'https?://[^:@]+:([^@]+)@'),
|
||||
]
|
||||
|
||||
ALLOWLIST_PATTERNS: list[str] = [
|
||||
r'EXAMPLE',
|
||||
r'example',
|
||||
r'YOUR_API_KEY',
|
||||
r'TODO',
|
||||
r'<your-',
|
||||
r'placeholder',
|
||||
r'xxxxxxxx',
|
||||
]
|
||||
|
||||
# 文件名白名单:命中则跳过该文件的所有发现
|
||||
WHITELIST_FILE_PATTERNS: list[str] = [
|
||||
r"\.example\.",
|
||||
r"^test_",
|
||||
r"^mock_",
|
||||
r"_test\.py$",
|
||||
r"\.fixture\.",
|
||||
]
|
||||
|
||||
# 用户确认记录(首次发现需确认,后续同模式自动放行)
|
||||
USER_CONFIRMATIONS: dict[str, dict] = {} # {fingerprint: {pattern, confirmed_at, user}}
|
||||
|
||||
|
||||
class ScanMode:
|
||||
"""扫描模式:advisory(只报告,不阻止)/ blocking(阻止合并)"""
|
||||
ADVISORY = "advisory"
|
||||
BLOCKING = "blocking"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanFinding:
|
||||
rule: str
|
||||
file: str
|
||||
line: int
|
||||
match: str # 截断显示,不包含完整密钥
|
||||
severity: str = "high" # high | medium | low
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanReport:
|
||||
task_id: str
|
||||
findings: list[ScanFinding] = field(default_factory=list)
|
||||
whitelisted: int = 0
|
||||
clean: bool = True
|
||||
advisory_blocked: bool = False
|
||||
|
||||
|
||||
def scan_file(file_path: Path, task_id: str = "") -> ScanReport:
|
||||
findings: list[ScanFinding] = []
|
||||
whitelisted = 0
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
return ScanReport(task_id=task_id, clean=True)
|
||||
|
||||
for rule, pattern in SECRET_PATTERNS:
|
||||
for match in re.finditer(pattern, content, re.IGNORECASE):
|
||||
matched_text = match.group(0)
|
||||
if any(re.search(ap, matched_text) for ap in ALLOWLIST_PATTERNS):
|
||||
whitelisted += 1
|
||||
continue
|
||||
|
||||
line_no = content[:match.start()].count("\n") + 1
|
||||
display = matched_text[:60] + "..." if len(matched_text) > 60 else matched_text
|
||||
findings.append(ScanFinding(
|
||||
rule=rule, file=str(file_path), line=line_no, match=display,
|
||||
))
|
||||
|
||||
return ScanReport(
|
||||
task_id=task_id,
|
||||
findings=findings,
|
||||
whitelisted=whitelisted,
|
||||
clean=len(findings) == 0,
|
||||
)
|
||||
|
||||
|
||||
def scan_result_data(result: dict, task_id: str = "") -> ScanReport:
|
||||
"""扫描 Worker result.json 中的敏感数据。"""
|
||||
import json
|
||||
text = json.dumps(result, ensure_ascii=False)
|
||||
findings: list[ScanFinding] = []
|
||||
whitelisted = 0
|
||||
|
||||
for rule, pattern in SECRET_PATTERNS:
|
||||
for match in re.finditer(pattern, text, re.IGNORECASE):
|
||||
matched_text = match.group(0)
|
||||
if any(re.search(ap, matched_text) for ap in ALLOWLIST_PATTERNS):
|
||||
whitelisted += 1
|
||||
continue
|
||||
display = matched_text[:60] + "..." if len(matched_text) > 60 else matched_text
|
||||
findings.append(ScanFinding(
|
||||
rule=rule, file="result.json", line=0, match=display,
|
||||
))
|
||||
|
||||
return ScanReport(
|
||||
task_id=task_id,
|
||||
findings=findings,
|
||||
whitelisted=whitelisted,
|
||||
clean=len(findings) == 0,
|
||||
)
|
||||
|
||||
|
||||
def scan_file_with_mode(
|
||||
file_path: Path,
|
||||
task_id: str = "",
|
||||
mode: str = ScanMode.BLOCKING,
|
||||
confirm_callback=None, # 可选:首次发现时调用此回调询问用户
|
||||
) -> ScanReport:
|
||||
"""
|
||||
增强版扫描:
|
||||
1. 基础扫描(已有逻辑)
|
||||
2. 文件名白名单过滤
|
||||
3. 模式判断(advisory vs blocking)
|
||||
"""
|
||||
report = scan_file(file_path, task_id) # 原有逻辑
|
||||
|
||||
# 文件名白名单过滤
|
||||
filtered_findings = []
|
||||
for f in report.findings:
|
||||
filename = file_path.name
|
||||
if any(re.search(p, filename) for p in WHITELIST_FILE_PATTERNS):
|
||||
report.whitelisted += 1
|
||||
continue
|
||||
filtered_findings.append(f)
|
||||
|
||||
report.findings = filtered_findings
|
||||
report.clean = len(filtered_findings) == 0
|
||||
|
||||
# 模式处理
|
||||
if not report.clean and mode == ScanMode.ADVISORY:
|
||||
# advisory 模式:只记录,不阻止
|
||||
report.advisory_blocked = False
|
||||
elif not report.clean and mode == ScanMode.BLOCKING:
|
||||
# blocking 模式:默认阻止
|
||||
report.advisory_blocked = True
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def confirm_pattern(task_id: str, pattern: str, user: str = "unknown") -> None:
|
||||
"""用户确认某模式为安全后,记录下来"""
|
||||
from air_runtime.utils import now_iso
|
||||
fingerprint = f"{task_id}:{pattern}"
|
||||
USER_CONFIRMATIONS[fingerprint] = {
|
||||
"pattern": pattern,
|
||||
"confirmed_at": now_iso(),
|
||||
"user": user,
|
||||
}
|
||||
|
||||
|
||||
def is_confirmed(task_id: str, pattern: str) -> bool:
|
||||
"""检查某模式是否已被用户确认"""
|
||||
fingerprint = f"{task_id}:{pattern}"
|
||||
return fingerprint in USER_CONFIRMATIONS
|
||||
220
lib/air_runtime/task_graph.py
Normal file
220
lib/air_runtime/task_graph.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
动态任务依赖图(DAG)— V2 P1-14 修复。
|
||||
替代 V1 静态 todo.md 表格,支持 Arc 增量重规划,Eng 增量吸收。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskNode:
|
||||
id: str
|
||||
status: str = "TODO" # TODO | DISPATCHED | DONE | BLOCKED
|
||||
task: str = ""
|
||||
files_dirs: str = ""
|
||||
done_when: str = ""
|
||||
in_degree: int = 0
|
||||
out_edges: list[str] = field(default_factory=list)
|
||||
write_set: list[str] = field(default_factory=list)
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Edge:
|
||||
source: str
|
||||
target: str
|
||||
kind: str = "dependency" # dependency | conflict | sync
|
||||
|
||||
|
||||
@dataclass
|
||||
class EdgeChange:
|
||||
added: list[Edge] = field(default_factory=list)
|
||||
removed: list[Edge] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanDelta:
|
||||
"""Arc 重规划产出的增量差异,替代全量覆盖 todo.md。"""
|
||||
removed_tasks: list[str] = field(default_factory=list)
|
||||
added_tasks: list[TaskNode] = field(default_factory=list)
|
||||
modified_tasks: list[TaskNode] = field(default_factory=list)
|
||||
edge_changes: EdgeChange = field(default_factory=EdgeChange)
|
||||
|
||||
|
||||
class TaskGraph:
|
||||
"""动态任务依赖图,支持增量更新和全量替换。"""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: dict[str, TaskNode] = {}
|
||||
self.edges: list[Edge] = []
|
||||
|
||||
def add_node(self, node: TaskNode) -> None:
|
||||
self.nodes[node.id] = node
|
||||
|
||||
def add_edge(self, edge: Edge) -> None:
|
||||
self.edges.append(edge)
|
||||
if edge.target in self.nodes:
|
||||
self.nodes[edge.target].in_degree += 1
|
||||
if edge.source in self.nodes:
|
||||
self.nodes[edge.source].out_edges.append(edge.target)
|
||||
|
||||
def apply_delta(self, delta: PlanDelta) -> None:
|
||||
"""增量吸收 Arc 的重规划结果,保留已调度任务不受影响。"""
|
||||
for task_id in delta.removed_tasks:
|
||||
self._remove_node(task_id)
|
||||
for node in delta.added_tasks:
|
||||
self._add_node(node)
|
||||
for node in delta.modified_tasks:
|
||||
self._update_node(node)
|
||||
for edge in delta.edge_changes.removed:
|
||||
self._remove_edge(edge)
|
||||
for edge in delta.edge_changes.added:
|
||||
self._add_edge(edge)
|
||||
|
||||
def apply_full_replace(self, nodes: list[TaskNode], edges: list[Edge]) -> None:
|
||||
"""全量替换模式:Arc 产出完整 DAG,保留已完成任务状态。"""
|
||||
done_status = {tid: n.status for tid, n in self.nodes.items() if n.status == "DONE"}
|
||||
self.nodes = {n.id: n for n in nodes}
|
||||
self.edges = list(edges)
|
||||
for tid, status in done_status.items():
|
||||
if tid in self.nodes:
|
||||
self.nodes[tid].status = status
|
||||
for edge in self.edges:
|
||||
if edge.target in self.nodes:
|
||||
self.nodes[edge.target].in_degree += 1
|
||||
if edge.source in self.nodes:
|
||||
self.nodes[edge.source].out_edges.append(edge.target)
|
||||
|
||||
def ready_tasks(self) -> list[str]:
|
||||
"""返回当前入度为 0 且状态为 TODO 的任务。"""
|
||||
return [nid for nid, n in self.nodes.items() if n.in_degree == 0 and n.status == "TODO"]
|
||||
|
||||
def diff(self, other: TaskGraph) -> PlanDelta:
|
||||
"""对比自身与 other,产出 PlanDelta(add/remove/modify node + edge changes)。
|
||||
|
||||
self = 新图, other = 旧图(before replan)。
|
||||
"""
|
||||
delta = PlanDelta()
|
||||
old_ids = set(other.nodes.keys())
|
||||
new_ids = set(self.nodes.keys())
|
||||
|
||||
# 移除
|
||||
delta.removed_tasks = list(old_ids - new_ids)
|
||||
|
||||
# 新增
|
||||
delta.added_tasks = [self.nodes[tid] for tid in (new_ids - old_ids)]
|
||||
|
||||
# 修改
|
||||
for tid in old_ids & new_ids:
|
||||
old_n = other.nodes[tid]
|
||||
new_n = self.nodes[tid]
|
||||
if (old_n.task != new_n.task
|
||||
or old_n.files_dirs != new_n.files_dirs
|
||||
or old_n.done_when != new_n.done_when
|
||||
or old_n.write_set != new_n.write_set):
|
||||
delta.modified_tasks.append(new_n)
|
||||
|
||||
# Edge 差异
|
||||
old_edges = {(e.source, e.target, e.kind) for e in other.edges}
|
||||
new_edges = {(e.source, e.target, e.kind) for e in self.edges}
|
||||
for s, t, k in (new_edges - old_edges):
|
||||
delta.edge_changes.added.append(Edge(source=s, target=t, kind=k))
|
||||
for s, t, k in (old_edges - new_edges):
|
||||
delta.edge_changes.removed.append(Edge(source=s, target=t, kind=k))
|
||||
|
||||
return delta
|
||||
|
||||
@classmethod
|
||||
def load(cls, path) -> TaskGraph:
|
||||
"""从 _export_task_graph_json 写的格式还原 TaskGraph。"""
|
||||
from pathlib import Path
|
||||
from air_runtime.io import safe_json_load
|
||||
p = Path(path)
|
||||
data = safe_json_load(p)
|
||||
graph = cls()
|
||||
if not data or not isinstance(data, dict):
|
||||
return graph
|
||||
for nid, nd in data.get("nodes", {}).items():
|
||||
graph.nodes[nid] = TaskNode(
|
||||
id=nd.get("id", nid),
|
||||
status=nd.get("status", "TODO"),
|
||||
task=nd.get("task", ""),
|
||||
files_dirs=nd.get("filesDirs", ""),
|
||||
done_when=nd.get("doneWhen", ""),
|
||||
in_degree=nd.get("inDegree", 0),
|
||||
out_edges=list(nd.get("outEdges", [])),
|
||||
write_set=list(nd.get("writeSet", [])),
|
||||
)
|
||||
for ed in data.get("edges", []):
|
||||
graph.edges.append(Edge(
|
||||
source=ed["source"], target=ed["target"],
|
||||
kind=ed.get("kind", "dependency"),
|
||||
))
|
||||
return graph
|
||||
|
||||
def task_ids_by_status(self, status: str) -> list[str]:
|
||||
return [nid for nid, n in self.nodes.items() if n.status == status]
|
||||
|
||||
def find_cycles(self) -> list[list[str]]:
|
||||
"""检测依赖环(DFS)。"""
|
||||
visited: set[str] = set()
|
||||
rec_stack: set[str] = set()
|
||||
cycles: list[list[str]] = []
|
||||
|
||||
def dfs(node_id: str, path: list[str]) -> None:
|
||||
visited.add(node_id)
|
||||
rec_stack.add(node_id)
|
||||
path.append(node_id)
|
||||
for target in self.nodes.get(node_id, TaskNode(id=node_id)).out_edges:
|
||||
if target not in visited:
|
||||
dfs(target, path.copy())
|
||||
elif target in rec_stack:
|
||||
cycle_start = path.index(target)
|
||||
cycles.append(path[cycle_start:])
|
||||
rec_stack.discard(node_id)
|
||||
|
||||
for nid in self.nodes:
|
||||
if nid not in visited:
|
||||
dfs(nid, [])
|
||||
|
||||
return cycles
|
||||
|
||||
def export_todo_md(self) -> str:
|
||||
"""导出为人可读的 todo.md 表格,保留 V1 的可见性优势。"""
|
||||
lines = ["| Task | Status | Files/Dirs | Done When | Validation | ADR |",
|
||||
"|------|--------|------------|-----------|------------|-----|"]
|
||||
for nid, node in self.nodes.items():
|
||||
lines.append(f"| {node.task} | {node.status} | {node.files_dirs} | "
|
||||
f"{node.done_when} | | |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def _remove_node(self, task_id: str) -> None:
|
||||
if task_id in self.nodes:
|
||||
del self.nodes[task_id]
|
||||
self.edges = [e for e in self.edges if e.source != task_id and e.target != task_id]
|
||||
|
||||
def _add_node(self, node: TaskNode) -> None:
|
||||
self.nodes[node.id] = node
|
||||
|
||||
def _update_node(self, node: TaskNode) -> None:
|
||||
if node.id in self.nodes:
|
||||
existing_status = self.nodes[node.id].status
|
||||
self.nodes[node.id] = node
|
||||
if existing_status in ("DISPATCHED", "DONE"):
|
||||
self.nodes[node.id].status = existing_status
|
||||
|
||||
def _remove_edge(self, edge: Edge) -> None:
|
||||
self.edges = [e for e in self.edges
|
||||
if not (e.source == edge.source and e.target == edge.target)]
|
||||
if edge.target in self.nodes:
|
||||
self.nodes[edge.target].in_degree = max(0, self.nodes[edge.target].in_degree - 1)
|
||||
|
||||
def _add_edge(self, edge: Edge) -> None:
|
||||
self.edges.append(edge)
|
||||
if edge.target in self.nodes:
|
||||
self.nodes[edge.target].in_degree += 1
|
||||
if edge.source in self.nodes:
|
||||
self.nodes[edge.source].out_edges.append(edge.target)
|
||||
183
lib/air_runtime/test_runtime.py
Normal file
183
lib/air_runtime/test_runtime.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
AirTst 测试运行器运行时 — V2 新增组件。
|
||||
统一测试执行接口,支持多框架,产出结构化结果。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.io import atomic_json_write
|
||||
from air_runtime.paths import tst_state_path
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestCase:
|
||||
name: str
|
||||
status: str # passed | failed | skipped
|
||||
duration: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestSuite:
|
||||
name: str
|
||||
total: int = 0
|
||||
passed: int = 0
|
||||
failed: int = 0
|
||||
skipped: int = 0
|
||||
cases: list[TestCase] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestRunResult:
|
||||
framework: str
|
||||
total: int = 0
|
||||
passed: int = 0
|
||||
failed: int = 0
|
||||
disabled: int = 0
|
||||
duration: str = ""
|
||||
suites: list[TestSuite] = field(default_factory=list)
|
||||
failures: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
class TestRunner:
|
||||
"""统一测试执行器。"""
|
||||
|
||||
FRAMEWORKS = {
|
||||
"pytest": ["python", "-m", "pytest", "--json-report", "-q"],
|
||||
"googletest": ["ctest", "--output-on-failure"],
|
||||
"jest": ["npx", "jest", "--json"],
|
||||
"vitest": ["npx", "vitest", "run", "--reporter=json"],
|
||||
"go": ["go", "test", "-json", "./..."],
|
||||
"cargo": ["cargo", "test", "--", "--format=json"],
|
||||
}
|
||||
|
||||
def run(self, task_id: str, project_root: Path, framework: str,
|
||||
target_path: Path | None = None,
|
||||
extra_args: list[str] | None = None) -> TestRunResult:
|
||||
if framework not in self.FRAMEWORKS:
|
||||
return TestRunResult(framework=framework, failures=[{"error": f"unsupported framework: {framework}"}])
|
||||
|
||||
cmd = list(self.FRAMEWORKS[framework])
|
||||
if target_path:
|
||||
cmd.append(str(target_path))
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True,
|
||||
timeout=600, cwd=str(project_root))
|
||||
except subprocess.TimeoutExpired:
|
||||
return TestRunResult(framework=framework, failures=[{"error": "timeout"}])
|
||||
|
||||
run_result = self._parse_result(framework, result.stdout)
|
||||
self._save_report(task_id, project_root, run_result)
|
||||
return run_result
|
||||
|
||||
def _parse_result(self, framework: str, stdout: str) -> TestRunResult:
|
||||
if framework == "pytest":
|
||||
return self._parse_pytest(stdout)
|
||||
if framework in ("jest", "vitest"):
|
||||
return self._parse_jest(stdout)
|
||||
if framework == "googletest":
|
||||
return self._parse_googletest(stdout)
|
||||
if framework == "go":
|
||||
return self._parse_go(stdout)
|
||||
if framework == "cargo":
|
||||
return self._parse_cargo(stdout)
|
||||
return TestRunResult(framework=framework, total=0)
|
||||
|
||||
def _parse_pytest(self, stdout: str) -> TestRunResult:
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
return TestRunResult(framework="pytest", failures=[{"error": "json parse failed"}])
|
||||
return TestRunResult(
|
||||
framework="pytest",
|
||||
total=data.get("summary", {}).get("total", 0),
|
||||
passed=data.get("summary", {}).get("passed", 0),
|
||||
failed=data.get("summary", {}).get("failed", 0),
|
||||
duration=str(data.get("duration", "")),
|
||||
)
|
||||
|
||||
def _parse_jest(self, stdout: str) -> TestRunResult:
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
return TestRunResult(framework="jest", failures=[{"error": "json parse failed"}])
|
||||
return TestRunResult(
|
||||
framework="jest",
|
||||
total=data.get("numTotalTests", 0),
|
||||
passed=data.get("numPassedTests", 0),
|
||||
failed=data.get("numFailedTests", 0),
|
||||
)
|
||||
|
||||
def _parse_googletest(self, stdout: str) -> TestRunResult:
|
||||
"""解析 ctest 输出。ctest 不输出 JSON,从文本提取统计。"""
|
||||
import re
|
||||
total = passed = failed = disabled = 0
|
||||
for line in stdout.splitlines():
|
||||
m = re.match(r"(\d+)% tests passed, (\d+) tests failed out of (\d+)", line)
|
||||
if m:
|
||||
failed = int(m.group(2))
|
||||
total = int(m.group(3))
|
||||
passed = total - failed
|
||||
# GoogleTest 也支持 --gtest_output=json
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
if isinstance(data, dict):
|
||||
total = sum(s.get("tests", 0) for s in data.get("testsuites", []))
|
||||
failed = sum(s.get("failures", 0) for s in data.get("testsuites", []))
|
||||
passed = total - failed
|
||||
disabled = sum(s.get("disabled", 0) for s in data.get("testsuites", []))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return TestRunResult(
|
||||
framework="googletest", total=total, passed=passed, failed=failed, disabled=disabled,
|
||||
)
|
||||
|
||||
def _parse_go(self, stdout: str) -> TestRunResult:
|
||||
"""解析 go test -json 输出(JSONL 格式,每行一个事件)。"""
|
||||
total = passed = failed = 0
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
action = ev.get("Action", "")
|
||||
if action == "pass":
|
||||
passed += 1
|
||||
total += 1
|
||||
elif action == "fail":
|
||||
failed += 1
|
||||
total += 1
|
||||
elif action == "skip":
|
||||
total += 1
|
||||
return TestRunResult(framework="go", total=total, passed=passed, failed=failed)
|
||||
|
||||
def _parse_cargo(self, stdout: str) -> TestRunResult:
|
||||
"""解析 cargo test --format=json 输出(JSONL 格式)。"""
|
||||
total = passed = failed = 0
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if ev.get("type") == "test":
|
||||
total += 1
|
||||
if ev.get("event") == "ok":
|
||||
passed += 1
|
||||
elif ev.get("event") == "failed":
|
||||
failed += 1
|
||||
return TestRunResult(framework="cargo", total=total, passed=passed, failed=failed)
|
||||
|
||||
def _save_report(self, task_id: str, project_root: Path, result: TestRunResult) -> None:
|
||||
report_dir = tst_state_path(project_root).parent / "reports"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
from air_runtime.utils import session_stamp
|
||||
report_path = report_dir / f"{task_id}-{session_stamp()}.json"
|
||||
atomic_json_write(report_path, result.__dict__)
|
||||
96
lib/air_runtime/todo_parser.py
Normal file
96
lib/air_runtime/todo_parser.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
TODO 解析器 — V2 修复 P1-8:列索引从表头推导,不再硬编码 cells[1]/cells[6]/cells[7]。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class TodoTask:
|
||||
task_id: str
|
||||
task: str
|
||||
files_dirs: str = ""
|
||||
status: str = "TODO"
|
||||
done_when: str = ""
|
||||
validations: str = ""
|
||||
adr: str = ""
|
||||
|
||||
|
||||
def parse_tasks(todo_path: Path) -> list[TodoTask]:
|
||||
"""从 todo.md 解析任务列表,动态检测列索引。"""
|
||||
if not todo_path.exists():
|
||||
return []
|
||||
|
||||
content = todo_path.read_text(encoding="utf-8")
|
||||
lines = [l.strip() for l in content.splitlines() if l.strip()]
|
||||
|
||||
# 找到 Markdown 表格头
|
||||
header_idx = -1
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("|") and "Task" in line and "Status" in line:
|
||||
header_idx = i
|
||||
break
|
||||
|
||||
if header_idx < 0:
|
||||
return []
|
||||
|
||||
# 解析列名
|
||||
header_line = lines[header_idx]
|
||||
header_cols = [c.strip() for c in header_line.split("|") if c.strip()]
|
||||
|
||||
# 建立列名 → 索引映射
|
||||
col_map = {}
|
||||
for idx, col_name in enumerate(header_cols):
|
||||
col_name_lower = col_name.lower()
|
||||
if "task" in col_name_lower:
|
||||
col_map["task"] = idx
|
||||
elif "status" in col_name_lower:
|
||||
col_map["status"] = idx
|
||||
elif "files" in col_name_lower or "dir" in col_name_lower:
|
||||
col_map["files_dirs"] = idx
|
||||
elif "done" in col_name_lower or "when" in col_name_lower:
|
||||
col_map["done_when"] = idx
|
||||
elif "valid" in col_name_lower:
|
||||
col_map["validations"] = idx
|
||||
elif "adr" in col_name_lower:
|
||||
col_map["adr"] = idx
|
||||
|
||||
# 跳过表头和分隔符
|
||||
tasks = []
|
||||
for line in lines[header_idx + 2:]:
|
||||
if not line.startswith("|"):
|
||||
continue
|
||||
cells = [c.strip() for c in line.split("|") if len(c.strip()) > 0]
|
||||
if not cells:
|
||||
continue
|
||||
|
||||
task_cell = cells[col_map.get("task", 0)] if col_map.get("task", 0) < len(cells) else ""
|
||||
# 提取 [T-xxx] 格式的任务 ID
|
||||
tid_match = re.match(r"\[([A-Za-z0-9_\-\.]+)\]", task_cell)
|
||||
task_id = tid_match.group(1) if tid_match else task_cell
|
||||
task = task_cell
|
||||
status = cells[col_map.get("status", 1)] if col_map.get("status", 1) < len(cells) else "TODO"
|
||||
files_dirs = cells[col_map.get("files_dirs", 2)] if col_map.get("files_dirs", 2) < len(cells) else ""
|
||||
done_when = cells[col_map.get("done_when", 3)] if col_map.get("done_when", 3) < len(cells) else ""
|
||||
validations = cells[col_map.get("validations", 4)] if col_map.get("validations", 4) < len(cells) else ""
|
||||
adr = cells[col_map.get("adr", 5)] if col_map.get("adr", 5) < len(cells) else ""
|
||||
|
||||
# 清理标记
|
||||
task_id = re.sub(r"^\[|\]$", "", task_id).strip()
|
||||
|
||||
if task_id and task_id != "---":
|
||||
tasks.append(TodoTask(
|
||||
task_id=task_id,
|
||||
task=task,
|
||||
files_dirs=files_dirs,
|
||||
status=status.upper() if status else "TODO",
|
||||
done_when=done_when,
|
||||
validations=validations,
|
||||
adr=adr,
|
||||
))
|
||||
|
||||
return tasks
|
||||
65
lib/air_runtime/utils.py
Normal file
65
lib/air_runtime/utils.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
公共工具函数 — 消除 V1 中 _ordered_unique、_session_stamp、policy normalization 等
|
||||
在各模块中 3~5 份重复定义的代码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def ordered_unique(items: list) -> list:
|
||||
"""保序去重。支持字符串列表和带 id 字段的字典列表。"""
|
||||
seen: set[str] = set()
|
||||
result = []
|
||||
for item in items:
|
||||
key = item if isinstance(item, str) else item.get("id", str(item))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def session_stamp() -> str:
|
||||
"""统一的文件系统安全时间戳,所有模块共用。"""
|
||||
return datetime.now(timezone.utc).isoformat().replace(":", "-").replace(".", "-").replace("+", "-")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""ISO 格式 UTC 时间戳,用于 JSON state 文件。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def normalize_policy(defaults: dict[str, Any], overrides: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""通用的策略合并:overrides 覆盖 defaults,类型自动转换。"""
|
||||
merged = {**defaults}
|
||||
if overrides:
|
||||
for k, v in overrides.items():
|
||||
if k in merged:
|
||||
expected_type = type(defaults[k])
|
||||
try:
|
||||
merged[k] = expected_type(v) if not isinstance(v, expected_type) else v
|
||||
except (ValueError, TypeError):
|
||||
merged[k] = v
|
||||
return merged
|
||||
|
||||
|
||||
def sanitize_task_id(task_id: str) -> str:
|
||||
"""防止路径注入:仅允许字母数字、下划线、连字符、点号。"""
|
||||
if not re.fullmatch(r"[A-Za-z0-9_\-\.]+", task_id):
|
||||
raise ValueError(f"invalid task_id: {task_id!r}")
|
||||
return task_id
|
||||
|
||||
|
||||
def sanitize_marker(marker: str) -> str:
|
||||
"""防止 HTML 注释注入。"""
|
||||
if "-->" in marker or "<!--" in marker:
|
||||
raise ValueError(f"marker contains comment delimiters: {marker!r}")
|
||||
return marker
|
||||
|
||||
|
||||
def truncate_history(data: list, max_items: int = 100) -> list:
|
||||
"""截断历史列表,防止无界增长(P2-2)。"""
|
||||
return data[-max_items:] if len(data) > max_items else data
|
||||
157
lib/air_runtime/worktree.py
Normal file
157
lib/air_runtime/worktree.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Worktree 隔离并行模块 — V2 P1-15 修复。
|
||||
区域级冲突检测 + git worktree 隔离,允许同文件不同区域任务安全并行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ConflictLevel(enum.Enum):
|
||||
NONE = "none" # 无文件重叠,直接并行
|
||||
SOFT = "soft" # 同文件不同区域,worktree 隔离并行
|
||||
HARD = "hard" # 同文件同区域,必须串行
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conflict:
|
||||
task_a: str
|
||||
task_b: str
|
||||
level: ConflictLevel # NONE / SOFT / HARD
|
||||
reason: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"taskA": self.task_a,
|
||||
"taskB": self.task_b,
|
||||
"level": self.level.value,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergeResult:
|
||||
task_id: str
|
||||
successful: bool
|
||||
conflicts: list[str]
|
||||
|
||||
|
||||
class RegionConflictDetector:
|
||||
"""区域级写集冲突检测。
|
||||
|
||||
区域定义(按优先级):
|
||||
1. todo.md 中的区域标注 [region: xxx]
|
||||
2. 函数/类边界(AST 分析或标记注释)
|
||||
3. 行号区间(从 diff 或任务元数据获取)
|
||||
"""
|
||||
|
||||
def detect(self, task_a_write_set: list[str], task_b_write_set: list[str],
|
||||
regions_a: dict[str, list[tuple[int, int]]] | None = None,
|
||||
regions_b: dict[str, list[tuple[int, int]]] | None = None) -> ConflictLevel:
|
||||
file_overlap = set(task_a_write_set) & set(task_b_write_set)
|
||||
if not file_overlap:
|
||||
return ConflictLevel.NONE
|
||||
|
||||
if regions_a is None or regions_b is None:
|
||||
return ConflictLevel.SOFT
|
||||
|
||||
for fpath in file_overlap:
|
||||
ra = regions_a.get(fpath, [(0, 999999)])
|
||||
rb = regions_b.get(fpath, [(0, 999999)])
|
||||
for a_start, a_end in ra:
|
||||
for b_start, b_end in rb:
|
||||
if a_start < b_end and b_start < a_end:
|
||||
return ConflictLevel.HARD
|
||||
return ConflictLevel.SOFT
|
||||
|
||||
def detect_batch(self, task_write_sets: dict[str, list[str]],
|
||||
task_regions: dict[str, dict[str, list[tuple[int, int]]]] | None = None,
|
||||
) -> list[Conflict]:
|
||||
"""批量检测:接收 {task_id: [file, ...]} 映射,返回所有冲突对。
|
||||
|
||||
逐对调用 detect(),仅返回 level != NONE 的冲突。
|
||||
"""
|
||||
conflicts: list[Conflict] = []
|
||||
task_ids = list(task_write_sets.keys())
|
||||
for i in range(len(task_ids)):
|
||||
for j in range(i + 1, len(task_ids)):
|
||||
a, b = task_ids[i], task_ids[j]
|
||||
ra = task_regions.get(a) if task_regions else None
|
||||
rb = task_regions.get(b) if task_regions else None
|
||||
level = self.detect(task_write_sets[a], task_write_sets[b], ra, rb)
|
||||
if level != ConflictLevel.NONE:
|
||||
overlap = sorted(set(task_write_sets[a]) & set(task_write_sets[b]))
|
||||
reason = f"file overlap: {', '.join(overlap)}" if overlap else ""
|
||||
if level == ConflictLevel.HARD:
|
||||
reason = f"region overlap: {', '.join(overlap)}" if overlap else "same region"
|
||||
conflicts.append(Conflict(task_a=a, task_b=b, level=level, reason=reason))
|
||||
return conflicts
|
||||
|
||||
def extract_regions_from_task(self, file_path: Path, region_markers: list[tuple[int, int]]) -> dict[str, list[tuple[int, int]]]:
|
||||
"""从任务的区域标注提取行号区间。"""
|
||||
return {str(file_path): region_markers}
|
||||
|
||||
|
||||
class WorktreeIsolation:
|
||||
"""为 SOFT 冲突任务创建 git worktree 隔离,完成后合并回主分支。"""
|
||||
|
||||
def __init__(self, repo_root: Path):
|
||||
self._repo_root = repo_root
|
||||
|
||||
def create_worktree(self, task_id: str, base_ref: str = "HEAD") -> Path:
|
||||
branch = f"air-{task_id}"
|
||||
wt_path = self._repo_root.parent / f"{self._repo_root.name}-air-{task_id}"
|
||||
|
||||
subprocess.run(
|
||||
["git", "-C", str(self._repo_root), "worktree", "add", "-b", branch, str(wt_path), base_ref],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
return wt_path
|
||||
|
||||
def merge_back(self, task_id: str, wt_path: Path) -> MergeResult:
|
||||
branch = f"air-{task_id}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(self._repo_root), "merge", "--no-ff", branch],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
return MergeResult(task_id=task_id, successful=True, conflicts=[])
|
||||
except subprocess.CalledProcessError as exc:
|
||||
conflicts = self._parse_conflicts(exc.stderr)
|
||||
self._abort_merge()
|
||||
return MergeResult(task_id=task_id, successful=False, conflicts=conflicts)
|
||||
|
||||
def cleanup(self, task_id: str, wt_path: Path) -> None:
|
||||
branch = f"air-{task_id}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(self._repo_root), "worktree", "remove", str(wt_path), "--force"],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(self._repo_root), "branch", "-D", branch],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
def _abort_merge(self) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(self._repo_root), "merge", "--abort"],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _parse_conflicts(stderr: str) -> list[str]:
|
||||
conflicts: list[str] = []
|
||||
for line in stderr.splitlines():
|
||||
if "CONFLICT" in line:
|
||||
conflicts.append(line.strip())
|
||||
return conflicts
|
||||
161
lib/air_runtime/xdb_capture.py
Normal file
161
lib/air_runtime/xdb_capture.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""AirXDB screenshot capture backends with automatic fallback chain.
|
||||
|
||||
Three capture methods:
|
||||
- KmsGrabCapture: DRM/KMS native screenshot via ffmpeg (no sudo)
|
||||
- XvfbCapture: Xvfb virtual framebuffer screenshot
|
||||
- FallbackCapture: text placeholder when no GUI capture is available
|
||||
|
||||
CaptureManager tries them in order (kms -> xvfb -> fallback) unless
|
||||
a specific method is requested.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureResult:
|
||||
success: bool
|
||||
output_path: Path | None
|
||||
method: str # kms, xvfb, fallback
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class KmsGrabCapture:
|
||||
"""KMS/DRM screenshot via ffmpeg -f kmsgrab.
|
||||
|
||||
No sudo is ever used. The caller must have read access to
|
||||
/dev/dri/cardX for this to work.
|
||||
"""
|
||||
|
||||
def capture(self, output_path: Path) -> CaptureResult:
|
||||
# 1. Detect whether the current user can access a DRI device
|
||||
dri_dev = Path("/dev/dri/card0")
|
||||
if dri_dev.exists():
|
||||
try:
|
||||
# Test read permission (no sudo)
|
||||
open(dri_dev).close()
|
||||
except PermissionError:
|
||||
return CaptureResult(False, None, "kms", "no /dev/dri permission")
|
||||
else:
|
||||
return CaptureResult(False, None, "kms", "no GPU")
|
||||
|
||||
# 2. Use ffmpeg directly (no sudo)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-f", "kmsgrab", "-i", "-",
|
||||
"-frames:v", "1",
|
||||
"-vf", "hwdownload,format=bgr0",
|
||||
"-f", "image2", str(output_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0 and output_path.exists():
|
||||
return CaptureResult(True, output_path, "kms", None)
|
||||
return CaptureResult(False, None, "kms", result.stderr)
|
||||
except FileNotFoundError:
|
||||
return CaptureResult(False, None, "kms", "ffmpeg not found")
|
||||
except subprocess.TimeoutExpired:
|
||||
return CaptureResult(False, None, "kms", "ffmpeg timed out")
|
||||
except Exception as e:
|
||||
return CaptureResult(False, None, "kms", str(e))
|
||||
|
||||
|
||||
class XvfbCapture:
|
||||
"""Xvfb virtual display screenshot via xvfb-run + scrot."""
|
||||
|
||||
def capture(
|
||||
self,
|
||||
output_path: Path,
|
||||
width: int = 1920,
|
||||
height: int = 1080,
|
||||
) -> CaptureResult:
|
||||
# Check xvfb-run availability
|
||||
if not shutil.which("xvfb-run"):
|
||||
return CaptureResult(False, None, "xvfb", "xvfb-run not found")
|
||||
|
||||
# Use scrot as the screenshot tool inside Xvfb
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"xvfb-run",
|
||||
"--auto-servernum",
|
||||
"--server-args",
|
||||
f"-screen 0 {width}x{height}x24",
|
||||
"--",
|
||||
"scrot",
|
||||
str(output_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode == 0 and output_path.exists():
|
||||
return CaptureResult(True, output_path, "xvfb", None)
|
||||
return CaptureResult(False, None, "xvfb", result.stderr)
|
||||
except FileNotFoundError:
|
||||
return CaptureResult(False, None, "xvfb", "xvfb-run not found")
|
||||
except subprocess.TimeoutExpired:
|
||||
return CaptureResult(False, None, "xvfb", "xvfb-run timed out")
|
||||
except Exception as e:
|
||||
return CaptureResult(False, None, "xvfb", str(e))
|
||||
|
||||
|
||||
class FallbackCapture:
|
||||
"""Fallback: write a text placeholder when no GUI capture is available."""
|
||||
|
||||
def capture(
|
||||
self,
|
||||
output_path: Path,
|
||||
description: str = "",
|
||||
) -> CaptureResult:
|
||||
output_path.write_text(
|
||||
f"[GUI capture not available]\n{description}\n"
|
||||
)
|
||||
return CaptureResult(True, output_path, "fallback", None)
|
||||
|
||||
|
||||
class CaptureManager:
|
||||
"""Auto-select the best capture method with fallback chain.
|
||||
|
||||
Order: kms -> xvfb -> fallback.
|
||||
Use ``prefer`` to force a specific method or "auto" for the chain.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.kms = KmsGrabCapture()
|
||||
self.xvfb = XvfbCapture()
|
||||
self.fallback = FallbackCapture()
|
||||
|
||||
def capture(
|
||||
self,
|
||||
output_path: Path,
|
||||
prefer: str = "auto",
|
||||
) -> CaptureResult:
|
||||
"""
|
||||
prefer: "kms" | "xvfb" | "fallback" | "auto"
|
||||
auto mode tries kms -> xvfb -> fallback in order.
|
||||
"""
|
||||
if prefer in ("kms", "auto"):
|
||||
result = self.kms.capture(output_path)
|
||||
if result.success:
|
||||
return result
|
||||
if prefer == "kms":
|
||||
return result # forced kms failed, return failure
|
||||
|
||||
if prefer in ("xvfb", "auto"):
|
||||
result = self.xvfb.capture(output_path)
|
||||
if result.success:
|
||||
return result
|
||||
if prefer == "xvfb":
|
||||
return result # forced xvfb failed, return failure
|
||||
|
||||
# fallback
|
||||
return self.fallback.capture(output_path, "all capture methods failed")
|
||||
Reference in New Issue
Block a user