commit e73a4da3549fea54bf57cc4457e9c6c0a4d210b8 Author: AirLongDian Date: Wed Jun 10 17:06:48 2026 +0800 feat: P1-19 P1-20 实现 - 边界测试强制 + 高风险审计 + UI Skill 路由 P1-19.1: Arc 边界测试强制 - TaskNode 新增 test_required 字段 - _inject_boundary_tests() 为每个模块注入接口测试和单元测试任务 - Done When 验证必须包含"测试通过" P1-19.2: AirRvr 高风险审计 - 新增 HighRiskAudit, HighRiskFinding 数据类 - ReviewReport 新增 highRiskAudit 字段,含 lifecycle/nullPointer/danglingPointer/exceptionSafety/concurrency + overallRisk + deliveryVerdict - 序列化/反序列化支持 P1-19.3: block-release 集成 - dispatch_worker_group() 派发前扫描最新审查报告 - deliveryVerdict=block-release 时阻止所有后续派发 - 记录 eng.blocked 事件 P1-20: frontend-design Skill 集成 - is_ui_task() UI 任务检测 - ensure_frontend_design_skill() 自动安装 Skill - route_ui_task() UI 任务路由决策 - enter_worker() 集成 UI 检测,skill 不可用时阻止执行 - commands/do.md 更新 UI 处理说明 - SKILL.md 新增 INV-12/INV-13/INV-14 Co-Authored-By: Claude Sonnet 4.6 diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..8bafe35 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "airplan-v2", + "version": "2.0.0", + "description": "AirPlan V2 — 统一制品驱动开发调度器。12个子模式:arc,eng,do,dbg,xdb,sdb,ndb,ctx,dep,tst,sec,rvr", + "author": { + "name": "AirPlan Team", + "email": "noreply@airlongdian.fun", + "url": "https://airlongdian.fun" + }, + "license": "MIT", + "keywords": [ + "airplan", + "scheduler", + "orchestrator", + "debugger", + "v2" + ], + "skills": [ + "./skills/" + ], + "commands": [ + "./commands/" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be20257 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +.bak +*.tmp +*.lock +.DS_Store \ No newline at end of file diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..ea37bf1 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,10 @@ +--- +name: airplan-v2 +description: TODO — describe WHEN Claude should use this. Include trigger phrases users + might say ("do X", "set up Y", "review Z"). Be specific; this string is what Claude + matches the user's request against. +--- + +# airplan-v2 + +TODO: what this skill does, and the steps Claude should take. diff --git a/commands/airplan.md b/commands/airplan.md new file mode 100644 index 0000000..0a8a013 --- /dev/null +++ b/commands/airplan.md @@ -0,0 +1,46 @@ +--- +description: "AirPlan V2 - unified scheduler with 12 modes" +argument-hint: "[arc|eng|do|dbg|xdb|sdb|ndb|ctx|dep|tst|sec|rvr] [sub-command]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /airplan + +AirPlan V2 - unified scheduler. V1 8 plugins merged into 1, adds 4 components, total 12 sub-modes. + +## Sub-modes + +| Mode | Role | +|------|------| +| arc | Architecture planner | +| eng | Scheduler engine | +| do | Task executor | +| dbg | Debugger | +| xdb | GUI validator | +| sdb | Static analyzer | +| ndb | Network debugger | +| ctx | Context manager | +| dep | Deployer | +| tst | Test runner | +| sec | Security scanner | +| rvr | Requirements reviewer | + +## Usage + +1. Parse $ARGUMENTS; default to "status" when empty. +2. Run from project root - replace MODE with first arg: + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode MODE --project . [OPTIONS] +``` + +Example: /airplan arc -> runs with --mode arc +Example: /airplan eng monitor -> runs with --mode eng --sub monitor + +3. Common patterns: + - /airplan arc parallel-review --todo AirPlan/todo.md + - /airplan eng dispatch + - /airplan eng monitor + - /airplan do finish --task-id T-001 --result /path/to/result.json + +4. Runtime auto-bootstraps missing AirPlan/ files. \ No newline at end of file diff --git a/commands/arc.md b/commands/arc.md new file mode 100644 index 0000000..e5b86b1 --- /dev/null +++ b/commands/arc.md @@ -0,0 +1,49 @@ +--- +description: "AirPlan arc - architecture planner, READ-ONLY, never writes code" +argument-hint: "[enter|status|parallel-review|incremental-replan]" +allowed-tools: "[Read, Glob, Grep]" +deny-plan-mode: true +--- + +# /arc + +AirArc is a pure architecture planner. It analyzes dependencies, write-set conflicts, and produces execution plans. It NEVER writes code, modifies source files, or enters plan mode. + +## Hard Rules (violated = bug) + +1. **READ-ONLY** — you only Read, Glob, Grep. Never Write, Edit, Bash. +2. **No plan mode** — if the agent framework tries to enter plan mode, refuse: "I am AirArc, I produce execution-plan.json, not code changes." +3. **Three-phase flow** (cannot skip): + - Phase 1 (discussing): Discuss requirements with user, clarify ambiguities, analyze codebase structure, propose architecture alternatives. **Forbidden to write execution-plan.json.** + - Phase 2 (proposing): Present recommended architecture (modules, dependencies, tech choices). Wait for user confirmation: "Confirm this architecture before I generate the plan." User objections return to Phase 1. **Forbidden to write execution-plan.json.** + - Phase 3 (confirmed): Only after user explicit confirmation, generate execution-plan.json and task-graph.json. + +## Sub-commands + +### enter + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub enter +``` + +### status + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub status +``` + +### parallel-review + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub parallel-review --todo AirPlan/todo.md +``` + +### incremental-replan + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub incremental-replan --todo AirPlan/todo.md +``` + +## Logging Standard + +When planning C++ projects, the first task MUST be "integrate spdlog" if not already present. All generated code must use spdlog, not std::cout/qDebug/printf. \ No newline at end of file diff --git a/commands/ctx.md b/commands/ctx.md new file mode 100644 index 0000000..17a19f2 --- /dev/null +++ b/commands/ctx.md @@ -0,0 +1,11 @@ +--- +description: "AirPlan ctx mode" +argument-hint: "[enter|status|...]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /ctx + +Run AirPlan ctx mode. + +python "$HOME/plugins/airplan/scripts/airplan.py" --mode ctx --project . [OPTIONS] diff --git a/commands/dbg.md b/commands/dbg.md new file mode 100644 index 0000000..ed669e6 --- /dev/null +++ b/commands/dbg.md @@ -0,0 +1,11 @@ +--- +description: "AirPlan dbg mode" +argument-hint: "[enter|status|...]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /dbg + +Run AirPlan dbg mode. + +python "$HOME/plugins/airplan/scripts/airplan.py" --mode dbg --project . [OPTIONS] diff --git a/commands/dep.md b/commands/dep.md new file mode 100644 index 0000000..75bb3f7 --- /dev/null +++ b/commands/dep.md @@ -0,0 +1,11 @@ +--- +description: "AirPlan dep mode" +argument-hint: "[enter|status|...]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /dep + +Run AirPlan dep mode. + +python "$HOME/plugins/airplan/scripts/airplan.py" --mode dep --project . [OPTIONS] diff --git a/commands/do.md b/commands/do.md new file mode 100644 index 0000000..eb134da --- /dev/null +++ b/commands/do.md @@ -0,0 +1,54 @@ +--- +description: "AirPlan do mode - task executor with UI skill routing" +argument-hint: "[enter|status|finish]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /do + +AirDo is the task executor. It runs individual tasks, enforces routing rules, and integrates with other AirPlan modes. + +## Hard Rules + +1. **UI Task Handling (P1-20)** — When task involves UI/frontend/interface design: + - Must detect UI tasks via keyword matching (gui, ui, render, widget, frontend, react, vue, etc.) + - Must ensure frontend-design Skill is available before executing UI code + - If frontend-design Skill is not installed, block the task + - Use `ensure_frontend_design_skill()` and `route_ui_task()` functions + +2. **Evidence Gate (INV-4)** — Tasks with GUI/network indicators must have corresponding evidence before finishing: + - GUI tasks require screenshot/validation evidence + - Network tasks require packet capture/connectivity evidence + - Code tasks require static analysis/test results + +3. **Force AirDbg Routing (INV-4)** — If task finishes without evidence, force route to AirDbg: + - `done` status + no validations/files_changed → route to airdbg + - `blocked`/`failed` status → always route to airdbg + +## Sub-commands + +### enter + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode do --project . --sub enter --task-id T-001 --task-text "实现登录界面UI" +``` + +### status + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode do --project . --sub status +``` + +### finish + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode do --project . --sub finish --task-id T-001 --result /path/to/result.json +``` + +## UI Task Detection Keywords + +UI task detection uses these keywords (case-insensitive): +- gui, ui, render, layout, dialog, osd, overlay, visual +- widget, pane, toolbar, settings_dialog, canvas, button +- window, popup, menu, drm, kms, opengl, vulkan +- frontend, react, vue, angular, web, css, html \ No newline at end of file diff --git a/commands/eng.md b/commands/eng.md new file mode 100644 index 0000000..281325a --- /dev/null +++ b/commands/eng.md @@ -0,0 +1,87 @@ +--- +description: "AirPlan eng - scheduler engine, dispatches isolated workers, never codes directly" +argument-hint: "[run|status|plan|dispatch|monitor|merge|intervene]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /eng + +AirEng 是调度引擎,不是执行器。它派发隔离 Worker、监控、合并结果。绝不直接实现代码。 + +## 硬规则(违反=bug) + +1. **Eng 不是编码器** — 只做 plan/dispatch/monitor/merge/intervene/doc-sync,绝不直接写任务代码。 +2. **必须通过 /do 派发** — 每个任务必须 spawn 隔离的 /do 子代理(fork_context=false)。 +3. **保持父线程精简** — 父线程只做调度操作。 +4. **直接写代码唯一例外** — Worker 硬阻塞无法自恢复时的紧急干预,干预后立即回到调度模式。 +5. **必须使用中文** — 所有状态报告、进度通知、问题描述均使用中文。禁止英文输出。 +6. **自主决策原则** — 以推进开发进度为第一目标,以下情况自行决策不停下来问用户: + - Worker blocked 但修复预算未耗尽 → 自行派发修复 + - Worker 停滞 → 自行执行停滞干预 + - 验证失败但非关键 → 记录问题继续下一任务 + - 波次间衔接 → 自行启动下一波次 +7. **仅以下情况才询问用户**:修复预算耗尽且任务仍 blocked;需求歧义无法继续;系统资源耗尽;用户显式暂停。 + +## 子命令 + +### run (默认) — 一次完整调度步骤 + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub status +``` + +然后: +- 无活跃波次 → 派发下一波次 +- 有活跃 Worker → 监控,不要盲目重新派发 +- 每个 dispatched 任务 spawn 一个 /do 子代理(fork_context=false) +- 只传递项目路径+任务 handoff 内容,不要 fork 完整父对话 +- 最多 recommendedConcurrency 个 Worker 同时活跃 +- 就绪结果出现时:merge through `--sub merge --result ` + +### status + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub status +``` + +### plan + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub plan --todo AirPlan/todo.md +``` + +### dispatch + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub dispatch +``` + +读取 AirPlan/state/aireng/dispatch/ 中的派发清单,为每个任务 spawn 一个隔离的 /do Worker 子代理。 + +### monitor + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub monitor +``` + +合并就绪结果,检测停滞 Worker,更新 nextAction。 + +### merge + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub merge --result +``` + +### intervene + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub intervene +``` + +仅用于无法通过常规监控或重新派发解决的硬阻塞。 + +## 无人值守模式 + +- 每 300 秒重新检查活跃 Worker +- 当前波次收敛后自动派发下一波次 +- 仅在状态达到 completed 或用户决策阻塞时停止 \ No newline at end of file diff --git a/commands/ndb.md b/commands/ndb.md new file mode 100644 index 0000000..11e89aa --- /dev/null +++ b/commands/ndb.md @@ -0,0 +1,11 @@ +--- +description: "AirPlan ndb mode" +argument-hint: "[enter|status|...]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /ndb + +Run AirPlan ndb mode. + +python "$HOME/plugins/airplan/scripts/airplan.py" --mode ndb --project . [OPTIONS] diff --git a/commands/rvr.md b/commands/rvr.md new file mode 100644 index 0000000..0267a2a --- /dev/null +++ b/commands/rvr.md @@ -0,0 +1,11 @@ +--- +description: "AirPlan rvr mode" +argument-hint: "[enter|status|...]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /rvr + +Run AirPlan rvr mode. + +python "$HOME/plugins/airplan/scripts/airplan.py" --mode rvr --project . [OPTIONS] diff --git a/commands/sdb.md b/commands/sdb.md new file mode 100644 index 0000000..1f4c906 --- /dev/null +++ b/commands/sdb.md @@ -0,0 +1,11 @@ +--- +description: "AirPlan sdb mode" +argument-hint: "[enter|status|...]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /sdb + +Run AirPlan sdb mode. + +python "$HOME/plugins/airplan/scripts/airplan.py" --mode sdb --project . [OPTIONS] diff --git a/commands/sec.md b/commands/sec.md new file mode 100644 index 0000000..50f54e2 --- /dev/null +++ b/commands/sec.md @@ -0,0 +1,11 @@ +--- +description: "AirPlan sec mode" +argument-hint: "[enter|status|...]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /sec + +Run AirPlan sec mode. + +python "$HOME/plugins/airplan/scripts/airplan.py" --mode sec --project . [OPTIONS] diff --git a/commands/tst.md b/commands/tst.md new file mode 100644 index 0000000..79a549d --- /dev/null +++ b/commands/tst.md @@ -0,0 +1,11 @@ +--- +description: "AirPlan tst mode" +argument-hint: "[enter|status|...]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /tst + +Run AirPlan tst mode. + +python "$HOME/plugins/airplan/scripts/airplan.py" --mode tst --project . [OPTIONS] diff --git a/commands/xdb.md b/commands/xdb.md new file mode 100644 index 0000000..7e3b763 --- /dev/null +++ b/commands/xdb.md @@ -0,0 +1,11 @@ +--- +description: "AirPlan xdb mode" +argument-hint: "[enter|status|...]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /xdb + +Run AirPlan xdb mode. + +python "$HOME/plugins/airplan/scripts/airplan.py" --mode xdb --project . [OPTIONS] diff --git a/lib/air_runtime/__init__.py b/lib/air_runtime/__init__.py new file mode 100644 index 0000000..3567c45 --- /dev/null +++ b/lib/air_runtime/__init__.py @@ -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" diff --git a/lib/air_runtime/contracts.py b/lib/air_runtime/contracts.py new file mode 100644 index 0000000..1506585 --- /dev/null +++ b/lib/air_runtime/contracts.py @@ -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 diff --git a/lib/air_runtime/deploy_runtime.py b/lib/air_runtime/deploy_runtime.py new file mode 100644 index 0000000..1ff7524 --- /dev/null +++ b/lib/air_runtime/deploy_runtime.py @@ -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)) diff --git a/lib/air_runtime/events.py b/lib/air_runtime/events.py new file mode 100644 index 0000000..1cc1543 --- /dev/null +++ b/lib/air_runtime/events.py @@ -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 diff --git a/lib/air_runtime/evidence_gate.py b/lib/air_runtime/evidence_gate.py new file mode 100644 index 0000000..4201124 --- /dev/null +++ b/lib/air_runtime/evidence_gate.py @@ -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"]) diff --git a/lib/air_runtime/installer.py b/lib/air_runtime/installer.py new file mode 100644 index 0000000..f39cbfa --- /dev/null +++ b/lib/air_runtime/installer.py @@ -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(), + } diff --git a/lib/air_runtime/io.py b/lib/air_runtime/io.py new file mode 100644 index 0000000..fbbd693 --- /dev/null +++ b/lib/air_runtime/io.py @@ -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 diff --git a/lib/air_runtime/lock.py b/lib/air_runtime/lock.py new file mode 100644 index 0000000..a90185d --- /dev/null +++ b/lib/air_runtime/lock.py @@ -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 diff --git a/lib/air_runtime/modes/__init__.py b/lib/air_runtime/modes/__init__.py new file mode 100644 index 0000000..fa3c9fb --- /dev/null +++ b/lib/air_runtime/modes/__init__.py @@ -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", +] diff --git a/lib/air_runtime/modes/arc_mode.py b/lib/air_runtime/modes/arc_mode.py new file mode 100644 index 0000000..1fe716e --- /dev/null +++ b/lib/air_runtime/modes/arc_mode.py @@ -0,0 +1,339 @@ +#!/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, "testRequired": n.test_required} + 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) -> tuple[TaskGraph, list[str]]: + """从 todo.md 构建初始 DAG,返回图和未满足 Done When 条件的任务列表。""" + from air_runtime.todo_parser import parse_tasks + tasks = parse_tasks(todo_path) + graph = TaskGraph() + violations = [] # P1-19.1: 记录 Done When 不含"测试通过"的任务 + + 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) + + # P1-19.1: Done When 必须包含"测试通过" + if t.done_when and "测试通过" not in t.done_when: + violations.append(t.task_id) + + # P1-19.1: 注入边界测试任务 + _inject_boundary_tests(graph, tasks) + + return graph, violations + + +def _inject_boundary_tests(graph: TaskGraph, tasks: list) -> None: + """P1-19.1: 为每个模块边界注入测试任务。 + + 规则: + - 每个模块的公共接口必须有对应的接口测试任务 + - 每个模块的核心逻辑必须有对应的单元测试任务 + - 测试任务标记 test_required=True + - Done When 必须包含"测试通过" + """ + # 从 tasks 提取模块信息(通过 files_dirs 推断模块) + module_files: dict[str, set[str]] = {} + for t in tasks: + if t.files_dirs: + for fd in t.files_dirs.split(","): + fd = fd.strip() + if fd: + # 取第一级目录作为模块名 + parts = fd.split("/") + if len(parts) > 1: + module = parts[0] + else: + module = fd.split(".")[0] if "." in fd else fd + module_files.setdefault(module, set()).add(fd) + + # 为每个模块创建测试任务 + test_counter = 0 + for module, files in module_files.items(): + if not files: + continue + + # 接口测试任务(模块边界) + test_counter += 1 + interface_test_id = f"T-TEST-{test_counter:03d}" + interface_test_node = TaskNode( + id=interface_test_id, + status="TODO", + task=f"[边界测试] {module} 模块接口测试", + files_dirs=",".join(sorted(files)), + done_when="测试通过", + test_required=True, + ) + graph.add_node(interface_test_node) + + # 单元测试任务(核心逻辑) + test_counter += 1 + unit_test_id = f"T-TEST-{test_counter:03d}" + unit_test_node = TaskNode( + id=unit_test_id, + status="TODO", + task=f"[单元测试] {module} 模块核心逻辑测试", + files_dirs=",".join(sorted(files)), + done_when="测试通过", + test_required=True, + ) + graph.add_node(unit_test_node) + + # 添加依赖边:实现任务 → 测试任务 + for tid, node in graph.nodes.items(): + if tid.startswith("T-TEST-"): + continue + # 检查是否属于同一模块 + node_files = set(node.files_dirs.split(",")) if node.files_dirs else set() + if node_files & files: # 有交集,说明是同一模块的任务 + graph.add_edge(Edge(source=tid, target=interface_test_id, kind="dependency")) + graph.add_edge(Edge(source=tid, target=unit_test_id, kind="dependency")) + + +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, done_when_violations = _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, + "doneWhenViolations": done_when_violations, # P1-19.1: Done When 不含"测试通过"的任务 + "boundaryTestTasks": [n.id for n in graph.nodes.values() if n.test_required], + } + 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), + "done_when_violations": done_when_violations, + "boundary_test_task_count": len([n for n in graph.nodes.values() if n.test_required])} + + +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)}") diff --git a/lib/air_runtime/modes/ctx_mode.py b/lib/air_runtime/modes/ctx_mode.py new file mode 100644 index 0000000..a1d26d5 --- /dev/null +++ b/lib/air_runtime/modes/ctx_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/dbg_mode.py b/lib/air_runtime/modes/dbg_mode.py new file mode 100644 index 0000000..cb74689 --- /dev/null +++ b/lib/air_runtime/modes/dbg_mode.py @@ -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 + 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)}") diff --git a/lib/air_runtime/modes/dep_mode.py b/lib/air_runtime/modes/dep_mode.py new file mode 100644 index 0000000..5418de8 --- /dev/null +++ b/lib/air_runtime/modes/dep_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/do_mode.py b/lib/air_runtime/modes/do_mode.py new file mode 100644 index 0000000..48b2318 --- /dev/null +++ b/lib/air_runtime/modes/do_mode.py @@ -0,0 +1,255 @@ +""" +AirDo mode — V2 任务执行器。 +V2 改进:强制 AirDbg 路由(L1 代码级),task_id 注入防护,UI 任务 frontend-design Skill 路由(P1-20)。 +""" + +from __future__ import annotations + +import subprocess +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 + + +# P1-20: UI 任务检测关键词 +UI_TASK_INDICATORS = ( + "gui", "ui", "render", "layout", "dialog", "osd", + "overlay", "visual", "screenshot", "display", + "widget", "pane", "toolbar", "settings_dialog", + "canvas", "button", "window", "popup", "menu", + "drm", "kms", "opengl", "vulkan", "frontend", + "react", "vue", "angular", "web", "css", "html", +) + + +def is_ui_task(task_text: str) -> bool: + """P1-20: 检测任务是否涉及 UI/前端界面设计。""" + text = task_text.lower() + return any(kw in text for kw in UI_TASK_INDICATORS) + + +def ensure_frontend_design_skill() -> bool: + """P1-20: 检测 frontend-design Skill 是否存在,不存在则尝试自动安装。""" + # 检查 skill 是否已安装(检查 ~/.claude/skills/frontend-design 或类似路径) + import os + home = Path.home() + skill_path = home / ".claude" / "skills" / "frontend-design" + if skill_path.exists(): + return True + + # 尝试自动安装 + import logging + logging.info("frontend-design skill not found, attempting auto-install...") + try: + result = subprocess.run( + ["claude", "plugin", "install", "frontend-design"], + capture_output=True, text=True, timeout=60, + ) + if result.returncode == 0: + logging.info("frontend-design skill installed successfully") + return True + logging.warning("frontend-design skill install failed: %s", result.stderr) + except Exception as e: + logging.warning("frontend-design skill install error: %s", e) + + return False + + +def route_ui_task(task_text: str, task_id: str) -> dict: + """P1-20: UI 任务路由决策。检测 UI 任务并确保 frontend-design Skill 可用。""" + if not is_ui_task(task_text): + return {"target": "execute", "skill": None, "is_ui_task": False} + + # 是 UI 任务,检查 skill 可用性 + if ensure_frontend_design_skill(): + return {"target": "execute", "skill": "frontend-design", "is_ui_task": True} + + # Skill 不可用,阻止任务 + return { + "target": "blocked", + "reason": "UI task requires frontend-design skill but installation failed", + "skill": "frontend-design", + "is_ui_task": True, + } + + +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, task_text: str = "") -> dict: + """P1-20: 新增 task_text 参数用于 UI 任务检测。""" + tid = sanitize_task_id(task_id) + paths = _paths(project_root, tid) + _ensure_dirs(paths) + + # P1-20: UI 任务检测和路由 + ui_routing = {"target": "execute", "skill": None, "is_ui_task": False} + if task_text: + ui_routing = route_ui_task(task_text, tid) + + if ui_routing.get("target") == "blocked": + # UI 任务但 skill 不可用,阻止执行 + worker_state = { + "taskId": tid, "status": "blocked", + "enteredAt": now_iso(), "resultPath": str(paths["result"]), + "blockReason": ui_routing.get("reason", "frontend-design skill unavailable"), + "uiRouting": ui_routing, + } + atomic_json_write(paths["worker_state"], worker_state) + atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid, + "updatedAt": now_iso(), "blocked": True}) + log = EventLog(event_log_path(project_root)) + log.emit("task.blocked", {"taskId": tid, "reason": ui_routing.get("reason")}) + return { + "taskId": tid, "status": "blocked", + "blockReason": ui_routing.get("reason"), + "uiRouting": ui_routing, + } + + worker_state = { + "taskId": tid, "status": "implementing", + "enteredAt": now_iso(), "resultPath": str(paths["result"]), + "uiRouting": ui_routing, + } + 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, "uiRouting": ui_routing}) + + return { + "taskId": tid, "briefPath": str(paths["brief"]), + "handoffPath": str(paths["handoff"]), + "resultPath": str(paths["result"]), + "workerStatePath": str(paths["worker_state"]), + "uiRouting": ui_routing, + } + + +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']}") diff --git a/lib/air_runtime/modes/eng_mode.py b/lib/air_runtime/modes/eng_mode.py new file mode 100644 index 0000000..839182e --- /dev/null +++ b/lib/air_runtime/modes/eng_mode.py @@ -0,0 +1,606 @@ +""" +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) + + # P1-19.3: 检查是否有 block-release verdict,阻止所有后续派发 + from air_runtime.review_runtime import ReviewRuntime + rvr = ReviewRuntime(project_root) + # 扫描最新的审查报告,检查是否有 block-release + rvr_state = rvr._state_dir / "reports" + block_release_found = False + latest_verdict = "safe-to-ship" + if rvr_state.exists(): + for report_file in sorted(rvr_state.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:10]: + report_data = safe_json_load(report_file) + if report_data: + dv = report_data.get("highRiskAudit", {}).get("deliveryVerdict", "safe-to-ship") + if dv == "block-release": + block_release_found = True + latest_verdict = dv + break + elif dv == "needs-fix": + latest_verdict = dv + if block_release_found: + log = EventLog(event_log_path(project_root)) + log.emit("eng.blocked", {"reason": "block-release verdict from review", "verdict": latest_verdict}) + return { + "blocked": True, + "reason": "block-release verdict from AirRvr review - all dispatches halted", + "deliveryVerdict": latest_verdict, + "waveId": "", + "taskIds": [], + } + + 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']}") diff --git a/lib/air_runtime/modes/eng_orchestrator.py b/lib/air_runtime/modes/eng_orchestrator.py new file mode 100644 index 0000000..81da883 --- /dev/null +++ b/lib/air_runtime/modes/eng_orchestrator.py @@ -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']}") diff --git a/lib/air_runtime/modes/merge_pipeline.py b/lib/air_runtime/modes/merge_pipeline.py new file mode 100644 index 0000000..e842938 --- /dev/null +++ b/lib/air_runtime/modes/merge_pipeline.py @@ -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*") + + +def _strip_merged_refs(row: str) -> str: + """去除行内所有已存在的 引用,避免重复 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" " + + 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 diff --git a/lib/air_runtime/modes/ndb_mode.py b/lib/air_runtime/modes/ndb_mode.py new file mode 100644 index 0000000..79c47f5 --- /dev/null +++ b/lib/air_runtime/modes/ndb_mode.py @@ -0,0 +1 @@ +from air_runtime.modes.xdb_sdb_ndb_modes import ndb_main as main diff --git a/lib/air_runtime/modes/rvr_mode.py b/lib/air_runtime/modes/rvr_mode.py new file mode 100644 index 0000000..02c4a3f --- /dev/null +++ b/lib/air_runtime/modes/rvr_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/sdb_mode.py b/lib/air_runtime/modes/sdb_mode.py new file mode 100644 index 0000000..9adc98a --- /dev/null +++ b/lib/air_runtime/modes/sdb_mode.py @@ -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}") diff --git a/lib/air_runtime/modes/sec_mode.py b/lib/air_runtime/modes/sec_mode.py new file mode 100644 index 0000000..8778faa --- /dev/null +++ b/lib/air_runtime/modes/sec_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/tst_mode.py b/lib/air_runtime/modes/tst_mode.py new file mode 100644 index 0000000..97cd6bd --- /dev/null +++ b/lib/air_runtime/modes/tst_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/xdb_mode.py b/lib/air_runtime/modes/xdb_mode.py new file mode 100644 index 0000000..a35e781 --- /dev/null +++ b/lib/air_runtime/modes/xdb_mode.py @@ -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}") \ No newline at end of file diff --git a/lib/air_runtime/modes/xdb_sdb_ndb_modes.py b/lib/air_runtime/modes/xdb_sdb_ndb_modes.py new file mode 100644 index 0000000..3b5b441 --- /dev/null +++ b/lib/air_runtime/modes/xdb_sdb_ndb_modes.py @@ -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)}") diff --git a/lib/air_runtime/paths.py b/lib/air_runtime/paths.py new file mode 100644 index 0000000..00834c2 --- /dev/null +++ b/lib/air_runtime/paths.py @@ -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", + ] diff --git a/lib/air_runtime/project_bootstrap.py b/lib/air_runtime/project_bootstrap.py new file mode 100644 index 0000000..1e31d45 --- /dev/null +++ b/lib/air_runtime/project_bootstrap.py @@ -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} \ No newline at end of file diff --git a/lib/air_runtime/review.py b/lib/air_runtime/review.py new file mode 100644 index 0000000..aec1713 --- /dev/null +++ b/lib/air_runtime/review.py @@ -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) \ No newline at end of file diff --git a/lib/air_runtime/review_runtime.py b/lib/air_runtime/review_runtime.py new file mode 100644 index 0000000..4f8a3c0 --- /dev/null +++ b/lib/air_runtime/review_runtime.py @@ -0,0 +1,195 @@ +""" +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 HighRiskFinding: + """P1-19.2: 高风险审计发现项。""" + file: str + line: int + severity: str # critical | high | medium | low + issue: str + + +@dataclass +class HighRiskAudit: + """P1-19.2: 高风险审计报告结构。""" + lifecycle: list[HighRiskFinding] = field(default_factory=list) + nullPointer: list[HighRiskFinding] = field(default_factory=list) + danglingPointer: list[HighRiskFinding] = field(default_factory=list) + exceptionSafety: list[HighRiskFinding] = field(default_factory=list) + concurrency: list[HighRiskFinding] = field(default_factory=list) + overallRisk: str = "low" # critical | high | medium | low + deliveryVerdict: str = "safe-to-ship" # safe-to-ship | needs-fix | block-release + + +@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, + }) + high_risk_audit: HighRiskAudit = field(default_factory=HighRiskAudit) # P1-19.2 + 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": [], "deliveryVerdict": "safe-to-ship"} + report = safe_json_load(report_path) + if not report or not isinstance(report, dict): + return {"verdict": "pass", "reportPath": str(report_path), "residual": [], "deliveryVerdict": "safe-to-ship"} + return { + "verdict": report.get("verdict", "pass"), + "residual": report.get("residual", []), + "reportPath": str(report_path), + "summary": report.get("summary", ""), + "deliveryVerdict": report.get("highRiskAudit", {}).get("deliveryVerdict", "safe-to-ship"), # P1-19.2 + } + + @staticmethod + def _report_to_dict(report: ReviewReport) -> dict: + # P1-19.2: highRiskAudit 序列化 + hra = report.high_risk_audit + high_risk_audit_dict = { + "lifecycle": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.lifecycle], + "nullPointer": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.nullPointer], + "danglingPointer": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.danglingPointer], + "exceptionSafety": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.exceptionSafety], + "concurrency": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.concurrency], + "overallRisk": hra.overallRisk, + "deliveryVerdict": hra.deliveryVerdict, + } + 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, + "highRiskAudit": high_risk_audit_dict, + "recommendations": report.recommendations, + } + + @staticmethod + def _dict_to_report(data: dict) -> ReviewReport: + # P1-19.2: highRiskAudit 反序列化 + hra_data = data.get("highRiskAudit", {}) + high_risk_audit = HighRiskAudit( + lifecycle=[HighRiskFinding(**f) for f in hra_data.get("lifecycle", [])], + nullPointer=[HighRiskFinding(**f) for f in hra_data.get("nullPointer", [])], + danglingPointer=[HighRiskFinding(**f) for f in hra_data.get("danglingPointer", [])], + exceptionSafety=[HighRiskFinding(**f) for f in hra_data.get("exceptionSafety", [])], + concurrency=[HighRiskFinding(**f) for f in hra_data.get("concurrency", [])], + overallRisk=hra_data.get("overallRisk", "low"), + deliveryVerdict=hra_data.get("deliveryVerdict", "safe-to-ship"), + ) + 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", {}), + high_risk_audit=high_risk_audit, + recommendations=data.get("recommendations", []), + ) diff --git a/lib/air_runtime/sdb_backends.py b/lib/air_runtime/sdb_backends.py new file mode 100644 index 0000000..f169428 --- /dev/null +++ b/lib/air_runtime/sdb_backends.py @@ -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: ::: warning: [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): + :: + """ + 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: (,): error TS: + 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: (,): error TS1234: + """ + 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(), +} diff --git a/lib/air_runtime/sec_runtime.py b/lib/air_runtime/sec_runtime.py new file mode 100644 index 0000000..b3ad2a8 --- /dev/null +++ b/lib/air_runtime/sec_runtime.py @@ -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' 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 diff --git a/lib/air_runtime/task_graph.py b/lib/air_runtime/task_graph.py new file mode 100644 index 0000000..0172488 --- /dev/null +++ b/lib/air_runtime/task_graph.py @@ -0,0 +1,222 @@ +""" +动态任务依赖图(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) + test_required: bool = False # P1-19.1: 边界测试强制标记 + + +@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", [])), + test_required=nd.get("testRequired", False), + ) + 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) diff --git a/lib/air_runtime/test_runtime.py b/lib/air_runtime/test_runtime.py new file mode 100644 index 0000000..130ba3b --- /dev/null +++ b/lib/air_runtime/test_runtime.py @@ -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__) diff --git a/lib/air_runtime/todo_parser.py b/lib/air_runtime/todo_parser.py new file mode 100644 index 0000000..c04c1ec --- /dev/null +++ b/lib/air_runtime/todo_parser.py @@ -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 \ No newline at end of file diff --git a/lib/air_runtime/utils.py b/lib/air_runtime/utils.py new file mode 100644 index 0000000..a42ad9b --- /dev/null +++ b/lib/air_runtime/utils.py @@ -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 "