Files
AirCoding/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/airxdb_runtime.py
AirCoding ae44be31d5 chore: push all design docs, V2 plan specs, and current working state
Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2,
AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code
changes across packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 17:12:29 +08:00

818 lines
27 KiB
Python
Executable File

from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Tuple
from .contracts import (
DEFAULT_XDB_POLICY,
TaskRecord,
ValidationRecord,
WorkerResult,
XdbSession,
now_iso,
)
from .paths import (
agents_path,
aireng_root,
airxdb_artifacts_dir,
airxdb_root,
architecture_adr_dir,
architecture_c4_module_path,
gui_debug_log_path,
todo_path,
)
from .todo_parser import find_task, parse_tasks
def _json_load(path: Path) -> Dict[str, object]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8-sig"))
def _json_dump(path: Path, payload: Dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def _ordered_unique(items: Iterable[str]) -> List[str]:
seen = set()
ordered: List[str] = []
for item in items:
cleaned = str(item).strip()
if not cleaned or cleaned in seen:
continue
seen.add(cleaned)
ordered.append(cleaned)
return ordered
def normalize_xdb_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]:
merged = dict(DEFAULT_XDB_POLICY)
if policy:
merged.update(policy)
merged["enabled"] = bool(merged.get("enabled", True))
merged["requireForGuiTasks"] = bool(merged.get("requireForGuiTasks", True))
merged["captureOnBlocked"] = bool(merged.get("captureOnBlocked", True))
merged["captureOnDone"] = bool(merged.get("captureOnDone", True))
merged["preferOriginalAirXDB"] = bool(merged.get("preferOriginalAirXDB", True))
merged["remoteFirstIfConfigured"] = bool(merged.get("remoteFirstIfConfigured", True))
merged["triggerValidationStatuses"] = _ordered_unique(
str(item).lower() for item in list(merged.get("triggerValidationStatuses", []))
)
merged["taskKeywords"] = _ordered_unique(
str(item).lower() for item in list(merged.get("taskKeywords", []))
)
try:
max_sessions = int(merged.get("maxSessionsPerTask", 1) or 1)
except (TypeError, ValueError):
max_sessions = 1
merged["maxSessionsPerTask"] = max(1, max_sessions)
return merged
def load_xdb_policy(project_root: Path) -> Dict[str, object]:
state_path = aireng_root(project_root) / "state.json"
state = _json_load(state_path)
return normalize_xdb_policy(state.get("xdbPolicy", {}))
def merge_xdb_sessions_into_state(
state: Dict[str, object], xdb_sessions: List[XdbSession]
) -> Dict[str, object]:
state["xdbPolicy"] = normalize_xdb_policy(dict(state.get("xdbPolicy", {})))
existing_items = list(state.get("xdbSessions", []))
seen_ids = {
str(item.get("sessionId", "")).strip()
for item in existing_items
if isinstance(item, dict)
}
for session in xdb_sessions:
if session.session_id in seen_ids:
continue
existing_items.append(session.to_dict())
seen_ids.add(session.session_id)
task_counts: Dict[str, int] = {}
for item in existing_items:
task_id = str(item.get("taskId", "")).strip()
if not task_id:
continue
task_counts[task_id] = task_counts.get(task_id, 0) + 1
state["xdbSessions"] = existing_items
state["taskXdbCounts"] = task_counts
if xdb_sessions:
latest = xdb_sessions[-1]
state["lastXdbSessionId"] = latest.session_id
state["lastXdbTaskId"] = latest.task_id
state["lastXdbAt"] = latest.created_at
return state
def _xdb_paths(project_root: Path) -> Dict[str, Path]:
root = airxdb_root(project_root)
return {
"root": root,
"state": root / "state.json",
"requests": root / "requests",
"sessions": root / "sessions",
"artifacts": airxdb_artifacts_dir(project_root),
"gui_debug_log": gui_debug_log_path(project_root),
}
def _airxdb_health(project_root: Path) -> Dict[str, bool]:
paths = _xdb_paths(project_root)
return {
"AirPlan/AGENTS.md": agents_path(project_root).exists(),
"AirPlan/docs/architecture/c4/module.md": architecture_c4_module_path(project_root).exists(),
"AirPlan/docs/architecture/adr": architecture_adr_dir(project_root).exists(),
"gui_debug_log": paths["gui_debug_log"].exists(),
}
def _ensure_xdb_layout(project_root: Path) -> Dict[str, Path]:
paths = _xdb_paths(project_root)
paths["requests"].mkdir(parents=True, exist_ok=True)
paths["sessions"].mkdir(parents=True, exist_ok=True)
paths["artifacts"].mkdir(parents=True, exist_ok=True)
paths["gui_debug_log"].parent.mkdir(parents=True, exist_ok=True)
if not paths["gui_debug_log"].exists():
paths["gui_debug_log"].write_text("# GUI Debug Log\n\n", encoding="utf-8")
return paths
def _resolve_original_airxdb_script(script_name: str) -> Path | None:
candidates = [
Path.home() / "plugins" / "airxdb" / "scripts" / script_name,
Path(r"C:\Users\20392\plugins\airxdb\scripts") / script_name,
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _bootstrap_airxdb(project_root: Path, prefer_original: bool) -> Tuple[str, str, str]:
mode = "runtime-bootstrap"
stdout = ""
error = ""
script_path = (
_resolve_original_airxdb_script("airxdb_mode.py") if prefer_original else None
)
if script_path is not None:
try:
completed = subprocess.run(
[
sys.executable,
str(script_path),
"--mode",
"enter",
"--project",
str(project_root),
],
capture_output=True,
text=True,
check=True,
)
mode = "original-airxdb"
stdout = completed.stdout.strip()
except subprocess.CalledProcessError as exc:
mode = "runtime-fallback"
stdout = exc.stdout.strip()
error = exc.stderr.strip() or str(exc)
paths = _ensure_xdb_layout(project_root)
state = _json_load(paths["state"])
state["enabled"] = True
state["updatedAt"] = now_iso()
state["projectRoot"] = str(project_root)
state["artifactHealth"] = _airxdb_health(project_root)
if stdout:
state["lastBootstrapStdout"] = stdout
if error:
state["lastBootstrapError"] = error
_json_dump(paths["state"], state)
return mode, stdout, error
def list_xdb_sessions(project_root: Path, task_id: str = "") -> List[XdbSession]:
session_dir = _xdb_paths(project_root)["sessions"]
if not session_dir.exists():
return []
sessions: List[XdbSession] = []
for path in sorted(session_dir.glob("*.json")):
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
session_payload = payload.get("xdbSession", payload)
session = XdbSession.from_dict(session_payload)
session.validate()
except (json.JSONDecodeError, TypeError, ValueError):
continue
if task_id and session.task_id != task_id:
continue
sessions.append(session)
sessions.sort(key=lambda item: (item.created_at, item.session_id))
return sessions
def _load_task(project_root: Path, task_id: str) -> TaskRecord | None:
current_todo_path = todo_path(project_root)
if not current_todo_path.exists():
return None
try:
return find_task(parse_tasks(current_todo_path), task_id)
except ValueError:
return None
def _parse_env_file(path: Path) -> Dict[str, str]:
if not path.exists():
return {}
payload: Dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8-sig").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and value:
payload[key] = value
return payload
def _is_remote_configured(project_root: Path) -> bool:
if os.environ.get("AIRXDB_REMOTE_SSH_TARGET", "").strip():
return True
remote_env = airxdb_root(project_root) / "remote-device.env"
return bool(_parse_env_file(remote_env).get("AIRXDB_REMOTE_SSH_TARGET", "").strip())
def task_requires_xdb(
task: TaskRecord | None,
result: WorkerResult | None,
policy: Dict[str, object] | None = None,
) -> bool:
effective_policy = normalize_xdb_policy(policy)
if not bool(effective_policy.get("enabled", True)):
return False
if not bool(effective_policy.get("requireForGuiTasks", True)):
return False
keyword_set = {
str(item).lower().strip()
for item in list(effective_policy.get("taskKeywords", []))
if str(item).strip()
}
search_parts = []
if task is not None:
search_parts.extend(
[
task.module,
task.task,
task.files_dirs,
task.done_when,
task.validation,
task.adr_c4_update,
]
)
if result is not None:
search_parts.extend(
[
result.summary,
" ".join(result.files_changed),
" ".join(result.evidence_paths),
" ".join(result.blockers),
" ".join(item.kind for item in result.validations),
" ".join(item.command for item in result.validations),
]
)
blob = " ".join(search_parts).lower()
for keyword in keyword_set:
pattern = re.compile(rf"(?<![a-z0-9]){re.escape(keyword)}(?![a-z0-9])")
if pattern.search(blob):
return True
return False
def _result_trigger_summary(
task: TaskRecord | None,
result: WorkerResult,
policy: Dict[str, object],
) -> Tuple[bool, List[str], str]:
if not task_requires_xdb(task, result, policy):
return False, [], ""
triggers: List[str] = []
reasons: List[str] = []
if result.status == "done" and bool(policy.get("captureOnDone", True)):
triggers.append("done-acceptance")
reasons.append("GUI acceptance requires AirXDB evidence before closure")
if result.status == "blocked" and bool(policy.get("captureOnBlocked", True)):
triggers.append("blocked-visual")
reasons.append("blocked GUI result should capture current screen evidence")
tracked_validation_statuses = {
str(item).lower() for item in list(policy.get("triggerValidationStatuses", []))
}
failed_validations = [
item
for item in result.validations
if item.status.lower() in tracked_validation_statuses
]
if failed_validations:
triggers.append("validation-failure")
reasons.append(
"GUI-related validation failures: "
+ ", ".join(f"{item.kind}:{item.status}" for item in failed_validations)
)
return bool(triggers), triggers, "; ".join(reasons)
def _session_stamp() -> str:
return now_iso().replace(":", "-").replace(".", "-").replace("+", "-")
def _session_trigger_set(session: XdbSession) -> set[str]:
return {
item.strip()
for item in session.trigger.split(",")
if item.strip()
}
def _session_matches_triggers(session: XdbSession, triggers: List[str]) -> bool:
if not triggers:
return False
trigger_set = set(triggers)
return trigger_set.issubset(_session_trigger_set(session))
def _parse_kv_output(stdout: str) -> Dict[str, str]:
payload: Dict[str, str] = {}
for raw_line in stdout.splitlines():
line = raw_line.strip()
if not line or "=" not in line:
continue
key, value = line.split("=", 1)
payload[key.strip()] = value.strip()
return payload
def _load_report_payload(report_path: Path) -> Dict[str, object]:
if not report_path or not report_path.exists() or not report_path.is_file():
return {}
try:
return json.loads(report_path.read_text(encoding="utf-8-sig"))
except json.JSONDecodeError:
return {}
def _extract_report_images(report_payload: Dict[str, object]) -> List[str]:
images: List[str] = []
screenshot = str(report_payload.get("screenshot", "")).strip()
if screenshot:
images.append(screenshot)
for step in list(report_payload.get("steps", [])):
if not isinstance(step, dict):
continue
for image in list(step.get("images", [])):
cleaned = str(image).strip()
if cleaned:
images.append(cleaned)
return _ordered_unique(images)
def _capture_airxdb(
project_root: Path, policy: Dict[str, object]
) -> Tuple[str, str, Dict[str, str], str, str]:
prefer_original = bool(policy.get("preferOriginalAirXDB", True))
output_dir = _xdb_paths(project_root)["artifacts"]
remote_first = bool(policy.get("remoteFirstIfConfigured", True)) and _is_remote_configured(
project_root
)
def run_capture(script_name: str, mode: str, remote_mode: bool) -> Tuple[str, str, Dict[str, str], str, str]:
script_path = _resolve_original_airxdb_script(script_name) if prefer_original else None
if script_path is None:
return (
mode,
"failed",
{},
"",
f"AirXDB helper script not found: {script_name}",
)
command = [
sys.executable,
str(script_path),
"--project",
str(project_root),
"--output-dir",
str(output_dir),
"--action",
"screenshot",
]
completed = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
stdout = completed.stdout.strip()
stderr = completed.stderr.strip()
parsed = _parse_kv_output(stdout)
status_key = "airxdb_remote_status" if remote_mode else "airxdb_smoke"
status = parsed.get(status_key, "")
if not status:
status = "ok" if completed.returncode == 0 else "failed"
if completed.returncode != 0 and status == "ok":
status = "failed"
return mode, status, parsed, stdout, stderr
if not remote_first:
return run_capture("airxdb_computer_mcp_smoke.py", "local-screenshot", False)
remote_attempt = run_capture("airxdb_remote_device.py", "remote-screenshot", True)
if remote_attempt[1] == "ok":
return remote_attempt
local_attempt = run_capture("airxdb_computer_mcp_smoke.py", "local-screenshot", False)
if local_attempt[1] == "ok":
parsed = dict(local_attempt[2])
parsed["fallbackFrom"] = remote_attempt[0]
parsed["fallbackRemoteStdout"] = remote_attempt[3]
parsed["fallbackRemoteError"] = remote_attempt[4]
return (
"remote-fallback-local",
local_attempt[1],
parsed,
"\n".join(
part
for part in [
f"[remote-attempt]\n{remote_attempt[3]}",
f"[local-attempt]\n{local_attempt[3]}",
]
if part.strip()
),
"\n".join(
part
for part in [
f"[remote-attempt]\n{remote_attempt[4]}",
f"[local-attempt]\n{local_attempt[4]}",
]
if part.strip()
),
)
parsed = dict(remote_attempt[2])
parsed["fallbackLocalStdout"] = local_attempt[3]
parsed["fallbackLocalError"] = local_attempt[4]
return (
"remote-fallback-local",
"failed",
parsed,
"\n".join(
part
for part in [
f"[remote-attempt]\n{remote_attempt[3]}",
f"[local-attempt]\n{local_attempt[3]}",
]
if part.strip()
),
"\n".join(
part
for part in [
f"[remote-attempt]\n{remote_attempt[4]}",
f"[local-attempt]\n{local_attempt[4]}",
]
if part.strip()
),
)
def _append_gui_debug_log(
gui_debug_log_path: Path,
task: TaskRecord | None,
result: WorkerResult,
session: XdbSession,
bootstrap_mode: str,
) -> None:
target_surface = task.module if task is not None else "unknown"
screenshot_text = ", ".join(f"`{item}`" for item in session.screenshots) or "`none`"
entry_lines = [
f"### {session.created_at}: auto-xdb capture for {result.task_id}",
"",
f"- Target surface: {target_surface}",
f"- Task: `{result.task_id}`",
f"- Source: `{session.source}`",
f"- Trigger: `{session.trigger}`",
f"- Midscene mode: `{session.mode}`",
f"- Symptom: {result.summary}",
"- Expected: GUI acceptance evidence should exist before closure, and blocked GUI paths should keep reproducible screen evidence.",
f"- Actual: worker status `{result.status}`; blockers={', '.join(result.blockers) or 'none'}",
f"- Report path: `{session.report_path or 'n/a'}`",
f"- Screenshots: {screenshot_text}",
f"- AirDbg handoff: linked debug sessions={', '.join(f'`{item.session_id}`' for item in result.debug_sessions) or '`none`'}",
f"- Validation after fix: pending runtime merge; bootstrap mode `{bootstrap_mode}`; xdb status `{session.status}`",
f"- ADR/C4 updates: {'required' if task and task.global_doc_paths else 'none'}",
f"- Residual risk: {'GUI acceptance is not complete until XDB evidence is successful.' if session.status != 'captured' else 'none'}",
"",
]
existing = (
gui_debug_log_path.read_text(encoding="utf-8")
if gui_debug_log_path.exists()
else "# GUI Debug Log\n\n"
)
gui_debug_log_path.write_text(
existing.rstrip() + "\n\n" + "\n".join(entry_lines),
encoding="utf-8",
)
def _update_airxdb_state(
project_root: Path,
session: XdbSession,
bootstrap_mode: str,
bootstrap_stdout: str,
bootstrap_error: str,
capture_stdout: str,
capture_error: str,
) -> None:
paths = _ensure_xdb_layout(project_root)
state = _json_load(paths["state"])
sessions = list(state.get("autoXdbSessions", []))
known_ids = {
str(item.get("sessionId", "")).strip()
for item in sessions
if isinstance(item, dict)
}
if session.session_id not in known_ids:
sessions.append(
{
"sessionId": session.session_id,
"taskId": session.task_id,
"source": session.source,
"trigger": session.trigger,
"status": session.status,
"createdAt": session.created_at,
"requestPath": session.request_path,
"reportPath": session.report_path,
}
)
state["enabled"] = True
state["updatedAt"] = now_iso()
state["projectRoot"] = str(project_root)
state["artifactHealth"] = _airxdb_health(project_root)
state["autoXdbSessions"] = sessions
state["autoXdbSessionCount"] = len(sessions)
state["lastAutoXdbAt"] = session.created_at
state["lastAutoXdbSessionId"] = session.session_id
state["lastBootstrapMode"] = bootstrap_mode
if bootstrap_stdout:
state["lastBootstrapStdout"] = bootstrap_stdout
if bootstrap_error:
state["lastBootstrapError"] = bootstrap_error
if capture_stdout:
state["lastCaptureStdout"] = capture_stdout
if capture_error:
state["lastCaptureError"] = capture_error
_json_dump(paths["state"], state)
def _attach_session_to_result(result: WorkerResult, session: XdbSession, note: str) -> None:
if not any(item.session_id == session.session_id for item in result.xdb_sessions):
result.xdb_sessions.append(session)
for path in [session.request_path, session.gui_debug_log_path, session.state_path, session.report_path]:
if path and path not in result.evidence_paths:
result.evidence_paths.append(path)
for path in session.screenshots:
if path and path not in result.evidence_paths:
result.evidence_paths.append(path)
if note not in result.notes:
result.notes.append(note)
def _session_linked_to_result(result: WorkerResult, session: XdbSession) -> bool:
linked_paths = set(result.evidence_paths)
session_paths = {
session.request_path,
session.report_path,
session.gui_debug_log_path,
session.state_path,
*session.screenshots,
}
return bool(linked_paths.intersection(path for path in session_paths if path))
def _mark_xdb_capture_blocker(result: WorkerResult, reason: str, session: XdbSession | None = None) -> None:
result.status = "blocked"
if reason not in result.blockers:
result.blockers.append(reason)
evidence = []
if session is not None:
evidence.extend(
[
session.request_path,
session.report_path,
session.gui_debug_log_path,
*session.screenshots,
]
)
evidence = [item for item in evidence if item]
if not any(
item.kind == "airxdb-acceptance" and item.status == "failed"
for item in result.validations
):
result.validations.append(
ValidationRecord(
kind="airxdb-acceptance",
status="failed",
command="automatic AirXDB capture",
evidence=evidence,
)
)
note = f"AirXDB acceptance was downgraded to blocked: {reason}"
if note not in result.notes:
result.notes.append(note)
def _latest_successful_session(sessions: List[XdbSession]) -> XdbSession | None:
for session in reversed(sessions):
if session.status == "captured":
return session
return None
def ensure_xdb_sessions_for_result(
project_root: Path,
result: WorkerResult,
source: str,
strict_done: bool = True,
) -> List[XdbSession]:
policy = load_xdb_policy(project_root)
task = _load_task(project_root, result.task_id)
needs_capture, triggers, reason = _result_trigger_summary(task, result, policy)
if not needs_capture:
return result.xdb_sessions
current_matching = [
item for item in result.xdb_sessions if _session_matches_triggers(item, triggers)
]
successful_current = _latest_successful_session(current_matching)
if successful_current is not None:
_attach_session_to_result(
result,
successful_current,
f"Reused current AirXDB session for {result.task_id}: {successful_current.session_id}",
)
return result.xdb_sessions
existing_sessions = list_xdb_sessions(project_root, result.task_id)
matching_existing = [
item for item in existing_sessions if _session_matches_triggers(item, triggers)
]
successful_existing = _latest_successful_session(matching_existing)
if (
source != "worker-finish"
and successful_existing is not None
and _session_linked_to_result(result, successful_existing)
):
_attach_session_to_result(
result,
successful_existing,
f"Reused existing AirXDB session for {result.task_id}: {successful_existing.session_id}",
)
return result.xdb_sessions
session_limit = int(policy.get("maxSessionsPerTask", 1) or 1)
if source != "worker-finish" and len(matching_existing) >= session_limit:
latest_matching = matching_existing[-1] if matching_existing else None
if latest_matching is not None:
if _session_linked_to_result(result, latest_matching):
_attach_session_to_result(
result,
latest_matching,
f"Reused latest matching AirXDB session for {result.task_id}: {latest_matching.session_id}",
)
else:
latest_matching = None
if strict_done and result.status == "done":
_mark_xdb_capture_blocker(
result,
f"AirXDB acceptance evidence is stale or exhausted for {result.task_id}",
latest_matching,
)
return result.xdb_sessions
paths = _ensure_xdb_layout(project_root)
bootstrap_mode, bootstrap_stdout, bootstrap_error = _bootstrap_airxdb(
project_root, bool(policy.get("preferOriginalAirXDB", True))
)
mode, capture_status, parsed, capture_stdout, capture_error = _capture_airxdb(
project_root, policy
)
created_at = now_iso()
session_id = f"{result.task_id}-{_session_stamp()}"
request_path = paths["requests"] / f"{session_id}.json"
session_path = paths["sessions"] / f"{session_id}.json"
report_path = (
Path(str(parsed.get("report", "")).strip()).resolve()
if str(parsed.get("report", "")).strip()
else None
)
report_payload = _load_report_payload(report_path)
screenshots = _extract_report_images(report_payload)
remote_target = parsed.get("target", "")
status = "captured" if capture_status == "ok" else "failed"
session = XdbSession(
session_id=session_id,
task_id=result.task_id,
source=source,
trigger=",".join(triggers),
reason=reason,
request_path=str(request_path),
gui_debug_log_path=str(paths["gui_debug_log"]),
report_path=str(report_path) if report_path is not None else "",
state_path=str(paths["state"]),
mode=mode,
status=status,
screenshots=screenshots,
created_at=created_at,
remote_target=remote_target,
)
_json_dump(
request_path,
{
"taskId": result.task_id,
"source": source,
"trigger": triggers,
"reason": reason,
"requestedAt": created_at,
"bootstrapMode": bootstrap_mode,
"captureMode": mode,
"captureStatus": capture_status,
"captureParsedOutput": parsed,
"bootstrapStdout": bootstrap_stdout,
"bootstrapError": bootstrap_error,
"captureStdout": capture_stdout,
"captureError": capture_error,
"resultSummary": result.summary,
"validationStatuses": [item.to_dict() for item in result.validations],
},
)
_json_dump(
session_path,
{
"xdbSession": session.to_dict(),
"captureReport": report_payload,
},
)
_append_gui_debug_log(paths["gui_debug_log"], task, result, session, bootstrap_mode)
_update_airxdb_state(
project_root,
session,
bootstrap_mode,
bootstrap_stdout,
bootstrap_error,
capture_stdout,
capture_error,
)
_attach_session_to_result(
result,
session,
(
f"AirXDB evidence captured for {result.task_id}: {session.session_id}"
if session.status == "captured"
else f"AirXDB capture failed for {result.task_id}: {session.session_id}"
),
)
if strict_done and result.status == "done" and session.status != "captured":
_mark_xdb_capture_blocker(
result,
f"AirXDB acceptance capture failed for {result.task_id}",
session,
)
return result.xdb_sessions