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>
This commit is contained in:
817
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/airxdb_runtime.py
Executable file
817
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/airxdb_runtime.py
Executable file
@@ -0,0 +1,817 @@
|
||||
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
|
||||
Reference in New Issue
Block a user