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"(? 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