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:
103
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/__init__.py
Executable file
103
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/__init__.py
Executable file
@@ -0,0 +1,103 @@
|
||||
from .contracts import (
|
||||
DEFAULT_DEBUG_POLICY,
|
||||
DEFAULT_XDB_POLICY,
|
||||
DEFAULT_REPAIR_POLICY,
|
||||
ParallelGroup,
|
||||
ParallelReview,
|
||||
RepairAttempt,
|
||||
ReviewConflict,
|
||||
TaskRecord,
|
||||
ValidationRecord,
|
||||
WorkerResult,
|
||||
XdbSession,
|
||||
default_worker_result,
|
||||
now_iso,
|
||||
)
|
||||
from .airxdb_runtime import (
|
||||
ensure_xdb_sessions_for_result,
|
||||
list_xdb_sessions,
|
||||
load_xdb_policy,
|
||||
merge_xdb_sessions_into_state,
|
||||
normalize_xdb_policy,
|
||||
task_requires_xdb,
|
||||
)
|
||||
from .debug_runtime import (
|
||||
ensure_debug_sessions_for_result,
|
||||
list_debug_sessions,
|
||||
load_debug_policy,
|
||||
merge_debug_sessions_into_state,
|
||||
normalize_debug_policy,
|
||||
)
|
||||
from .repair_runtime import (
|
||||
ensure_repair_attempts_for_result,
|
||||
list_repair_attempts,
|
||||
load_active_repair_attempt,
|
||||
load_repair_policy,
|
||||
merge_repair_attempts_into_state,
|
||||
normalize_repair_policy,
|
||||
summarize_active_repairs,
|
||||
write_repair_queue,
|
||||
)
|
||||
from .engine import (
|
||||
artifact_health,
|
||||
build_engine_plan,
|
||||
enter_engine,
|
||||
merge_worker_result,
|
||||
status_engine,
|
||||
)
|
||||
from .doc_sync import (
|
||||
apply_document_updates,
|
||||
enforce_doc_sync_requirements,
|
||||
sync_engine_managed_docs,
|
||||
update_todo_after_merge,
|
||||
)
|
||||
from .review import build_parallel_review, render_review_markdown
|
||||
from .todo_parser import find_task, parse_tasks
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_DEBUG_POLICY",
|
||||
"DEFAULT_XDB_POLICY",
|
||||
"DEFAULT_REPAIR_POLICY",
|
||||
"ParallelGroup",
|
||||
"ParallelReview",
|
||||
"RepairAttempt",
|
||||
"ReviewConflict",
|
||||
"TaskRecord",
|
||||
"ValidationRecord",
|
||||
"WorkerResult",
|
||||
"XdbSession",
|
||||
"artifact_health",
|
||||
"apply_document_updates",
|
||||
"build_engine_plan",
|
||||
"build_parallel_review",
|
||||
"default_worker_result",
|
||||
"ensure_xdb_sessions_for_result",
|
||||
"ensure_debug_sessions_for_result",
|
||||
"enforce_doc_sync_requirements",
|
||||
"enter_engine",
|
||||
"find_task",
|
||||
"list_xdb_sessions",
|
||||
"list_debug_sessions",
|
||||
"list_repair_attempts",
|
||||
"load_active_repair_attempt",
|
||||
"load_xdb_policy",
|
||||
"load_debug_policy",
|
||||
"load_repair_policy",
|
||||
"merge_xdb_sessions_into_state",
|
||||
"merge_debug_sessions_into_state",
|
||||
"merge_repair_attempts_into_state",
|
||||
"merge_worker_result",
|
||||
"normalize_xdb_policy",
|
||||
"normalize_debug_policy",
|
||||
"normalize_repair_policy",
|
||||
"now_iso",
|
||||
"parse_tasks",
|
||||
"render_review_markdown",
|
||||
"summarize_active_repairs",
|
||||
"sync_engine_managed_docs",
|
||||
"task_requires_xdb",
|
||||
"status_engine",
|
||||
"update_todo_after_merge",
|
||||
"write_repair_queue",
|
||||
"ensure_repair_attempts_for_result",
|
||||
]
|
||||
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
|
||||
617
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/contracts.py
Executable file
617
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/contracts.py
Executable file
@@ -0,0 +1,617 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .paths import required_project_artifacts
|
||||
|
||||
KNOWN_TASK_STATUSES = {"TODO", "DOING", "DONE", "BLOCKED"}
|
||||
ACTIVE_TASK_STATUSES = {"TODO", "DOING"}
|
||||
FINAL_WORKER_STATUSES = {"done", "blocked", "skipped"}
|
||||
DOCUMENT_UPDATE_ACTIONS = {"replace_block", "append_lines", "create_file"}
|
||||
DEFAULT_WORKER_RESULT_NOTE = (
|
||||
"Do not merge this result until summary, filesChanged, validations, and risks are reviewed."
|
||||
)
|
||||
DEFAULT_DEBUG_POLICY = {
|
||||
"enabled": True,
|
||||
"triggerOnBlocked": True,
|
||||
"triggerValidationStatuses": ["failed", "error"],
|
||||
"maxSessionsPerTask": 1,
|
||||
"preferOriginalAirDbg": True,
|
||||
}
|
||||
DEFAULT_XDB_POLICY = {
|
||||
"enabled": True,
|
||||
"requireForGuiTasks": True,
|
||||
"captureOnBlocked": True,
|
||||
"captureOnDone": True,
|
||||
"triggerValidationStatuses": ["failed", "error"],
|
||||
"maxSessionsPerTask": 1,
|
||||
"preferOriginalAirXDB": True,
|
||||
"remoteFirstIfConfigured": True,
|
||||
"taskKeywords": [
|
||||
"gui",
|
||||
"ui",
|
||||
"desktop",
|
||||
"browser",
|
||||
"screen",
|
||||
"screenshot",
|
||||
"visual",
|
||||
"layout",
|
||||
"popup",
|
||||
"focus",
|
||||
"canvas",
|
||||
"acceptance",
|
||||
"midscene",
|
||||
"airxdb",
|
||||
"lvgl",
|
||||
"lcd",
|
||||
"qt-app",
|
||||
],
|
||||
}
|
||||
DEFAULT_REPAIR_POLICY = {
|
||||
"enabled": True,
|
||||
"triggerOnBlocked": True,
|
||||
"triggerValidationStatuses": ["failed", "error"],
|
||||
"maxAttemptsPerTask": 2,
|
||||
"requeueTodoStatus": "DOING",
|
||||
"autoPrepareWorker": True,
|
||||
}
|
||||
REQUIRED_PROJECT_ARTIFACTS = required_project_artifacts()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _dedupe(items: List[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 _ensure_str_list(value: Any) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return _dedupe([str(item) for item in value])
|
||||
return _dedupe([str(value)])
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationRecord:
|
||||
kind: str
|
||||
status: str
|
||||
command: str = ""
|
||||
evidence: List[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "ValidationRecord":
|
||||
return cls(
|
||||
kind=str(payload.get("kind", "")).strip(),
|
||||
status=str(payload.get("status", "")).strip(),
|
||||
command=str(payload.get("command", "")).strip(),
|
||||
evidence=_ensure_str_list(payload.get("evidence")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRecord:
|
||||
task_id: str
|
||||
status: str
|
||||
module: str
|
||||
task: str
|
||||
files_dirs: str
|
||||
done_when: str
|
||||
validation: str
|
||||
adr_c4_update: str
|
||||
line_number: int
|
||||
dependencies: List[str] = field(default_factory=list)
|
||||
write_paths: List[str] = field(default_factory=list)
|
||||
global_doc_paths: List[str] = field(default_factory=list)
|
||||
|
||||
def normalized_write_set(self) -> List[str]:
|
||||
return _dedupe(self.write_paths + self.global_doc_paths)
|
||||
|
||||
def touches_global_docs(self) -> bool:
|
||||
return bool(self.global_doc_paths)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
payload = asdict(self)
|
||||
payload["taskId"] = payload.pop("task_id")
|
||||
payload["filesDirs"] = payload.pop("files_dirs")
|
||||
payload["doneWhen"] = payload.pop("done_when")
|
||||
payload["adrC4Update"] = payload.pop("adr_c4_update")
|
||||
payload["lineNumber"] = payload.pop("line_number")
|
||||
payload["writeSet"] = payload.pop("write_paths")
|
||||
payload["globalDocPaths"] = payload.pop("global_doc_paths")
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "TaskRecord":
|
||||
return cls(
|
||||
task_id=str(payload.get("taskId", "")).strip(),
|
||||
status=str(payload.get("status", "")).strip().upper(),
|
||||
module=str(payload.get("module", "")).strip(),
|
||||
task=str(payload.get("task", "")).strip(),
|
||||
files_dirs=str(payload.get("filesDirs", "")).strip(),
|
||||
done_when=str(payload.get("doneWhen", "")).strip(),
|
||||
validation=str(payload.get("validation", "")).strip(),
|
||||
adr_c4_update=str(payload.get("adrC4Update", "")).strip(),
|
||||
line_number=int(payload.get("lineNumber", 0) or 0),
|
||||
dependencies=_ensure_str_list(payload.get("dependencies")),
|
||||
write_paths=_ensure_str_list(payload.get("writeSet")),
|
||||
global_doc_paths=_ensure_str_list(payload.get("globalDocPaths")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerResult:
|
||||
task_id: str
|
||||
status: str
|
||||
summary: str
|
||||
files_changed: List[str] = field(default_factory=list)
|
||||
validations: List[ValidationRecord] = field(default_factory=list)
|
||||
evidence_paths: List[str] = field(default_factory=list)
|
||||
risks: List[str] = field(default_factory=list)
|
||||
blockers: List[str] = field(default_factory=list)
|
||||
recommend_global_doc_updates: List[str] = field(default_factory=list)
|
||||
global_doc_paths: List[str] = field(default_factory=list)
|
||||
document_updates: List["DocumentUpdate"] = field(default_factory=list)
|
||||
debug_sessions: List["DebugSession"] = field(default_factory=list)
|
||||
xdb_sessions: List["XdbSession"] = field(default_factory=list)
|
||||
repair_attempts: List["RepairAttempt"] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
finalized_at: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "WorkerResult":
|
||||
return cls(
|
||||
task_id=str(payload.get("taskId", "")).strip(),
|
||||
status=str(payload.get("status", "")).strip().lower(),
|
||||
summary=str(payload.get("summary", "")).strip(),
|
||||
files_changed=_ensure_str_list(payload.get("filesChanged")),
|
||||
validations=[
|
||||
ValidationRecord.from_dict(item)
|
||||
for item in payload.get("validations", [])
|
||||
],
|
||||
evidence_paths=_ensure_str_list(payload.get("evidencePaths")),
|
||||
risks=_ensure_str_list(payload.get("risks")),
|
||||
blockers=_ensure_str_list(payload.get("blockers")),
|
||||
recommend_global_doc_updates=_ensure_str_list(
|
||||
payload.get("recommendGlobalDocUpdates")
|
||||
),
|
||||
global_doc_paths=_ensure_str_list(payload.get("globalDocPaths")),
|
||||
document_updates=[
|
||||
DocumentUpdate.from_dict(item)
|
||||
for item in payload.get("documentUpdates", [])
|
||||
],
|
||||
debug_sessions=[
|
||||
DebugSession.from_dict(item)
|
||||
for item in payload.get("debugSessions", [])
|
||||
],
|
||||
xdb_sessions=[
|
||||
XdbSession.from_dict(item)
|
||||
for item in payload.get("xdbSessions", [])
|
||||
],
|
||||
repair_attempts=[
|
||||
RepairAttempt.from_dict(item)
|
||||
for item in payload.get("repairAttempts", [])
|
||||
],
|
||||
notes=_ensure_str_list(payload.get("notes")),
|
||||
finalized_at=str(payload.get("finalizedAt", "")).strip(),
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.task_id:
|
||||
raise ValueError("worker result missing taskId")
|
||||
if self.status not in FINAL_WORKER_STATUSES:
|
||||
raise ValueError(
|
||||
f"worker result status must be one of {sorted(FINAL_WORKER_STATUSES)}"
|
||||
)
|
||||
if not self.summary:
|
||||
raise ValueError("worker result summary must not be empty")
|
||||
if self.status == "blocked" and not self.blockers:
|
||||
raise ValueError("blocked worker result must include blockers")
|
||||
for item in self.document_updates:
|
||||
item.validate()
|
||||
for item in self.debug_sessions:
|
||||
item.validate()
|
||||
for item in self.xdb_sessions:
|
||||
item.validate()
|
||||
for item in self.repair_attempts:
|
||||
item.validate()
|
||||
|
||||
def has_meaningful_done_payload(self) -> bool:
|
||||
return bool(
|
||||
self.files_changed
|
||||
or self.validations
|
||||
or self.evidence_paths
|
||||
or self.document_updates
|
||||
or self.debug_sessions
|
||||
or self.xdb_sessions
|
||||
)
|
||||
|
||||
def validate_for_finalize(self) -> None:
|
||||
self.validate()
|
||||
if self.status == "done" and not self.has_meaningful_done_payload():
|
||||
raise ValueError(
|
||||
"done worker result must include filesChanged, validations, evidencePaths, "
|
||||
"documentUpdates, debugSessions, or xdbSessions before finalize"
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"taskId": self.task_id,
|
||||
"status": self.status,
|
||||
"summary": self.summary,
|
||||
"filesChanged": self.files_changed,
|
||||
"validations": [item.to_dict() for item in self.validations],
|
||||
"evidencePaths": self.evidence_paths,
|
||||
"risks": self.risks,
|
||||
"blockers": self.blockers,
|
||||
"recommendGlobalDocUpdates": self.recommend_global_doc_updates,
|
||||
"globalDocPaths": self.global_doc_paths,
|
||||
"documentUpdates": [item.to_dict() for item in self.document_updates],
|
||||
"debugSessions": [item.to_dict() for item in self.debug_sessions],
|
||||
"xdbSessions": [item.to_dict() for item in self.xdb_sessions],
|
||||
"repairAttempts": [item.to_dict() for item in self.repair_attempts],
|
||||
"notes": self.notes,
|
||||
"finalizedAt": self.finalized_at,
|
||||
}
|
||||
|
||||
|
||||
def default_worker_result(task_id: str, summary: str = "") -> WorkerResult:
|
||||
return WorkerResult(
|
||||
task_id=task_id,
|
||||
status="done",
|
||||
summary=summary,
|
||||
files_changed=[],
|
||||
validations=[],
|
||||
evidence_paths=[],
|
||||
risks=[],
|
||||
blockers=[],
|
||||
recommend_global_doc_updates=[],
|
||||
global_doc_paths=[],
|
||||
document_updates=[],
|
||||
debug_sessions=[],
|
||||
xdb_sessions=[],
|
||||
repair_attempts=[],
|
||||
notes=[
|
||||
DEFAULT_WORKER_RESULT_NOTE
|
||||
],
|
||||
finalized_at="",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DebugSession:
|
||||
session_id: str
|
||||
task_id: str
|
||||
source: str
|
||||
trigger: str
|
||||
reason: str
|
||||
request_path: str
|
||||
debug_log_path: str
|
||||
state_path: str = ""
|
||||
mode: str = "original-airdbg"
|
||||
status: str = "requested"
|
||||
created_at: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "DebugSession":
|
||||
return cls(
|
||||
session_id=str(payload.get("sessionId", "")).strip(),
|
||||
task_id=str(payload.get("taskId", "")).strip(),
|
||||
source=str(payload.get("source", "")).strip(),
|
||||
trigger=str(payload.get("trigger", "")).strip(),
|
||||
reason=str(payload.get("reason", "")).strip(),
|
||||
request_path=str(payload.get("requestPath", "")).strip(),
|
||||
debug_log_path=str(payload.get("debugLogPath", "")).strip(),
|
||||
state_path=str(payload.get("statePath", "")).strip(),
|
||||
mode=str(payload.get("mode", "original-airdbg")).strip(),
|
||||
status=str(payload.get("status", "requested")).strip(),
|
||||
created_at=str(payload.get("createdAt", "")).strip(),
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.session_id:
|
||||
raise ValueError("debug session missing sessionId")
|
||||
if not self.task_id:
|
||||
raise ValueError("debug session missing taskId")
|
||||
if not self.request_path:
|
||||
raise ValueError("debug session missing requestPath")
|
||||
if not self.debug_log_path:
|
||||
raise ValueError("debug session missing debugLogPath")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"sessionId": self.session_id,
|
||||
"taskId": self.task_id,
|
||||
"source": self.source,
|
||||
"trigger": self.trigger,
|
||||
"reason": self.reason,
|
||||
"requestPath": self.request_path,
|
||||
"debugLogPath": self.debug_log_path,
|
||||
"statePath": self.state_path,
|
||||
"mode": self.mode,
|
||||
"status": self.status,
|
||||
"createdAt": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class XdbSession:
|
||||
session_id: str
|
||||
task_id: str
|
||||
source: str
|
||||
trigger: str
|
||||
reason: str
|
||||
request_path: str
|
||||
gui_debug_log_path: str
|
||||
report_path: str = ""
|
||||
state_path: str = ""
|
||||
mode: str = "local-screenshot"
|
||||
status: str = "captured"
|
||||
screenshots: List[str] = field(default_factory=list)
|
||||
created_at: str = ""
|
||||
remote_target: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "XdbSession":
|
||||
return cls(
|
||||
session_id=str(payload.get("sessionId", "")).strip(),
|
||||
task_id=str(payload.get("taskId", "")).strip(),
|
||||
source=str(payload.get("source", "")).strip(),
|
||||
trigger=str(payload.get("trigger", "")).strip(),
|
||||
reason=str(payload.get("reason", "")).strip(),
|
||||
request_path=str(payload.get("requestPath", "")).strip(),
|
||||
gui_debug_log_path=str(payload.get("guiDebugLogPath", "")).strip(),
|
||||
report_path=str(payload.get("reportPath", "")).strip(),
|
||||
state_path=str(payload.get("statePath", "")).strip(),
|
||||
mode=str(payload.get("mode", "local-screenshot")).strip(),
|
||||
status=str(payload.get("status", "captured")).strip(),
|
||||
screenshots=_ensure_str_list(payload.get("screenshots")),
|
||||
created_at=str(payload.get("createdAt", "")).strip(),
|
||||
remote_target=str(payload.get("remoteTarget", "")).strip(),
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.session_id:
|
||||
raise ValueError("xdb session missing sessionId")
|
||||
if not self.task_id:
|
||||
raise ValueError("xdb session missing taskId")
|
||||
if not self.request_path:
|
||||
raise ValueError("xdb session missing requestPath")
|
||||
if not self.gui_debug_log_path:
|
||||
raise ValueError("xdb session missing guiDebugLogPath")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"sessionId": self.session_id,
|
||||
"taskId": self.task_id,
|
||||
"source": self.source,
|
||||
"trigger": self.trigger,
|
||||
"reason": self.reason,
|
||||
"requestPath": self.request_path,
|
||||
"guiDebugLogPath": self.gui_debug_log_path,
|
||||
"reportPath": self.report_path,
|
||||
"statePath": self.state_path,
|
||||
"mode": self.mode,
|
||||
"status": self.status,
|
||||
"screenshots": self.screenshots,
|
||||
"createdAt": self.created_at,
|
||||
"remoteTarget": self.remote_target,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepairAttempt:
|
||||
repair_id: str
|
||||
task_id: str
|
||||
attempt_index: int
|
||||
source_status: str
|
||||
source_result_path: str
|
||||
reason: str
|
||||
repair_brief_path: str
|
||||
state_path: str = ""
|
||||
worker_brief_path: str = ""
|
||||
debug_session_ids: List[str] = field(default_factory=list)
|
||||
status: str = "queued"
|
||||
created_at: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "RepairAttempt":
|
||||
return cls(
|
||||
repair_id=str(payload.get("repairId", "")).strip(),
|
||||
task_id=str(payload.get("taskId", "")).strip(),
|
||||
attempt_index=int(payload.get("attemptIndex", 0) or 0),
|
||||
source_status=str(payload.get("sourceStatus", "")).strip(),
|
||||
source_result_path=str(payload.get("sourceResultPath", "")).strip(),
|
||||
reason=str(payload.get("reason", "")).strip(),
|
||||
repair_brief_path=str(payload.get("repairBriefPath", "")).strip(),
|
||||
state_path=str(payload.get("statePath", "")).strip(),
|
||||
worker_brief_path=str(payload.get("workerBriefPath", "")).strip(),
|
||||
debug_session_ids=_ensure_str_list(payload.get("debugSessionIds")),
|
||||
status=str(payload.get("status", "queued")).strip(),
|
||||
created_at=str(payload.get("createdAt", "")).strip(),
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.repair_id:
|
||||
raise ValueError("repair attempt missing repairId")
|
||||
if not self.task_id:
|
||||
raise ValueError("repair attempt missing taskId")
|
||||
if self.attempt_index <= 0:
|
||||
raise ValueError("repair attempt must have positive attemptIndex")
|
||||
if not self.repair_brief_path:
|
||||
raise ValueError("repair attempt missing repairBriefPath")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"repairId": self.repair_id,
|
||||
"taskId": self.task_id,
|
||||
"attemptIndex": self.attempt_index,
|
||||
"sourceStatus": self.source_status,
|
||||
"sourceResultPath": self.source_result_path,
|
||||
"reason": self.reason,
|
||||
"repairBriefPath": self.repair_brief_path,
|
||||
"statePath": self.state_path,
|
||||
"workerBriefPath": self.worker_brief_path,
|
||||
"debugSessionIds": self.debug_session_ids,
|
||||
"status": self.status,
|
||||
"createdAt": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentUpdate:
|
||||
path: str
|
||||
action: str
|
||||
content: str = ""
|
||||
marker: str = ""
|
||||
append_lines: List[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "DocumentUpdate":
|
||||
return cls(
|
||||
path=str(payload.get("path", "")).strip(),
|
||||
action=str(payload.get("action", "")).strip(),
|
||||
content=str(payload.get("content", "")).rstrip(),
|
||||
marker=str(payload.get("marker", "")).strip(),
|
||||
append_lines=_ensure_str_list(payload.get("appendLines")),
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.path:
|
||||
raise ValueError("document update missing path")
|
||||
if self.action not in DOCUMENT_UPDATE_ACTIONS:
|
||||
raise ValueError(
|
||||
f"document update action must be one of {sorted(DOCUMENT_UPDATE_ACTIONS)}"
|
||||
)
|
||||
if self.action == "replace_block" and not self.marker:
|
||||
raise ValueError("replace_block document update requires marker")
|
||||
if self.action == "append_lines" and not self.append_lines:
|
||||
raise ValueError("append_lines document update requires appendLines")
|
||||
if self.action in {"replace_block", "create_file"} and not self.content:
|
||||
raise ValueError(f"{self.action} document update requires content")
|
||||
if self.action in {"replace_block", "create_file"} and "TODO" in self.content:
|
||||
raise ValueError(
|
||||
f"{self.action} document update for {self.path} still contains TODO placeholders"
|
||||
)
|
||||
if self.action == "append_lines" and any("TODO" in line for line in self.append_lines):
|
||||
raise ValueError(
|
||||
f"append_lines document update for {self.path} still contains TODO placeholders"
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"path": self.path,
|
||||
"action": self.action,
|
||||
}
|
||||
if self.content:
|
||||
payload["content"] = self.content
|
||||
if self.marker:
|
||||
payload["marker"] = self.marker
|
||||
if self.append_lines:
|
||||
payload["appendLines"] = self.append_lines
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewConflict:
|
||||
task_ids: List[str]
|
||||
reason: str
|
||||
overlap_paths: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"taskIds": self.task_ids,
|
||||
"reason": self.reason,
|
||||
"overlapPaths": self.overlap_paths,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "ReviewConflict":
|
||||
return cls(
|
||||
task_ids=_ensure_str_list(payload.get("taskIds")),
|
||||
reason=str(payload.get("reason", "")).strip(),
|
||||
overlap_paths=_ensure_str_list(payload.get("overlapPaths")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParallelGroup:
|
||||
name: str
|
||||
task_ids: List[str]
|
||||
reason: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"taskIds": self.task_ids,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "ParallelGroup":
|
||||
return cls(
|
||||
name=str(payload.get("name", "")).strip(),
|
||||
task_ids=_ensure_str_list(payload.get("taskIds")),
|
||||
reason=str(payload.get("reason", "")).strip(),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParallelReview:
|
||||
source_todo: str
|
||||
generated_at: str
|
||||
tasks_considered: List[TaskRecord]
|
||||
dependency_edges: List[Dict[str, str]]
|
||||
parallel_groups: List[ParallelGroup]
|
||||
conflicts: List[ReviewConflict]
|
||||
serialization_points: List[Dict[str, Any]]
|
||||
notes: List[str]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"sourceTodo": self.source_todo,
|
||||
"generatedAt": self.generated_at,
|
||||
"tasksConsidered": [task.to_dict() for task in self.tasks_considered],
|
||||
"dependencyEdges": self.dependency_edges,
|
||||
"parallelGroups": [group.to_dict() for group in self.parallel_groups],
|
||||
"conflicts": [conflict.to_dict() for conflict in self.conflicts],
|
||||
"serializationPoints": self.serialization_points,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "ParallelReview":
|
||||
return cls(
|
||||
source_todo=str(payload.get("sourceTodo", "")).strip(),
|
||||
generated_at=str(payload.get("generatedAt", "")).strip(),
|
||||
tasks_considered=[
|
||||
TaskRecord.from_dict(item)
|
||||
for item in payload.get("tasksConsidered", [])
|
||||
],
|
||||
dependency_edges=list(payload.get("dependencyEdges", [])),
|
||||
parallel_groups=[
|
||||
ParallelGroup.from_dict(item)
|
||||
for item in payload.get("parallelGroups", [])
|
||||
],
|
||||
conflicts=[
|
||||
ReviewConflict.from_dict(item)
|
||||
for item in payload.get("conflicts", [])
|
||||
],
|
||||
serialization_points=list(payload.get("serializationPoints", [])),
|
||||
notes=_ensure_str_list(payload.get("notes")),
|
||||
)
|
||||
413
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/debug_runtime.py
Executable file
413
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/debug_runtime.py
Executable file
@@ -0,0 +1,413 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Tuple
|
||||
|
||||
from .contracts import DEFAULT_DEBUG_POLICY, DebugSession, WorkerResult, now_iso
|
||||
from .paths import airdbg_root, agents_path, aireng_root, architecture_adr_dir, architecture_c4_module_path, debug_log_path
|
||||
|
||||
|
||||
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_debug_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]:
|
||||
merged = dict(DEFAULT_DEBUG_POLICY)
|
||||
if policy:
|
||||
merged.update(policy)
|
||||
|
||||
merged["enabled"] = bool(merged.get("enabled", True))
|
||||
merged["triggerOnBlocked"] = bool(merged.get("triggerOnBlocked", True))
|
||||
merged["preferOriginalAirDbg"] = bool(merged.get("preferOriginalAirDbg", True))
|
||||
merged["triggerValidationStatuses"] = _ordered_unique(
|
||||
str(item).lower() for item in list(merged.get("triggerValidationStatuses", []))
|
||||
)
|
||||
|
||||
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_debug_policy(project_root: Path) -> Dict[str, object]:
|
||||
state_path = aireng_root(project_root) / "state.json"
|
||||
state = _json_load(state_path)
|
||||
return normalize_debug_policy(state.get("debugPolicy", {}))
|
||||
|
||||
|
||||
def merge_debug_sessions_into_state(
|
||||
state: Dict[str, object], debug_sessions: List[DebugSession]
|
||||
) -> Dict[str, object]:
|
||||
state["debugPolicy"] = normalize_debug_policy(dict(state.get("debugPolicy", {})))
|
||||
|
||||
existing_items = list(state.get("debugSessions", []))
|
||||
seen_ids = {
|
||||
str(item.get("sessionId", "")).strip()
|
||||
for item in existing_items
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
for session in debug_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["debugSessions"] = existing_items
|
||||
state["taskDebugCounts"] = task_counts
|
||||
if debug_sessions:
|
||||
latest = debug_sessions[-1]
|
||||
state["lastDebugSessionId"] = latest.session_id
|
||||
state["lastDebugTaskId"] = latest.task_id
|
||||
state["lastDebugAt"] = latest.created_at
|
||||
return state
|
||||
|
||||
|
||||
def _debug_paths(project_root: Path) -> Dict[str, Path]:
|
||||
root = airdbg_root(project_root)
|
||||
return {
|
||||
"root": root,
|
||||
"state": root / "state.json",
|
||||
"requests": root / "requests",
|
||||
"sessions": root / "sessions",
|
||||
"debug_log": debug_log_path(project_root),
|
||||
}
|
||||
|
||||
|
||||
def _airdbg_health(project_root: Path) -> Dict[str, bool]:
|
||||
paths = _debug_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(),
|
||||
"debug_log": paths["debug_log"].exists(),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_debug_layout(project_root: Path) -> Dict[str, Path]:
|
||||
paths = _debug_paths(project_root)
|
||||
paths["requests"].mkdir(parents=True, exist_ok=True)
|
||||
paths["sessions"].mkdir(parents=True, exist_ok=True)
|
||||
paths["debug_log"].parent.mkdir(parents=True, exist_ok=True)
|
||||
if not paths["debug_log"].exists():
|
||||
paths["debug_log"].write_text("# Debug Log\n\n", encoding="utf-8")
|
||||
return paths
|
||||
|
||||
|
||||
def _resolve_original_airdbg_script() -> Path | None:
|
||||
candidates = [
|
||||
Path.home() / "plugins" / "airdbg" / "scripts" / "airdbg_mode.py",
|
||||
Path(r"C:\Users\20392\plugins\airdbg\scripts\airdbg_mode.py"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _bootstrap_airdbg(project_root: Path, prefer_original: bool) -> Tuple[str, str, str]:
|
||||
mode = "runtime-bootstrap"
|
||||
stdout = ""
|
||||
error = ""
|
||||
script_path = _resolve_original_airdbg_script() 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-airdbg"
|
||||
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_debug_layout(project_root)
|
||||
state = _json_load(paths["state"])
|
||||
state["enabled"] = True
|
||||
state["updatedAt"] = now_iso()
|
||||
state["projectRoot"] = str(project_root)
|
||||
state["artifactHealth"] = _airdbg_health(project_root)
|
||||
if stdout:
|
||||
state["lastBootstrapStdout"] = stdout
|
||||
if error:
|
||||
state["lastBootstrapError"] = error
|
||||
_json_dump(paths["state"], state)
|
||||
return mode, stdout, error
|
||||
|
||||
|
||||
def list_debug_sessions(project_root: Path, task_id: str = "") -> List[DebugSession]:
|
||||
session_dir = _debug_paths(project_root)["sessions"]
|
||||
if not session_dir.exists():
|
||||
return []
|
||||
|
||||
sessions: List[DebugSession] = []
|
||||
for path in sorted(session_dir.glob("*.json")):
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
session_payload = payload.get("debugSession", payload)
|
||||
session = DebugSession.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 _result_trigger_summary(
|
||||
result: WorkerResult, policy: Dict[str, object]
|
||||
) -> Tuple[List[str], str]:
|
||||
triggers: List[str] = []
|
||||
reasons: List[str] = []
|
||||
|
||||
if result.status == "blocked" and bool(policy.get("triggerOnBlocked", True)):
|
||||
triggers.append("blocked-status")
|
||||
blocker_text = ", ".join(result.blockers) if result.blockers else "worker returned blocked"
|
||||
reasons.append(f"worker result is blocked: {blocker_text}")
|
||||
|
||||
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(
|
||||
"validation failures: "
|
||||
+ ", ".join(f"{item.kind}:{item.status}" for item in failed_validations)
|
||||
)
|
||||
|
||||
return triggers, "; ".join(reasons)
|
||||
|
||||
|
||||
def _session_stamp() -> str:
|
||||
return now_iso().replace(":", "-").replace(".", "-").replace("+", "-")
|
||||
|
||||
|
||||
def _append_debug_log(
|
||||
debug_log_path: Path,
|
||||
result: WorkerResult,
|
||||
session: DebugSession,
|
||||
bootstrap_mode: str,
|
||||
) -> None:
|
||||
section_lines = [
|
||||
f"### {session.created_at}: auto-debug request for {result.task_id}",
|
||||
"",
|
||||
f"- Task: `{result.task_id}`",
|
||||
f"- Source: `{session.source}`",
|
||||
f"- Trigger: `{session.trigger}`",
|
||||
f"- Symptom: {result.summary}",
|
||||
"- Expected: Task validations should pass, or the task should complete without blockers.",
|
||||
f"- Actual: worker status `{result.status}`; blockers={', '.join(result.blockers) or 'none'}",
|
||||
"- Reproduction: finalize the worker result and let the runtime auto-route the failure into AirDbg.",
|
||||
"- Root cause: pending AirDbg investigation.",
|
||||
f"- Fix: pending AirDbg session `{session.session_id}`",
|
||||
(
|
||||
"- Validation: debug request stored at "
|
||||
f"`{session.request_path}`; log appended automatically; bootstrap mode `{bootstrap_mode}`"
|
||||
),
|
||||
f"- Evidence: {', '.join(f'`{item}`' for item in result.evidence_paths) or '`none`'}",
|
||||
"- ADR/C4 updates: none yet",
|
||||
"- Residual risk: the underlying blocker is unresolved until the debug session is completed.",
|
||||
"",
|
||||
]
|
||||
existing = debug_log_path.read_text(encoding="utf-8") if debug_log_path.exists() else "# Debug Log\n\n"
|
||||
debug_log_path.write_text(existing.rstrip() + "\n\n" + "\n".join(section_lines), encoding="utf-8")
|
||||
|
||||
|
||||
def _update_airdbg_state(
|
||||
project_root: Path,
|
||||
session: DebugSession,
|
||||
bootstrap_mode: str,
|
||||
bootstrap_stdout: str,
|
||||
bootstrap_error: str,
|
||||
) -> None:
|
||||
paths = _ensure_debug_layout(project_root)
|
||||
state = _json_load(paths["state"])
|
||||
sessions = list(state.get("autoDebugSessions", []))
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
state["enabled"] = True
|
||||
state["updatedAt"] = now_iso()
|
||||
state["projectRoot"] = str(project_root)
|
||||
state["artifactHealth"] = _airdbg_health(project_root)
|
||||
state["autoDebugSessions"] = sessions
|
||||
state["autoDebugSessionCount"] = len(sessions)
|
||||
state["lastAutoDebugAt"] = session.created_at
|
||||
state["lastAutoDebugSessionId"] = session.session_id
|
||||
state["lastBootstrapMode"] = bootstrap_mode
|
||||
if bootstrap_stdout:
|
||||
state["lastBootstrapStdout"] = bootstrap_stdout
|
||||
if bootstrap_error:
|
||||
state["lastBootstrapError"] = bootstrap_error
|
||||
_json_dump(paths["state"], state)
|
||||
|
||||
|
||||
def _attach_session_to_result(result: WorkerResult, session: DebugSession, note: str) -> None:
|
||||
if not any(item.session_id == session.session_id for item in result.debug_sessions):
|
||||
result.debug_sessions.append(session)
|
||||
for path in [session.request_path, session.debug_log_path, session.state_path]:
|
||||
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 ensure_debug_sessions_for_result(
|
||||
project_root: Path, result: WorkerResult, source: str
|
||||
) -> List[DebugSession]:
|
||||
policy = load_debug_policy(project_root)
|
||||
if not bool(policy.get("enabled", True)):
|
||||
return result.debug_sessions
|
||||
|
||||
triggers, reason = _result_trigger_summary(result, policy)
|
||||
if not triggers:
|
||||
return result.debug_sessions
|
||||
|
||||
existing_sessions = list_debug_sessions(project_root, result.task_id)
|
||||
if result.debug_sessions:
|
||||
for session in result.debug_sessions:
|
||||
_attach_session_to_result(
|
||||
result,
|
||||
session,
|
||||
f"Debug session already linked for {result.task_id}: {session.session_id}",
|
||||
)
|
||||
return result.debug_sessions
|
||||
|
||||
if existing_sessions:
|
||||
latest = existing_sessions[-1]
|
||||
_attach_session_to_result(
|
||||
result,
|
||||
latest,
|
||||
f"Reused existing debug session for {result.task_id}: {latest.session_id}",
|
||||
)
|
||||
return result.debug_sessions
|
||||
|
||||
session_limit = int(policy.get("maxSessionsPerTask", 1) or 1)
|
||||
if len(existing_sessions) >= session_limit:
|
||||
return result.debug_sessions
|
||||
|
||||
paths = _ensure_debug_layout(project_root)
|
||||
bootstrap_mode, bootstrap_stdout, bootstrap_error = _bootstrap_airdbg(
|
||||
project_root, bool(policy.get("preferOriginalAirDbg", True))
|
||||
)
|
||||
|
||||
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"
|
||||
trigger = ",".join(triggers)
|
||||
|
||||
request_payload = {
|
||||
"sessionId": session_id,
|
||||
"taskId": result.task_id,
|
||||
"source": source,
|
||||
"trigger": trigger,
|
||||
"reason": reason or result.summary,
|
||||
"workerStatus": result.status,
|
||||
"summary": result.summary,
|
||||
"filesChanged": result.files_changed,
|
||||
"validations": [item.to_dict() for item in result.validations],
|
||||
"evidencePaths": result.evidence_paths,
|
||||
"risks": result.risks,
|
||||
"blockers": result.blockers,
|
||||
"createdAt": created_at,
|
||||
"bootstrapMode": bootstrap_mode,
|
||||
"bootstrapStdout": bootstrap_stdout,
|
||||
"bootstrapError": bootstrap_error,
|
||||
}
|
||||
_json_dump(request_path, request_payload)
|
||||
|
||||
session = DebugSession(
|
||||
session_id=session_id,
|
||||
task_id=result.task_id,
|
||||
source=source,
|
||||
trigger=trigger,
|
||||
reason=reason or result.summary,
|
||||
request_path=str(request_path),
|
||||
debug_log_path=str(paths["debug_log"]),
|
||||
state_path=str(paths["state"]),
|
||||
mode=bootstrap_mode,
|
||||
status="requested",
|
||||
created_at=created_at,
|
||||
)
|
||||
_json_dump(
|
||||
session_path,
|
||||
{
|
||||
"debugSession": session.to_dict(),
|
||||
"request": request_payload,
|
||||
},
|
||||
)
|
||||
|
||||
_append_debug_log(paths["debug_log"], result, session, bootstrap_mode)
|
||||
_update_airdbg_state(project_root, session, bootstrap_mode, bootstrap_stdout, bootstrap_error)
|
||||
_attach_session_to_result(
|
||||
result,
|
||||
session,
|
||||
f"Auto-requested debug session for {result.task_id}: {session.session_id}",
|
||||
)
|
||||
return result.debug_sessions
|
||||
350
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/doc_sync.py
Executable file
350
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/doc_sync.py
Executable file
@@ -0,0 +1,350 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List
|
||||
|
||||
from .contracts import DocumentUpdate, ValidationRecord, WorkerResult, now_iso
|
||||
from .paths import (
|
||||
agents_path,
|
||||
architecture_c4_module_path,
|
||||
plan_path as workflow_plan_path,
|
||||
todo_path as workflow_todo_path,
|
||||
)
|
||||
from .todo_parser import parse_tasks
|
||||
|
||||
|
||||
def _project_relative(project_root: Path, raw_path: str) -> Path:
|
||||
candidate = Path(raw_path)
|
||||
if candidate.is_absolute():
|
||||
resolved = candidate.resolve()
|
||||
else:
|
||||
resolved = (project_root / candidate).resolve()
|
||||
|
||||
project_root_resolved = project_root.resolve()
|
||||
try:
|
||||
resolved.relative_to(project_root_resolved)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"document update path escapes project root: {raw_path}") from exc
|
||||
return resolved
|
||||
|
||||
|
||||
def _replace_marker_block(text: str, marker: str, content: str) -> str:
|
||||
begin = f"<!-- AIR-ENGINE:{marker}:BEGIN -->"
|
||||
end = f"<!-- AIR-ENGINE:{marker}:END -->"
|
||||
block = f"{begin}\n{content.rstrip()}\n{end}\n"
|
||||
start_index = text.find(begin)
|
||||
end_index = text.find(end)
|
||||
if start_index >= 0 and end_index > start_index:
|
||||
end_index += len(end)
|
||||
return text[:start_index].rstrip() + "\n\n" + block + text[end_index:].lstrip()
|
||||
return text.rstrip() + "\n\n" + block
|
||||
|
||||
|
||||
def _extract_marker_block(text: str, marker: str) -> str:
|
||||
begin = f"<!-- AIR-ENGINE:{marker}:BEGIN -->"
|
||||
end = f"<!-- AIR-ENGINE:{marker}:END -->"
|
||||
start_index = text.find(begin)
|
||||
end_index = text.find(end)
|
||||
if start_index < 0 or end_index <= start_index:
|
||||
return ""
|
||||
content_start = start_index + len(begin)
|
||||
return text[content_start:end_index].strip()
|
||||
|
||||
|
||||
def _apply_document_update(project_root: Path, update: DocumentUpdate) -> str:
|
||||
target_path = _project_relative(project_root, update.path)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = target_path.read_text(encoding="utf-8") if target_path.exists() else ""
|
||||
|
||||
if update.action == "create_file":
|
||||
target_path.write_text(update.content.rstrip() + "\n", encoding="utf-8")
|
||||
elif update.action == "append_lines":
|
||||
joined = "\n".join(update.append_lines).rstrip()
|
||||
if existing.strip():
|
||||
target_path.write_text(existing.rstrip() + "\n" + joined + "\n", encoding="utf-8")
|
||||
else:
|
||||
target_path.write_text(joined + "\n", encoding="utf-8")
|
||||
elif update.action == "replace_block":
|
||||
updated = _replace_marker_block(existing, update.marker, update.content)
|
||||
target_path.write_text(updated, encoding="utf-8")
|
||||
else:
|
||||
raise ValueError(f"unsupported document update action: {update.action}")
|
||||
|
||||
return str(target_path)
|
||||
|
||||
|
||||
def apply_document_updates(project_root: Path, result: WorkerResult) -> List[str]:
|
||||
applied: List[str] = []
|
||||
for update in result.document_updates:
|
||||
applied.append(_apply_document_update(project_root, update))
|
||||
return applied
|
||||
|
||||
|
||||
def _validation_summary(validations: List[ValidationRecord]) -> str:
|
||||
if not validations:
|
||||
return "No explicit validation recorded"
|
||||
return "; ".join(
|
||||
f"{item.kind}:{item.status}" + (f" ({item.command})" if item.command else "")
|
||||
for item in validations
|
||||
)
|
||||
|
||||
|
||||
def _debug_summary(result: WorkerResult) -> str:
|
||||
if not result.debug_sessions:
|
||||
return "none"
|
||||
return ", ".join(
|
||||
f"{item.session_id} [{item.trigger}]"
|
||||
for item in result.debug_sessions
|
||||
)
|
||||
|
||||
|
||||
def _xdb_summary(result: WorkerResult) -> str:
|
||||
if not result.xdb_sessions:
|
||||
return "none"
|
||||
return ", ".join(
|
||||
f"{item.session_id} [{item.mode}:{item.status}]"
|
||||
for item in result.xdb_sessions
|
||||
)
|
||||
|
||||
|
||||
def _repair_summary(result: WorkerResult) -> str:
|
||||
if not result.repair_attempts:
|
||||
return "none"
|
||||
return ", ".join(
|
||||
f"{item.repair_id} [{item.status}]"
|
||||
for item in result.repair_attempts
|
||||
)
|
||||
|
||||
|
||||
def _todo_status_from_worker_status(worker_status: str) -> str:
|
||||
if worker_status == "done":
|
||||
return "DONE"
|
||||
return "BLOCKED"
|
||||
|
||||
|
||||
def _todo_status_from_result(result: WorkerResult) -> str:
|
||||
if result.status == "done":
|
||||
return "DONE"
|
||||
if result.repair_attempts:
|
||||
return "DOING"
|
||||
return "BLOCKED"
|
||||
|
||||
|
||||
def _rebuild_table_row(cells: List[str]) -> str:
|
||||
return "| " + " | ".join(cells) + " |"
|
||||
|
||||
|
||||
def update_todo_after_merge(
|
||||
project_root: Path,
|
||||
result: WorkerResult,
|
||||
applied_doc_paths: List[str],
|
||||
sync_paths: List[str],
|
||||
) -> Path:
|
||||
todo_path = workflow_todo_path(project_root)
|
||||
original_text = todo_path.read_text(encoding="utf-8")
|
||||
lines = original_text.splitlines()
|
||||
updated_lines: List[str] = []
|
||||
task_found = False
|
||||
|
||||
unique_applied_doc_paths = list(dict.fromkeys(applied_doc_paths))
|
||||
unique_sync_paths = list(dict.fromkeys(sync_paths))
|
||||
for raw_line in lines:
|
||||
if raw_line.startswith(f"| {result.task_id} "):
|
||||
cells = [cell.strip() for cell in raw_line.strip().strip("|").split("|")]
|
||||
if len(cells) > 8:
|
||||
cells = cells[:7] + ["; ".join(cell for cell in cells[7:] if cell)]
|
||||
if len(cells) >= 8:
|
||||
cells[1] = _todo_status_from_result(result)
|
||||
validation_cell = _validation_summary(result.validations)
|
||||
if result.evidence_paths:
|
||||
validation_cell += f"; evidence={len(result.evidence_paths)}"
|
||||
if result.blockers:
|
||||
validation_cell += "; blockers=" + ", ".join(result.blockers)
|
||||
if result.xdb_sessions:
|
||||
validation_cell += f"; xdb={len(result.xdb_sessions)}"
|
||||
if result.debug_sessions:
|
||||
validation_cell += f"; debug={len(result.debug_sessions)}"
|
||||
if result.repair_attempts:
|
||||
validation_cell += f"; repair={len(result.repair_attempts)}"
|
||||
cells[6] = validation_cell
|
||||
if unique_applied_doc_paths:
|
||||
cells[7] = "Merged by engine: " + ", ".join(
|
||||
Path(path).name for path in unique_applied_doc_paths
|
||||
)
|
||||
updated_lines.append(_rebuild_table_row(cells))
|
||||
task_found = True
|
||||
continue
|
||||
updated_lines.append(raw_line)
|
||||
|
||||
if not task_found:
|
||||
raise ValueError(f"task row not found in todo.md for {result.task_id}")
|
||||
|
||||
log_lines = [
|
||||
f"- `{now_iso()}` task `{result.task_id}` merged with status `{result.status}`",
|
||||
f" Summary: {result.summary}",
|
||||
f" Files Changed: {', '.join(f'`{item}`' for item in result.files_changed) or '`none`'}",
|
||||
f" Validations: {_validation_summary(result.validations)}",
|
||||
f" Evidence: {', '.join(f'`{item}`' for item in result.evidence_paths) or '`none`'}",
|
||||
f" XDB Sessions: {_xdb_summary(result)}",
|
||||
f" Debug Sessions: {_debug_summary(result)}",
|
||||
f" Repair Attempts: {_repair_summary(result)}",
|
||||
f" Risks: {', '.join(result.risks) or 'none'}",
|
||||
f" Blockers: {', '.join(result.blockers) or 'none'}",
|
||||
f" Applied Doc Paths: {', '.join(f'`{item}`' for item in unique_applied_doc_paths) or '`none`'}",
|
||||
f" Engine Sync Paths: {', '.join(f'`{item}`' for item in unique_sync_paths) or '`none`'}",
|
||||
]
|
||||
existing_log = _extract_marker_block(original_text, "TODO-RUN-LOG")
|
||||
existing_body = ""
|
||||
if existing_log:
|
||||
existing_lines = existing_log.splitlines()
|
||||
if existing_lines and existing_lines[0].strip() in {"# Air Engine Merge Log", "# AirEng Merge Log"}:
|
||||
existing_body = "\n".join(existing_lines[1:]).strip()
|
||||
else:
|
||||
existing_body = existing_log.strip()
|
||||
|
||||
merged_log_content = "# AirEng Merge Log\n\n" + "\n".join(log_lines)
|
||||
if existing_body:
|
||||
merged_log_content += "\n\n" + existing_body
|
||||
|
||||
text = "\n".join(updated_lines).rstrip() + "\n"
|
||||
text = _replace_marker_block(
|
||||
text,
|
||||
"TODO-RUN-LOG",
|
||||
merged_log_content,
|
||||
)
|
||||
todo_path.write_text(text, encoding="utf-8")
|
||||
return todo_path
|
||||
|
||||
|
||||
def mark_tasks_dispatched(
|
||||
project_root: Path,
|
||||
group_name: str,
|
||||
dispatched_tasks: List[Dict[str, object]],
|
||||
recommended_concurrency: int,
|
||||
) -> List[str]:
|
||||
updated_paths: List[str] = []
|
||||
current_todo_path = workflow_todo_path(project_root)
|
||||
current_plan_path = workflow_plan_path(project_root)
|
||||
task_ids = {str(item.get("taskId", "")).strip() for item in dispatched_tasks if str(item.get("taskId", "")).strip()}
|
||||
|
||||
if current_todo_path.exists() and task_ids:
|
||||
lines = current_todo_path.read_text(encoding="utf-8").splitlines()
|
||||
rewritten: List[str] = []
|
||||
for raw_line in lines:
|
||||
if raw_line.startswith("| "):
|
||||
cells = [cell.strip() for cell in raw_line.strip().strip("|").split("|")]
|
||||
if cells and cells[0] in task_ids and len(cells) >= 2:
|
||||
cells[1] = "DOING"
|
||||
raw_line = _rebuild_table_row(cells)
|
||||
rewritten.append(raw_line)
|
||||
current_todo_path.write_text("\n".join(rewritten).rstrip() + "\n", encoding="utf-8")
|
||||
updated_paths.append(str(current_todo_path))
|
||||
|
||||
if current_plan_path.exists():
|
||||
plan_text = current_plan_path.read_text(encoding="utf-8")
|
||||
lines = [
|
||||
"## AirEng Active Dispatch",
|
||||
"",
|
||||
f"- Updated At: `{now_iso()}`",
|
||||
f"- Group: `{group_name or 'default'}`",
|
||||
f"- Recommended Concurrency: `{recommended_concurrency}`",
|
||||
"- Tasks:",
|
||||
]
|
||||
if dispatched_tasks:
|
||||
for item in dispatched_tasks:
|
||||
lines.append(
|
||||
f" - `{item.get('taskId', '')}` handoff=`{item.get('handoffPath', '')}` brief=`{item.get('briefPath', '')}`"
|
||||
)
|
||||
else:
|
||||
lines.append(" - none")
|
||||
plan_text = _replace_marker_block(plan_text, "DISPATCH-STATUS", "\n".join(lines))
|
||||
current_plan_path.write_text(plan_text, encoding="utf-8")
|
||||
updated_paths.append(str(current_plan_path))
|
||||
|
||||
return updated_paths
|
||||
|
||||
|
||||
def _sync_block_content(result: WorkerResult, applied_doc_paths: List[str], title: str) -> str:
|
||||
unique_applied_doc_paths = list(dict.fromkeys(applied_doc_paths))
|
||||
lines = [
|
||||
title,
|
||||
"",
|
||||
f"- Last Synced At: `{now_iso()}`",
|
||||
f"- Task: `{result.task_id}`",
|
||||
f"- Status: `{result.status}`",
|
||||
f"- Summary: {result.summary}",
|
||||
f"- Files Changed: {', '.join(f'`{item}`' for item in result.files_changed) or '`none`'}",
|
||||
f"- Validations: {_validation_summary(result.validations)}",
|
||||
f"- XDB Sessions: {_xdb_summary(result)}",
|
||||
f"- Debug Sessions: {_debug_summary(result)}",
|
||||
f"- Repair Attempts: {_repair_summary(result)}",
|
||||
f"- Applied Doc Paths: {', '.join(f'`{item}`' for item in unique_applied_doc_paths) or '`none`'}",
|
||||
f"- Risks: {', '.join(result.risks) or 'none'}",
|
||||
f"- Blockers: {', '.join(result.blockers) or 'none'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sync_engine_managed_docs(project_root: Path, result: WorkerResult, applied_doc_paths: List[str]) -> List[str]:
|
||||
updated_paths: List[str] = []
|
||||
current_agents_path = agents_path(project_root)
|
||||
if current_agents_path.exists():
|
||||
agents_text = current_agents_path.read_text(encoding="utf-8")
|
||||
agents_text = _replace_marker_block(
|
||||
agents_text,
|
||||
"AGENTS-SYNC",
|
||||
_sync_block_content(result, applied_doc_paths, "## AirEng Sync"),
|
||||
)
|
||||
current_agents_path.write_text(agents_text, encoding="utf-8")
|
||||
updated_paths.append(str(current_agents_path))
|
||||
|
||||
c4_path = architecture_c4_module_path(project_root)
|
||||
if c4_path.exists():
|
||||
c4_text = c4_path.read_text(encoding="utf-8")
|
||||
c4_text = _replace_marker_block(
|
||||
c4_text,
|
||||
"C4-SYNC",
|
||||
_sync_block_content(result, applied_doc_paths, "## AirEng Sync"),
|
||||
)
|
||||
c4_path.write_text(c4_text, encoding="utf-8")
|
||||
updated_paths.append(str(c4_path))
|
||||
|
||||
return updated_paths
|
||||
|
||||
|
||||
def enforce_doc_sync_requirements(project_root: Path, result: WorkerResult) -> None:
|
||||
task = None
|
||||
todo_path = workflow_todo_path(project_root)
|
||||
if todo_path.exists():
|
||||
task = next((item for item in parse_tasks(todo_path) if item.task_id == result.task_id), None)
|
||||
|
||||
required_doc_paths = set(result.global_doc_paths)
|
||||
if task is not None:
|
||||
required_doc_paths.update(task.global_doc_paths)
|
||||
|
||||
if result.status != "done":
|
||||
return
|
||||
|
||||
if not required_doc_paths:
|
||||
return
|
||||
|
||||
covered_paths = set()
|
||||
for update in result.document_updates:
|
||||
covered_paths.add(update.path)
|
||||
|
||||
missing_paths = []
|
||||
for required in sorted(required_doc_paths):
|
||||
if required == "AirPlan/docs/architecture/adr/":
|
||||
if not any("AirPlan/docs/architecture/adr/" in path.replace("\\", "/") for path in covered_paths):
|
||||
missing_paths.append(required)
|
||||
continue
|
||||
if required not in covered_paths:
|
||||
missing_paths.append(required)
|
||||
|
||||
if missing_paths:
|
||||
raise ValueError(
|
||||
"document sync is incomplete for task "
|
||||
+ result.task_id
|
||||
+ ": missing updates for "
|
||||
+ ", ".join(missing_paths)
|
||||
)
|
||||
1082
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/engine.py
Executable file
1082
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/engine.py
Executable file
File diff suppressed because it is too large
Load Diff
131
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/paths.py
Executable file
131
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/paths.py
Executable file
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
AIRPLAN_DIRNAME = "AirPlan"
|
||||
|
||||
|
||||
def airplan_root(project_root: Path) -> Path:
|
||||
return project_root / AIRPLAN_DIRNAME
|
||||
|
||||
|
||||
def docs_root(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "docs"
|
||||
|
||||
|
||||
def state_root(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "state"
|
||||
|
||||
|
||||
def agents_path(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "AGENTS.md"
|
||||
|
||||
|
||||
def root_agents_bootstrap_path(project_root: Path) -> Path:
|
||||
return project_root / "AGENTS.md"
|
||||
|
||||
|
||||
def plan_path(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "plan.md"
|
||||
|
||||
|
||||
def todo_path(project_root: Path) -> Path:
|
||||
return airplan_root(project_root) / "todo.md"
|
||||
|
||||
|
||||
def analysis_requirements_path(project_root: Path) -> Path:
|
||||
return docs_root(project_root) / "analysis" / "requirements.md"
|
||||
|
||||
|
||||
def architecture_root(project_root: Path) -> Path:
|
||||
return docs_root(project_root) / "architecture"
|
||||
|
||||
|
||||
def architecture_solution_path(project_root: Path) -> Path:
|
||||
return architecture_root(project_root) / "solution-architecture.md"
|
||||
|
||||
|
||||
def architecture_adr_dir(project_root: Path) -> Path:
|
||||
return architecture_root(project_root) / "adr"
|
||||
|
||||
|
||||
def architecture_c4_module_path(project_root: Path) -> Path:
|
||||
return architecture_root(project_root) / "c4" / "module.md"
|
||||
|
||||
|
||||
def debug_root(project_root: Path) -> Path:
|
||||
return docs_root(project_root) / "debug"
|
||||
|
||||
|
||||
def debug_log_path(project_root: Path) -> Path:
|
||||
return debug_root(project_root) / "debug-log.md"
|
||||
|
||||
|
||||
def gui_debug_log_path(project_root: Path) -> Path:
|
||||
return debug_root(project_root) / "gui-debug-log.md"
|
||||
|
||||
|
||||
def airxdb_artifacts_dir(project_root: Path) -> Path:
|
||||
return debug_root(project_root) / "airxdb-artifacts"
|
||||
|
||||
|
||||
def network_root(project_root: Path) -> Path:
|
||||
return docs_root(project_root) / "network"
|
||||
|
||||
|
||||
def airndb_log_path(project_root: Path) -> Path:
|
||||
return network_root(project_root) / "airndb-log.md"
|
||||
|
||||
|
||||
def airndb_captures_dir(project_root: Path) -> Path:
|
||||
return network_root(project_root) / "airndb-captures"
|
||||
|
||||
|
||||
def validation_root(project_root: Path) -> Path:
|
||||
return docs_root(project_root) / "validation"
|
||||
|
||||
|
||||
def staticanalysis_path(project_root: Path) -> Path:
|
||||
return docs_root(project_root) / "staticanalysis.md"
|
||||
|
||||
|
||||
def plugin_state_root(project_root: Path, plugin_name: str) -> Path:
|
||||
return state_root(project_root) / plugin_name
|
||||
|
||||
|
||||
def airarc_root(project_root: Path) -> Path:
|
||||
return plugin_state_root(project_root, "airarc")
|
||||
|
||||
|
||||
def aireng_root(project_root: Path) -> Path:
|
||||
return plugin_state_root(project_root, "aireng")
|
||||
|
||||
|
||||
def airdo_root(project_root: Path) -> Path:
|
||||
return plugin_state_root(project_root, "airdo")
|
||||
|
||||
|
||||
def airdbg_root(project_root: Path) -> Path:
|
||||
return plugin_state_root(project_root, "airdbg")
|
||||
|
||||
|
||||
def airndb_root(project_root: Path) -> Path:
|
||||
return plugin_state_root(project_root, "airndb")
|
||||
|
||||
|
||||
def airsdb_root(project_root: Path) -> Path:
|
||||
return plugin_state_root(project_root, "airsdb")
|
||||
|
||||
|
||||
def airxdb_root(project_root: Path) -> Path:
|
||||
return plugin_state_root(project_root, "airxdb")
|
||||
|
||||
|
||||
def required_project_artifacts() -> tuple[str, ...]:
|
||||
return (
|
||||
"AirPlan/AGENTS.md",
|
||||
"AirPlan/docs/architecture/adr",
|
||||
"AirPlan/docs/architecture/c4/module.md",
|
||||
"AirPlan/plan.md",
|
||||
"AirPlan/todo.md",
|
||||
)
|
||||
342
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/project_bootstrap.py
Executable file
342
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/project_bootstrap.py
Executable file
@@ -0,0 +1,342 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from .paths import (
|
||||
agents_path,
|
||||
airplan_root,
|
||||
analysis_requirements_path,
|
||||
architecture_adr_dir,
|
||||
architecture_c4_module_path,
|
||||
architecture_solution_path,
|
||||
debug_log_path,
|
||||
gui_debug_log_path,
|
||||
root_agents_bootstrap_path,
|
||||
state_root,
|
||||
staticanalysis_path,
|
||||
validation_root,
|
||||
)
|
||||
|
||||
AIRARC_BEGIN = "<!-- AIRARC:BEGIN -->"
|
||||
AIRARC_END = "<!-- AIRARC:END -->"
|
||||
AIRENG_BEGIN = "<!-- AIRENG:BEGIN -->"
|
||||
AIRENG_END = "<!-- AIRENG:END -->"
|
||||
AIRDO_BEGIN = "<!-- AIRDO:BEGIN -->"
|
||||
AIRDO_END = "<!-- AIRDO:END -->"
|
||||
|
||||
ROOT_AGENTS_TEMPLATE = """# AGENTS.md
|
||||
|
||||
- Canonical workflow context for this repository lives in `AirPlan/AGENTS.md`.
|
||||
- Always load `AirPlan/AGENTS.md` first for project instructions, workflow rules, plan/todo state, ADR/C4 context, and current Air sync blocks.
|
||||
- Treat `AirPlan/plan.md`, `AirPlan/todo.md`, and `AirPlan/docs/` as the authoritative workflow documents.
|
||||
- Treat `AirPlan/state/` as the authoritative plugin and runtime state root.
|
||||
- This root file is only a bootstrap shim; keep real workflow context maintained inside `AirPlan/AGENTS.md`.
|
||||
"""
|
||||
|
||||
PROJECT_AGENTS_TEMPLATE = f"""# AGENTS.md
|
||||
|
||||
## Workflow Root
|
||||
|
||||
- This project uses `AirPlan/` as the workflow root.
|
||||
- Keep planning, execution state, ADR, C4, validation, debug, and plugin runtime data under `AirPlan/`.
|
||||
- The repo-root `AGENTS.md` only bootstraps into this file.
|
||||
|
||||
{AIRARC_BEGIN}
|
||||
## AirArc Workflow
|
||||
|
||||
1. Use `AirPlan/AGENTS.md` as the canonical project context entry point.
|
||||
2. Load and maintain:
|
||||
- `AirPlan/docs/analysis/requirements.md`
|
||||
- `AirPlan/docs/architecture/solution-architecture.md`
|
||||
- `AirPlan/docs/architecture/c4/module.md`
|
||||
- `AirPlan/docs/architecture/adr/`
|
||||
3. Produce or refine `AirPlan/plan.md` and `AirPlan/todo.md`.
|
||||
4. Keep plans optimized for lower-cost follow-up sessions, including scope, validation, file targets, and parallelization boundaries.
|
||||
5. AirArc is architecture-only: it may plan tasks and edit planning or architecture documents, but it must not write code or implement tasks directly.
|
||||
{AIRARC_END}
|
||||
|
||||
{AIRENG_BEGIN}
|
||||
## AirEng Workflow
|
||||
|
||||
1. Use `/aireng` as the sole scheduler for confirmed execution.
|
||||
2. Prefer `AirPlan/state/airarc/reviews/execution-plan.json`, then `AirPlan/state/airarc/reviews/parallel-review.json`, before falling back to local `AirPlan/todo.md`.
|
||||
3. Dispatch isolated `/airdo` subagents with bounded concurrency instead of defaulting to parent-thread coding.
|
||||
4. Monitor active workers on a 5-minute cadence, merge ready results, and continue later waves automatically when work remains.
|
||||
5. Keep `AirPlan/todo.md`, `AirPlan/plan.md`, `AirPlan/AGENTS.md`, ADR, and C4 docs synchronized during dispatch, monitoring, repair, and merge.
|
||||
6. AirEng owns global debug, XDB, repair, intervention, and document convergence, but it should only intervene directly for hard blockers and must return to scheduler mode immediately afterward.
|
||||
{AIRENG_END}
|
||||
|
||||
{AIRDO_BEGIN}
|
||||
## AirDo Workflow
|
||||
|
||||
1. Use `/airdo` for one narrow task slice from `AirPlan/todo.md`.
|
||||
2. Before editing, load:
|
||||
- `AirPlan/AGENTS.md`
|
||||
- `AirPlan/docs/architecture/adr/`
|
||||
- `AirPlan/docs/architecture/c4/module.md`
|
||||
- `AirPlan/plan.md`
|
||||
- `AirPlan/todo.md`
|
||||
3. Keep task-local progress resumable in `AirPlan/state/airdo/`.
|
||||
4. When AirEng owns orchestration, return shared document changes through `documentUpdates`.
|
||||
5. Route GUI work through AirXDB, debugging through AirDbg, network evidence through AirNDB, and static analysis through AirSDB when needed.
|
||||
{AIRDO_END}
|
||||
"""
|
||||
|
||||
PLAN_TEMPLATE = """# Implementation Plan
|
||||
|
||||
## Read First
|
||||
1. `AirPlan/AGENTS.md`
|
||||
2. `AirPlan/docs/analysis/requirements.md`
|
||||
3. `AirPlan/docs/architecture/solution-architecture.md`
|
||||
4. `AirPlan/docs/architecture/c4/module.md`
|
||||
5. `AirPlan/docs/architecture/adr/`
|
||||
6. `AirPlan/todo.md`
|
||||
|
||||
## Goal
|
||||
- Replace this section with the concrete product or project goal.
|
||||
|
||||
## Constraints
|
||||
- Record technical, organizational, legal, hardware, or platform constraints here.
|
||||
|
||||
## Phases
|
||||
- Add implementation phases once AirArc planning is complete.
|
||||
|
||||
## Validation Strategy
|
||||
- Record build, test, debug, GUI, network, and static-analysis validation commands here.
|
||||
"""
|
||||
|
||||
TODO_TEMPLATE = """# TODO
|
||||
|
||||
Status values: TODO / DOING / DONE / BLOCKED
|
||||
|
||||
| ID | Status | Module | Task | Files/Dirs | Done When | Validation | Static Analysis | ADR/C4 Update |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| T-001 | TODO | Planning | Replace with the first confirmed execution task | `AirPlan/plan.md`, `AirPlan/docs/` | Acceptance criteria are explicit and testable | Record the exact validation command | Record the static-analysis plan or why it is not applicable | Record required ADR or C4 updates |
|
||||
|
||||
## Quality Gates
|
||||
- Run the validation command listed in `AirPlan/plan.md` before marking a task `DONE`.
|
||||
- Keep ADR and C4 docs synchronized whenever architecture, module boundaries, dependencies, or ownership change.
|
||||
- Record skipped validation, residual risk, and follow-up work explicitly.
|
||||
"""
|
||||
|
||||
REQUIREMENTS_TEMPLATE = """# Requirements
|
||||
|
||||
## Product Intent
|
||||
- Replace with the user-visible outcome this repository should deliver.
|
||||
|
||||
## Functional Requirements
|
||||
- Replace with numbered or grouped functional requirements.
|
||||
|
||||
## Constraints
|
||||
- Replace with non-functional constraints, environmental limits, or safety rules.
|
||||
|
||||
## Acceptance Notes
|
||||
- Replace with the most important acceptance criteria and evidence rules.
|
||||
"""
|
||||
|
||||
SOLUTION_ARCHITECTURE_TEMPLATE = """# Solution Architecture
|
||||
|
||||
## Overview
|
||||
- Replace with the top-level architecture summary.
|
||||
|
||||
## Major Components
|
||||
- Replace with the main containers and their responsibilities.
|
||||
|
||||
## Data And Control Flow
|
||||
- Replace with the major interaction paths between components.
|
||||
|
||||
## Key Risks
|
||||
- Replace with the architecture risks, unknowns, and open decisions.
|
||||
"""
|
||||
|
||||
C4_MODULE_TEMPLATE = """# C4 Module
|
||||
|
||||
## System Context
|
||||
- Replace with the project purpose and external actors or systems.
|
||||
|
||||
## Containers
|
||||
- Replace with the main runtime or repository containers.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Responsibility | Public Interfaces | Dependencies | Data Ownership | Quality Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `replace_me` | Replace with the first real module | Replace with interfaces | Replace with dependencies | Replace with owned data | Replace with testing or quality notes |
|
||||
"""
|
||||
|
||||
ADR_TEMPLATE = """# ADR-0001: Use AirPlan As The Workflow Root
|
||||
|
||||
- Status: Accepted
|
||||
- Date: YYYY-MM-DD
|
||||
|
||||
## Context
|
||||
This project needs a durable workflow root for planning, execution state, architecture context, validation evidence, and resumable AI sessions.
|
||||
|
||||
## Decision
|
||||
Store project workflow artifacts under `AirPlan/`, use the repo-root `AGENTS.md` only as a bootstrap shim, and let `aireng` plus `airdo` maintain plan, todo, ADR, and C4 context there.
|
||||
|
||||
## Consequences
|
||||
- Planning and execution context stay resumable across sessions.
|
||||
- Global workflow docs live in one predictable location.
|
||||
- Plugin runtime state does not clutter the main project tree.
|
||||
"""
|
||||
|
||||
DEBUG_LOG_TEMPLATE = """# Debug Log
|
||||
|
||||
- Add reproducible bug investigations, root-cause notes, and validation outcomes here.
|
||||
"""
|
||||
|
||||
GUI_DEBUG_LOG_TEMPLATE = """# GUI Debug Log
|
||||
|
||||
- Add screenshots, GUI observations, Midscene evidence, and visual acceptance notes here.
|
||||
"""
|
||||
|
||||
NETWORK_LOG_TEMPLATE = """# AirNDB Log
|
||||
|
||||
- Add packet-capture commands, pcap paths, network observations, and conclusions here.
|
||||
"""
|
||||
|
||||
STATIC_ANALYSIS_TEMPLATE = """# Static Analysis
|
||||
|
||||
- Add cppcheck or other static-analysis summaries, report paths, and residual risks here.
|
||||
"""
|
||||
|
||||
VALIDATION_README_TEMPLATE = """# Validation Artifacts
|
||||
|
||||
- Save build logs, flash logs, test logs, screenshots, and validation summaries under this directory.
|
||||
"""
|
||||
|
||||
ARTIFACTS_README_TEMPLATE = """# Artifact Output
|
||||
|
||||
- Save generated evidence files in this directory.
|
||||
"""
|
||||
|
||||
|
||||
def _write_if_missing(path: Path, content: str) -> str:
|
||||
if path.exists():
|
||||
return "exists"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content.rstrip() + "\n", encoding="utf-8", newline="\n")
|
||||
return "created"
|
||||
|
||||
|
||||
def _upsert_marked_block(existing: str, begin: str, end: str, block: str) -> Tuple[str, bool]:
|
||||
begin_index = existing.find(begin)
|
||||
end_index = existing.find(end)
|
||||
normalized_block = block.rstrip() + "\n"
|
||||
if begin_index >= 0 and end_index > begin_index:
|
||||
end_index += len(end)
|
||||
updated = existing[:begin_index].rstrip() + "\n\n" + normalized_block + existing[end_index:].lstrip()
|
||||
return updated, updated != existing
|
||||
updated = existing.rstrip() + "\n\n" + normalized_block
|
||||
return updated, True
|
||||
|
||||
|
||||
def _ensure_project_agents(path: Path) -> str:
|
||||
if not path.exists():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(PROJECT_AGENTS_TEMPLATE.rstrip() + "\n", encoding="utf-8", newline="\n")
|
||||
return "created"
|
||||
|
||||
original = path.read_text(encoding="utf-8-sig")
|
||||
updated = original
|
||||
changed = False
|
||||
for begin, end, block in [
|
||||
(
|
||||
AIRARC_BEGIN,
|
||||
AIRARC_END,
|
||||
PROJECT_AGENTS_TEMPLATE[
|
||||
PROJECT_AGENTS_TEMPLATE.index(AIRARC_BEGIN) : PROJECT_AGENTS_TEMPLATE.index(AIRARC_END) + len(AIRARC_END)
|
||||
],
|
||||
),
|
||||
(
|
||||
AIRENG_BEGIN,
|
||||
AIRENG_END,
|
||||
PROJECT_AGENTS_TEMPLATE[
|
||||
PROJECT_AGENTS_TEMPLATE.index(AIRENG_BEGIN) : PROJECT_AGENTS_TEMPLATE.index(AIRENG_END) + len(AIRENG_END)
|
||||
],
|
||||
),
|
||||
(
|
||||
AIRDO_BEGIN,
|
||||
AIRDO_END,
|
||||
PROJECT_AGENTS_TEMPLATE[
|
||||
PROJECT_AGENTS_TEMPLATE.index(AIRDO_BEGIN) : PROJECT_AGENTS_TEMPLATE.index(AIRDO_END) + len(AIRDO_END)
|
||||
],
|
||||
),
|
||||
]:
|
||||
updated, block_changed = _upsert_marked_block(updated, begin, end, block)
|
||||
changed = changed or block_changed
|
||||
|
||||
if not changed:
|
||||
return "exists"
|
||||
|
||||
path.write_text(updated.rstrip() + "\n", encoding="utf-8", newline="\n")
|
||||
return "updated"
|
||||
|
||||
|
||||
def ensure_project_bootstrap(project_root: Path) -> Dict[str, str]:
|
||||
airplan_root(project_root).mkdir(parents=True, exist_ok=True)
|
||||
architecture_adr_dir(project_root).mkdir(parents=True, exist_ok=True)
|
||||
architecture_c4_module_path(project_root).parent.mkdir(parents=True, exist_ok=True)
|
||||
analysis_requirements_path(project_root).parent.mkdir(parents=True, exist_ok=True)
|
||||
debug_log_path(project_root).parent.mkdir(parents=True, exist_ok=True)
|
||||
gui_debug_log_path(project_root).parent.mkdir(parents=True, exist_ok=True)
|
||||
(debug_log_path(project_root).parent / "airxdb-artifacts").mkdir(parents=True, exist_ok=True)
|
||||
(airplan_root(project_root) / "docs" / "network" / "airndb-captures").mkdir(parents=True, exist_ok=True)
|
||||
(validation_root(project_root) / "logs").mkdir(parents=True, exist_ok=True)
|
||||
state_root(project_root).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = {
|
||||
str(root_agents_bootstrap_path(project_root)): _write_if_missing(
|
||||
root_agents_bootstrap_path(project_root), ROOT_AGENTS_TEMPLATE
|
||||
),
|
||||
str(agents_path(project_root)): _ensure_project_agents(agents_path(project_root)),
|
||||
str(airplan_root(project_root) / "plan.md"): _write_if_missing(
|
||||
airplan_root(project_root) / "plan.md", PLAN_TEMPLATE
|
||||
),
|
||||
str(airplan_root(project_root) / "todo.md"): _write_if_missing(
|
||||
airplan_root(project_root) / "todo.md", TODO_TEMPLATE
|
||||
),
|
||||
str(analysis_requirements_path(project_root)): _write_if_missing(
|
||||
analysis_requirements_path(project_root), REQUIREMENTS_TEMPLATE
|
||||
),
|
||||
str(architecture_solution_path(project_root)): _write_if_missing(
|
||||
architecture_solution_path(project_root), SOLUTION_ARCHITECTURE_TEMPLATE
|
||||
),
|
||||
str(architecture_c4_module_path(project_root)): _write_if_missing(
|
||||
architecture_c4_module_path(project_root), C4_MODULE_TEMPLATE
|
||||
),
|
||||
str(architecture_adr_dir(project_root) / "ADR-0001-use-airplan-as-the-workflow-root.md"): _write_if_missing(
|
||||
architecture_adr_dir(project_root) / "ADR-0001-use-airplan-as-the-workflow-root.md",
|
||||
ADR_TEMPLATE,
|
||||
),
|
||||
str(debug_log_path(project_root)): _write_if_missing(
|
||||
debug_log_path(project_root), DEBUG_LOG_TEMPLATE
|
||||
),
|
||||
str(gui_debug_log_path(project_root)): _write_if_missing(
|
||||
gui_debug_log_path(project_root), GUI_DEBUG_LOG_TEMPLATE
|
||||
),
|
||||
str(debug_log_path(project_root).parent / "airxdb-artifacts" / "README.md"): _write_if_missing(
|
||||
debug_log_path(project_root).parent / "airxdb-artifacts" / "README.md",
|
||||
ARTIFACTS_README_TEMPLATE,
|
||||
),
|
||||
str(airplan_root(project_root) / "docs" / "network" / "airndb-log.md"): _write_if_missing(
|
||||
airplan_root(project_root) / "docs" / "network" / "airndb-log.md",
|
||||
NETWORK_LOG_TEMPLATE,
|
||||
),
|
||||
str(airplan_root(project_root) / "docs" / "network" / "airndb-captures" / "README.md"): _write_if_missing(
|
||||
airplan_root(project_root) / "docs" / "network" / "airndb-captures" / "README.md",
|
||||
ARTIFACTS_README_TEMPLATE,
|
||||
),
|
||||
str(validation_root(project_root) / "README.md"): _write_if_missing(
|
||||
validation_root(project_root) / "README.md", VALIDATION_README_TEMPLATE
|
||||
),
|
||||
str(validation_root(project_root) / "logs" / "README.md"): _write_if_missing(
|
||||
validation_root(project_root) / "logs" / "README.md", ARTIFACTS_README_TEMPLATE
|
||||
),
|
||||
str(staticanalysis_path(project_root)): _write_if_missing(
|
||||
staticanalysis_path(project_root), STATIC_ANALYSIS_TEMPLATE
|
||||
),
|
||||
}
|
||||
return results
|
||||
373
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/repair_runtime.py
Executable file
373
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/repair_runtime.py
Executable file
@@ -0,0 +1,373 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List
|
||||
|
||||
from .contracts import DEFAULT_REPAIR_POLICY, RepairAttempt, WorkerResult, now_iso
|
||||
from .paths import (
|
||||
airdo_root,
|
||||
aireng_root,
|
||||
debug_log_path,
|
||||
gui_debug_log_path,
|
||||
plan_path,
|
||||
todo_path,
|
||||
)
|
||||
|
||||
|
||||
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_repair_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]:
|
||||
merged = dict(DEFAULT_REPAIR_POLICY)
|
||||
if policy:
|
||||
merged.update(policy)
|
||||
|
||||
merged["enabled"] = bool(merged.get("enabled", True))
|
||||
merged["triggerOnBlocked"] = bool(merged.get("triggerOnBlocked", True))
|
||||
merged["autoPrepareWorker"] = bool(merged.get("autoPrepareWorker", True))
|
||||
merged["triggerValidationStatuses"] = _ordered_unique(
|
||||
str(item).lower() for item in list(merged.get("triggerValidationStatuses", []))
|
||||
)
|
||||
try:
|
||||
max_attempts = int(merged.get("maxAttemptsPerTask", 2) or 2)
|
||||
except (TypeError, ValueError):
|
||||
max_attempts = 2
|
||||
merged["maxAttemptsPerTask"] = max(1, max_attempts)
|
||||
|
||||
requeue_status = str(merged.get("requeueTodoStatus", "DOING")).strip().upper()
|
||||
if requeue_status not in {"TODO", "DOING"}:
|
||||
requeue_status = "DOING"
|
||||
merged["requeueTodoStatus"] = requeue_status
|
||||
return merged
|
||||
|
||||
|
||||
def load_repair_policy(project_root: Path) -> Dict[str, object]:
|
||||
state_path = aireng_root(project_root) / "state.json"
|
||||
state = _json_load(state_path)
|
||||
return normalize_repair_policy(state.get("repairPolicy", {}))
|
||||
|
||||
|
||||
def _repair_root(project_root: Path) -> Path:
|
||||
return aireng_root(project_root) / "repairs"
|
||||
|
||||
|
||||
def _repair_queue_path(project_root: Path) -> Path:
|
||||
return aireng_root(project_root) / "repair-queue.md"
|
||||
|
||||
|
||||
def _repair_attempt_path(project_root: Path, task_id: str, repair_id: str) -> Path:
|
||||
return _repair_root(project_root) / task_id / repair_id / "attempt.json"
|
||||
|
||||
|
||||
def _repair_brief_path(project_root: Path, task_id: str, repair_id: str) -> Path:
|
||||
return _repair_root(project_root) / task_id / repair_id / "repair-brief.md"
|
||||
|
||||
|
||||
def _worker_repair_brief_path(project_root: Path, task_id: str) -> Path:
|
||||
return airdo_root(project_root) / "tasks" / task_id / "repair-brief.md"
|
||||
|
||||
|
||||
def _repair_reasons(result: WorkerResult, policy: Dict[str, object]) -> List[str]:
|
||||
reasons: List[str] = []
|
||||
if result.status == "blocked" and bool(policy.get("triggerOnBlocked", True)):
|
||||
reasons.append("blocked worker result requires automatic repair")
|
||||
tracked_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_statuses
|
||||
]
|
||||
if failed_validations:
|
||||
reasons.append(
|
||||
"validation failures: "
|
||||
+ ", ".join(f"{item.kind}:{item.status}" for item in failed_validations)
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def _build_repair_brief(project_root: Path, result: WorkerResult, attempt: RepairAttempt) -> str:
|
||||
debug_session_ids = ", ".join(f"`{item}`" for item in attempt.debug_session_ids) or "`none`"
|
||||
xdb_session_ids = ", ".join(f"`{item.session_id}`" for item in result.xdb_sessions) or "`none`"
|
||||
validations = (
|
||||
"; ".join(
|
||||
f"{item.kind}:{item.status}" + (f" ({item.command})" if item.command else "")
|
||||
for item in result.validations
|
||||
)
|
||||
or "none"
|
||||
)
|
||||
evidence = ", ".join(f"`{item}`" for item in result.evidence_paths) or "`none`"
|
||||
blockers = ", ".join(result.blockers) or "none"
|
||||
risks = ", ".join(result.risks) or "none"
|
||||
return "\n".join(
|
||||
[
|
||||
f"# Auto Repair Brief: {attempt.repair_id}",
|
||||
"",
|
||||
f"- Task ID: `{result.task_id}`",
|
||||
f"- Attempt Index: `{attempt.attempt_index}`",
|
||||
f"- Source Status: `{result.status}`",
|
||||
f"- Reason: {attempt.reason}",
|
||||
f"- Debug Sessions: {debug_session_ids}",
|
||||
f"- Source Result Path: `{attempt.source_result_path}`",
|
||||
"",
|
||||
"## Required Context",
|
||||
"",
|
||||
f"- Load `{project_root / 'AirPlan' / 'AGENTS.md'}`, `{project_root / 'AirPlan' / 'docs' / 'architecture' / 'adr'}`, `{project_root / 'AirPlan' / 'docs' / 'architecture' / 'c4' / 'module.md'}`, `{plan_path(project_root)}`, `{todo_path(project_root)}`, `{debug_log_path(project_root)}`, and `{gui_debug_log_path(project_root)}` when present.",
|
||||
"- Read the referenced AirDbg request/session artifacts before changing code.",
|
||||
"- If this task has AirXDB evidence, read the GUI screenshots and report before changing code.",
|
||||
"- Continue repairing automatically. Do not stop at the existence of a debug request.",
|
||||
"- Only return `blocked` again when the repair budget is exhausted or a real user decision is required.",
|
||||
"",
|
||||
"## Failure Snapshot",
|
||||
"",
|
||||
f"- Summary: {result.summary}",
|
||||
f"- Validations: {validations}",
|
||||
f"- Blockers: {blockers}",
|
||||
f"- Evidence: {evidence}",
|
||||
f"- XDB Sessions: {xdb_session_ids}",
|
||||
f"- Risks: {risks}",
|
||||
"",
|
||||
"## Repair Goal",
|
||||
"",
|
||||
"- Modify the scoped code until the original validation path passes again.",
|
||||
"- Keep the same task id and reuse the existing debug session id in the final repaired result.",
|
||||
"- Update document sync content if the final fix changes architecture or execution rules.",
|
||||
"",
|
||||
f"- Project Root: `{project_root}`",
|
||||
]
|
||||
) + "\n"
|
||||
|
||||
|
||||
def _write_repair_attempt_artifacts(
|
||||
project_root: Path, result: WorkerResult, attempt: RepairAttempt
|
||||
) -> RepairAttempt:
|
||||
attempt_path = Path(attempt.state_path)
|
||||
brief_path = Path(attempt.repair_brief_path)
|
||||
worker_brief_path = Path(attempt.worker_brief_path)
|
||||
|
||||
brief_content = _build_repair_brief(project_root, result, attempt)
|
||||
brief_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
brief_path.write_text(brief_content, encoding="utf-8")
|
||||
|
||||
worker_brief_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
worker_brief_path.write_text(brief_content, encoding="utf-8")
|
||||
|
||||
_json_dump(
|
||||
attempt_path,
|
||||
{
|
||||
"repairAttempt": attempt.to_dict(),
|
||||
"sourceSummary": result.summary,
|
||||
"sourceBlockers": result.blockers,
|
||||
"sourceValidations": [item.to_dict() for item in result.validations],
|
||||
"sourceEvidencePaths": result.evidence_paths,
|
||||
},
|
||||
)
|
||||
return attempt
|
||||
|
||||
|
||||
def list_repair_attempts(project_root: Path, task_id: str = "") -> List[RepairAttempt]:
|
||||
repair_root = _repair_root(project_root)
|
||||
if not repair_root.exists():
|
||||
return []
|
||||
|
||||
attempts: List[RepairAttempt] = []
|
||||
search_root = repair_root / task_id if task_id else repair_root
|
||||
if not search_root.exists():
|
||||
return []
|
||||
|
||||
for path in sorted(search_root.rglob("attempt.json")):
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
attempt_payload = payload.get("repairAttempt", payload)
|
||||
attempt = RepairAttempt.from_dict(attempt_payload)
|
||||
attempt.validate()
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
continue
|
||||
if task_id and attempt.task_id != task_id:
|
||||
continue
|
||||
attempts.append(attempt)
|
||||
|
||||
attempts.sort(key=lambda item: (item.created_at, item.repair_id))
|
||||
return attempts
|
||||
|
||||
|
||||
def load_active_repair_attempt(project_root: Path, task_id: str) -> RepairAttempt | None:
|
||||
for attempt in reversed(list_repair_attempts(project_root, task_id)):
|
||||
if attempt.status in {"queued", "active"}:
|
||||
return attempt
|
||||
return None
|
||||
|
||||
|
||||
def _set_attempt_status(attempt: RepairAttempt, status: str) -> RepairAttempt:
|
||||
attempt.status = status
|
||||
attempt_path = Path(attempt.state_path)
|
||||
if attempt_path.exists():
|
||||
payload = json.loads(attempt_path.read_text(encoding="utf-8-sig"))
|
||||
payload["repairAttempt"] = attempt.to_dict()
|
||||
_json_dump(attempt_path, payload)
|
||||
return attempt
|
||||
|
||||
|
||||
def ensure_repair_attempts_for_result(
|
||||
project_root: Path, result: WorkerResult, source_result_path: str = ""
|
||||
) -> List[RepairAttempt]:
|
||||
policy = load_repair_policy(project_root)
|
||||
if not bool(policy.get("enabled", True)):
|
||||
return result.repair_attempts
|
||||
|
||||
if result.repair_attempts:
|
||||
return result.repair_attempts
|
||||
|
||||
existing_attempts = list_repair_attempts(project_root, result.task_id)
|
||||
active_attempts = [
|
||||
item for item in existing_attempts if item.status in {"queued", "active"}
|
||||
]
|
||||
|
||||
if result.status == "done":
|
||||
if active_attempts:
|
||||
resolved_attempts = []
|
||||
for attempt in active_attempts:
|
||||
resolved_attempts.append(_set_attempt_status(attempt, "resolved"))
|
||||
result.repair_attempts = resolved_attempts
|
||||
return result.repair_attempts
|
||||
|
||||
reasons = _repair_reasons(result, policy)
|
||||
if not reasons:
|
||||
return result.repair_attempts
|
||||
|
||||
if active_attempts:
|
||||
result.repair_attempts = active_attempts
|
||||
return result.repair_attempts
|
||||
|
||||
if len(existing_attempts) >= int(policy.get("maxAttemptsPerTask", 2) or 2):
|
||||
result.notes.append(
|
||||
f"Automatic repair budget exhausted for {result.task_id}; user decision may be required."
|
||||
)
|
||||
return result.repair_attempts
|
||||
|
||||
attempt_index = len(existing_attempts) + 1
|
||||
repair_id = f"{result.task_id}-repair-{attempt_index:03d}"
|
||||
created_at = now_iso()
|
||||
attempt = RepairAttempt(
|
||||
repair_id=repair_id,
|
||||
task_id=result.task_id,
|
||||
attempt_index=attempt_index,
|
||||
source_status=result.status,
|
||||
source_result_path=source_result_path or "",
|
||||
reason="; ".join(reasons),
|
||||
repair_brief_path=str(_repair_brief_path(project_root, result.task_id, repair_id)),
|
||||
state_path=str(_repair_attempt_path(project_root, result.task_id, repair_id)),
|
||||
worker_brief_path=str(_worker_repair_brief_path(project_root, result.task_id)),
|
||||
debug_session_ids=_ordered_unique(
|
||||
item.session_id for item in result.debug_sessions if item.session_id
|
||||
),
|
||||
status="queued",
|
||||
created_at=created_at,
|
||||
)
|
||||
_write_repair_attempt_artifacts(project_root, result, attempt)
|
||||
result.repair_attempts = [attempt]
|
||||
result.notes.append(
|
||||
f"Automatic repair attempt queued for {result.task_id}: {attempt.repair_id}"
|
||||
)
|
||||
return result.repair_attempts
|
||||
|
||||
|
||||
def mark_repair_attempts_active(project_root: Path, task_id: str) -> List[RepairAttempt]:
|
||||
updated: List[RepairAttempt] = []
|
||||
for attempt in list_repair_attempts(project_root, task_id):
|
||||
if attempt.status == "queued":
|
||||
updated.append(_set_attempt_status(attempt, "active"))
|
||||
return updated
|
||||
|
||||
|
||||
def merge_repair_attempts_into_state(
|
||||
state: Dict[str, object], repair_attempts: List[RepairAttempt]
|
||||
) -> Dict[str, object]:
|
||||
state["repairPolicy"] = normalize_repair_policy(dict(state.get("repairPolicy", {})))
|
||||
|
||||
existing_items = list(state.get("repairAttempts", []))
|
||||
items_by_id = {}
|
||||
for item in existing_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
repair_id = str(item.get("repairId", "")).strip()
|
||||
if repair_id:
|
||||
items_by_id[repair_id] = item
|
||||
|
||||
for attempt in repair_attempts:
|
||||
items_by_id[attempt.repair_id] = attempt.to_dict()
|
||||
|
||||
merged_items = [items_by_id[key] for key in sorted(items_by_id)]
|
||||
state["repairAttempts"] = merged_items
|
||||
state["activeRepairCount"] = sum(
|
||||
1 for item in merged_items if str(item.get("status", "")).strip() in {"queued", "active"}
|
||||
)
|
||||
if repair_attempts:
|
||||
latest = repair_attempts[-1]
|
||||
state["lastRepairAttemptId"] = latest.repair_id
|
||||
state["lastRepairTaskId"] = latest.task_id
|
||||
state["lastRepairAt"] = latest.created_at
|
||||
return state
|
||||
|
||||
|
||||
def summarize_active_repairs(state: Dict[str, object]) -> List[Dict[str, str]]:
|
||||
summary: List[Dict[str, str]] = []
|
||||
for item in list(state.get("repairAttempts", [])):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
status = str(item.get("status", "")).strip()
|
||||
if status not in {"queued", "active"}:
|
||||
continue
|
||||
summary.append(
|
||||
{
|
||||
"repairId": str(item.get("repairId", "")).strip(),
|
||||
"taskId": str(item.get("taskId", "")).strip(),
|
||||
"status": status,
|
||||
"reason": str(item.get("reason", "")).strip(),
|
||||
"repairBriefPath": str(item.get("repairBriefPath", "")).strip(),
|
||||
}
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def write_repair_queue(project_root: Path, state: Dict[str, object]) -> Path:
|
||||
queue_path = _repair_queue_path(project_root)
|
||||
active_items = summarize_active_repairs(state)
|
||||
lines = [
|
||||
"# Air Engine Auto Repair Queue",
|
||||
"",
|
||||
"This file lists repair attempts that were automatically queued from blocked or failed worker results.",
|
||||
"",
|
||||
]
|
||||
if active_items:
|
||||
for item in active_items:
|
||||
lines.append(f"- Repair: `{item['repairId']}`")
|
||||
lines.append(f" Task: `{item['taskId']}`")
|
||||
lines.append(f" Status: `{item['status']}`")
|
||||
lines.append(f" Reason: {item['reason'] or 'n/a'}")
|
||||
lines.append(f" Brief: `{item['repairBriefPath']}`")
|
||||
else:
|
||||
lines.append("- No active auto repair attempts.")
|
||||
|
||||
queue_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return queue_path
|
||||
238
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/review.py
Executable file
238
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/review.py
Executable file
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import combinations
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Set
|
||||
|
||||
from .contracts import (
|
||||
ACTIVE_TASK_STATUSES,
|
||||
ParallelGroup,
|
||||
ParallelReview,
|
||||
ReviewConflict,
|
||||
TaskRecord,
|
||||
now_iso,
|
||||
)
|
||||
from .todo_parser import parse_tasks
|
||||
|
||||
|
||||
def _overlap_paths(left: Iterable[str], right: Iterable[str]) -> List[str]:
|
||||
overlap: Set[str] = set()
|
||||
left_items = list(left)
|
||||
right_items = list(right)
|
||||
|
||||
for left_path in left_items:
|
||||
for right_path in right_items:
|
||||
left_clean = left_path.rstrip("/\\")
|
||||
right_clean = right_path.rstrip("/\\")
|
||||
if left_clean == right_clean:
|
||||
overlap.add(left_path)
|
||||
continue
|
||||
if left_clean.startswith(right_clean):
|
||||
overlap.add(right_path)
|
||||
elif right_clean.startswith(left_clean):
|
||||
overlap.add(left_path)
|
||||
|
||||
return sorted(overlap)
|
||||
|
||||
|
||||
def _task_conflict(left: TaskRecord, right: TaskRecord) -> ReviewConflict | None:
|
||||
overlap = _overlap_paths(left.normalized_write_set(), right.normalized_write_set())
|
||||
if overlap:
|
||||
return ReviewConflict(
|
||||
task_ids=[left.task_id, right.task_id],
|
||||
reason="shared write set overlap",
|
||||
overlap_paths=overlap,
|
||||
)
|
||||
|
||||
if left.touches_global_docs() and right.touches_global_docs():
|
||||
return ReviewConflict(
|
||||
task_ids=[left.task_id, right.task_id],
|
||||
reason="both tasks touch global planning or architecture documents",
|
||||
overlap_paths=sorted(set(left.global_doc_paths + right.global_doc_paths)),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _greedy_parallel_groups(tasks: List[TaskRecord]) -> List[ParallelGroup]:
|
||||
groups: List[List[TaskRecord]] = []
|
||||
for task in tasks:
|
||||
placed = False
|
||||
for group in groups:
|
||||
if all(_task_conflict(task, existing) is None for existing in group):
|
||||
group.append(task)
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
groups.append([task])
|
||||
|
||||
output: List[ParallelGroup] = []
|
||||
for index, group in enumerate(groups, start=1):
|
||||
output.append(
|
||||
ParallelGroup(
|
||||
name=f"group-{index}",
|
||||
task_ids=[task.task_id for task in group],
|
||||
reason="no detected write-set conflict inside this group",
|
||||
)
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def _build_dependency_edges(tasks: List[TaskRecord]) -> List[Dict[str, str]]:
|
||||
task_ids = {task.task_id for task in tasks}
|
||||
edges: List[Dict[str, str]] = []
|
||||
for task in tasks:
|
||||
for dependency in task.dependencies:
|
||||
if dependency in task_ids:
|
||||
edges.append({"from": dependency, "to": task.task_id})
|
||||
return edges
|
||||
|
||||
|
||||
def build_parallel_review(todo_path: Path) -> ParallelReview:
|
||||
all_tasks = parse_tasks(todo_path)
|
||||
active_tasks = [task for task in all_tasks if task.status in ACTIVE_TASK_STATUSES]
|
||||
active_task_ids = {task.task_id for task in active_tasks}
|
||||
task_map = {task.task_id: task for task in active_tasks}
|
||||
edges = _build_dependency_edges(active_tasks)
|
||||
|
||||
in_degree = {task.task_id: 0 for task in active_tasks}
|
||||
children: Dict[str, List[str]] = {task.task_id: [] for task in active_tasks}
|
||||
for edge in edges:
|
||||
parent = edge["from"]
|
||||
child = edge["to"]
|
||||
if parent not in active_task_ids or child not in active_task_ids:
|
||||
continue
|
||||
in_degree[child] += 1
|
||||
children[parent].append(child)
|
||||
|
||||
ready = sorted(
|
||||
[task.task_id for task in active_tasks if in_degree[task.task_id] == 0],
|
||||
key=lambda task_id: task_map[task_id].line_number,
|
||||
)
|
||||
scheduled = set()
|
||||
parallel_groups: List[ParallelGroup] = []
|
||||
wave_index = 1
|
||||
|
||||
while ready:
|
||||
current_wave_ids = ready
|
||||
ready = []
|
||||
current_tasks = [task_map[task_id] for task_id in current_wave_ids]
|
||||
wave_groups = _greedy_parallel_groups(current_tasks)
|
||||
for group in wave_groups:
|
||||
group.name = f"wave-{wave_index}-{group.name}"
|
||||
parallel_groups.extend(wave_groups)
|
||||
wave_index += 1
|
||||
|
||||
for task_id in current_wave_ids:
|
||||
scheduled.add(task_id)
|
||||
for child in children.get(task_id, []):
|
||||
in_degree[child] -= 1
|
||||
if in_degree[child] == 0:
|
||||
ready.append(child)
|
||||
ready.sort(key=lambda task_id: task_map[task_id].line_number)
|
||||
|
||||
notes: List[str] = []
|
||||
unscheduled = sorted(active_task_ids - scheduled)
|
||||
if unscheduled:
|
||||
notes.append(
|
||||
"Some active tasks could not be layered. Check for cyclic or missing dependencies: "
|
||||
+ ", ".join(unscheduled)
|
||||
)
|
||||
|
||||
if all(not task.dependencies for task in active_tasks) and len(active_tasks) > 1:
|
||||
notes.append(
|
||||
"No explicit dependency hints were found. Parallel grouping relies on write-set isolation and global-doc serialization rules."
|
||||
)
|
||||
|
||||
blocked_tasks = [task.task_id for task in all_tasks if task.status == "BLOCKED"]
|
||||
if blocked_tasks:
|
||||
notes.append("Blocked tasks were excluded from ready groups: " + ", ".join(blocked_tasks))
|
||||
|
||||
conflicts: List[ReviewConflict] = []
|
||||
for left, right in combinations(active_tasks, 2):
|
||||
conflict = _task_conflict(left, right)
|
||||
if conflict is not None:
|
||||
conflicts.append(conflict)
|
||||
|
||||
serialization_points: List[Dict[str, object]] = []
|
||||
for task in active_tasks:
|
||||
reasons: List[str] = []
|
||||
if task.touches_global_docs():
|
||||
reasons.append("touches global planning or architecture documents")
|
||||
if any(task.task_id in conflict.task_ids for conflict in conflicts):
|
||||
reasons.append("has shared write-set conflicts that need engine-level scheduling")
|
||||
if reasons:
|
||||
serialization_points.append(
|
||||
{
|
||||
"taskId": task.task_id,
|
||||
"reasons": reasons,
|
||||
"paths": task.global_doc_paths or task.normalized_write_set(),
|
||||
}
|
||||
)
|
||||
|
||||
return ParallelReview(
|
||||
source_todo=str(todo_path),
|
||||
generated_at=now_iso(),
|
||||
tasks_considered=active_tasks,
|
||||
dependency_edges=edges,
|
||||
parallel_groups=parallel_groups,
|
||||
conflicts=conflicts,
|
||||
serialization_points=serialization_points,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def render_review_markdown(review: ParallelReview) -> str:
|
||||
lines = [
|
||||
"# AirArc Parallel Review",
|
||||
"",
|
||||
f"- Source TODO: `{review.source_todo}`",
|
||||
f"- Generated At: `{review.generated_at}`",
|
||||
f"- Active Tasks: `{', '.join(task.task_id for task in review.tasks_considered) or 'none'}`",
|
||||
"",
|
||||
"## Parallel Groups",
|
||||
]
|
||||
|
||||
if review.parallel_groups:
|
||||
for group in review.parallel_groups:
|
||||
lines.append(f"- `{group.name}`: {', '.join(group.task_ids)}")
|
||||
lines.append(f" Reason: {group.reason}")
|
||||
else:
|
||||
lines.append("- No ready parallel groups were detected.")
|
||||
|
||||
lines.extend(["", "## Dependency Edges"])
|
||||
if review.dependency_edges:
|
||||
for edge in review.dependency_edges:
|
||||
lines.append(f"- `{edge['from']}` -> `{edge['to']}`")
|
||||
else:
|
||||
lines.append("- No explicit dependency edges were detected.")
|
||||
|
||||
lines.extend(["", "## Shared-Write Conflicts"])
|
||||
if review.conflicts:
|
||||
for conflict in review.conflicts:
|
||||
lines.append(
|
||||
f"- `{conflict.task_ids[0]}` <-> `{conflict.task_ids[1]}`: {conflict.reason}"
|
||||
)
|
||||
if conflict.overlap_paths:
|
||||
lines.append(" Paths: " + ", ".join(f"`{path}`" for path in conflict.overlap_paths))
|
||||
else:
|
||||
lines.append("- No shared-write conflicts were detected.")
|
||||
|
||||
lines.extend(["", "## Serialization Points"])
|
||||
if review.serialization_points:
|
||||
for point in review.serialization_points:
|
||||
lines.append(f"- `{point['taskId']}`: {'; '.join(point['reasons'])}")
|
||||
paths = point.get("paths", [])
|
||||
if paths:
|
||||
lines.append(" Paths: " + ", ".join(f"`{path}`" for path in paths))
|
||||
else:
|
||||
lines.append("- No serialization points were detected.")
|
||||
|
||||
lines.extend(["", "## Notes"])
|
||||
if review.notes:
|
||||
for note in review.notes:
|
||||
lines.append(f"- {note}")
|
||||
else:
|
||||
lines.append("- No extra notes.")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
160
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/todo_parser.py
Executable file
160
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/todo_parser.py
Executable file
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .contracts import TaskRecord
|
||||
|
||||
TASK_ID_RE = re.compile(r"T-\d+")
|
||||
HEADER_RE = re.compile(r"^\|\s*ID\s*\|\s*Status\s*\|", re.IGNORECASE)
|
||||
SEPARATOR_RE = re.compile(r"^\|\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?$")
|
||||
CODE_SPAN_RE = re.compile(r"`([^`]+)`")
|
||||
DEPENDENCY_HINT_RE = re.compile(r"\[(?:deps?|depends)\s*:\s*([^\]]+)\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _split_row(line: str) -> List[str]:
|
||||
return [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
|
||||
|
||||
def _normalize_paths(items: List[str]) -> List[str]:
|
||||
seen = set()
|
||||
ordered: List[str] = []
|
||||
for item in items:
|
||||
cleaned = item.strip().strip("`")
|
||||
if not cleaned or cleaned.lower() == "planned":
|
||||
continue
|
||||
if cleaned in seen:
|
||||
continue
|
||||
seen.add(cleaned)
|
||||
ordered.append(cleaned)
|
||||
return ordered
|
||||
|
||||
|
||||
def extract_paths(cell_text: str) -> List[str]:
|
||||
code_paths = CODE_SPAN_RE.findall(cell_text)
|
||||
if code_paths:
|
||||
return _normalize_paths(code_paths)
|
||||
|
||||
candidates = re.split(r"[,;+]", cell_text)
|
||||
paths = [
|
||||
token.strip()
|
||||
for token in candidates
|
||||
if "/" in token or "\\" in token or token.endswith((".md", ".py", ".json"))
|
||||
]
|
||||
return _normalize_paths(paths)
|
||||
|
||||
|
||||
def extract_dependencies(*cells: str) -> List[str]:
|
||||
found: List[str] = []
|
||||
for cell in cells:
|
||||
for match in DEPENDENCY_HINT_RE.finditer(cell):
|
||||
found.extend(TASK_ID_RE.findall(match.group(1)))
|
||||
return _normalize_paths(found)
|
||||
|
||||
|
||||
def extract_global_doc_paths(*cells: str) -> List[str]:
|
||||
text = " ".join(cells)
|
||||
candidates: List[str] = []
|
||||
lowered = text.lower()
|
||||
|
||||
if "agents.md" in lowered or "agents" in text:
|
||||
candidates.append("AirPlan/AGENTS.md")
|
||||
if "airplan/docs/architecture/adr" in lowered or "docs/architecture/adr" in lowered or "adr" in text:
|
||||
candidates.append("AirPlan/docs/architecture/adr/")
|
||||
if "airplan/docs/architecture/c4/module.md" in lowered or "docs/architecture/c4/module.md" in lowered or "c4" in text:
|
||||
candidates.append("AirPlan/docs/architecture/c4/module.md")
|
||||
if "plan.md" in lowered:
|
||||
candidates.append("AirPlan/plan.md")
|
||||
if "todo.md" in lowered or "todo" in text:
|
||||
candidates.append("AirPlan/todo.md")
|
||||
if "staticanalysis.md" in lowered:
|
||||
candidates.append("AirPlan/docs/staticanalysis.md")
|
||||
|
||||
for path in extract_paths(text):
|
||||
normalized = path.replace("\\", "/")
|
||||
if normalized in {"AGENTS.md", "AirPlan/AGENTS.md"}:
|
||||
candidates.append("AirPlan/AGENTS.md")
|
||||
elif "AirPlan/docs/architecture/adr" in normalized or "docs/architecture/adr" in normalized:
|
||||
candidates.append("AirPlan/docs/architecture/adr/")
|
||||
elif "AirPlan/docs/architecture/c4/module.md" in normalized or "docs/architecture/c4/module.md" in normalized:
|
||||
candidates.append("AirPlan/docs/architecture/c4/module.md")
|
||||
elif normalized in {"plan.md", "AirPlan/plan.md"}:
|
||||
candidates.append("AirPlan/plan.md")
|
||||
elif normalized in {"todo.md", "AirPlan/todo.md"}:
|
||||
candidates.append("AirPlan/todo.md")
|
||||
elif normalized in {"staticanalysis.md", "AirPlan/docs/staticanalysis.md"}:
|
||||
candidates.append("AirPlan/docs/staticanalysis.md")
|
||||
|
||||
return _normalize_paths(candidates)
|
||||
|
||||
|
||||
def parse_tasks(todo_path: Path) -> List[TaskRecord]:
|
||||
lines = todo_path.read_text(encoding="utf-8").splitlines()
|
||||
header_index: Optional[int] = None
|
||||
|
||||
for idx, line in enumerate(lines):
|
||||
if HEADER_RE.search(line):
|
||||
header_index = idx
|
||||
break
|
||||
|
||||
if header_index is None:
|
||||
raise ValueError(f"no TODO task table found in {todo_path}")
|
||||
|
||||
header_cells = _split_row(lines[header_index])
|
||||
tasks: List[TaskRecord] = []
|
||||
|
||||
for line_number in range(header_index + 1, len(lines)):
|
||||
raw_line = lines[line_number]
|
||||
if not raw_line.strip():
|
||||
if tasks:
|
||||
break
|
||||
continue
|
||||
if not raw_line.strip().startswith("|"):
|
||||
if tasks:
|
||||
break
|
||||
continue
|
||||
if SEPARATOR_RE.match(raw_line):
|
||||
continue
|
||||
|
||||
row_cells = _split_row(raw_line)
|
||||
if len(row_cells) < len(header_cells):
|
||||
row_cells += [""] * (len(header_cells) - len(row_cells))
|
||||
|
||||
row: Dict[str, str] = dict(zip(header_cells, row_cells))
|
||||
task_id = row.get("ID", "").strip()
|
||||
if not task_id:
|
||||
continue
|
||||
|
||||
files_dirs = row.get("Files/Dirs", "").strip()
|
||||
adr_c4_update = row.get("ADR/C4 Update", "").strip()
|
||||
task_text = row.get("Task", "").strip()
|
||||
validation = row.get("Validation", "").strip()
|
||||
|
||||
tasks.append(
|
||||
TaskRecord(
|
||||
task_id=task_id,
|
||||
status=row.get("Status", "").strip().upper(),
|
||||
module=row.get("Module", "").strip(),
|
||||
task=task_text,
|
||||
files_dirs=files_dirs,
|
||||
done_when=row.get("Done When", "").strip(),
|
||||
validation=validation,
|
||||
adr_c4_update=adr_c4_update,
|
||||
line_number=line_number + 1,
|
||||
dependencies=extract_dependencies(task_text, files_dirs, validation, adr_c4_update),
|
||||
write_paths=extract_paths(files_dirs),
|
||||
global_doc_paths=extract_global_doc_paths(
|
||||
files_dirs, adr_c4_update, task_text
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def find_task(tasks: List[TaskRecord], task_id: str) -> Optional[TaskRecord]:
|
||||
for task in tasks:
|
||||
if task.task_id == task_id:
|
||||
return task
|
||||
return None
|
||||
372
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/worker.py
Executable file
372
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/worker.py
Executable file
@@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from .airxdb_runtime import ensure_xdb_sessions_for_result, task_requires_xdb
|
||||
from .contracts import (
|
||||
DocumentUpdate,
|
||||
WorkerResult,
|
||||
default_worker_result,
|
||||
now_iso,
|
||||
required_project_artifacts,
|
||||
)
|
||||
from .debug_runtime import ensure_debug_sessions_for_result
|
||||
from .doc_sync import enforce_doc_sync_requirements
|
||||
from .paths import (
|
||||
agents_path,
|
||||
airdo_root,
|
||||
architecture_adr_dir,
|
||||
architecture_c4_module_path,
|
||||
plan_path,
|
||||
todo_path,
|
||||
)
|
||||
from .repair_runtime import load_active_repair_attempt
|
||||
from .todo_parser import find_task, parse_tasks
|
||||
|
||||
|
||||
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 _artifact_health(project_root: Path) -> Dict[str, bool]:
|
||||
return {
|
||||
relative: (project_root / relative).exists()
|
||||
for relative in required_project_artifacts()
|
||||
}
|
||||
|
||||
|
||||
def _paths(project_root: Path) -> Dict[str, Path]:
|
||||
worker_root = airdo_root(project_root)
|
||||
return {
|
||||
"root": worker_root,
|
||||
"state": worker_root / "state.json",
|
||||
"tasks": worker_root / "tasks",
|
||||
"results": worker_root / "results",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_layout(project_root: Path) -> Dict[str, Path]:
|
||||
paths = _paths(project_root)
|
||||
paths["tasks"].mkdir(parents=True, exist_ok=True)
|
||||
paths["results"].mkdir(parents=True, exist_ok=True)
|
||||
return paths
|
||||
|
||||
|
||||
def _task_dir(project_root: Path, task_id: str) -> Path:
|
||||
return _paths(project_root)["tasks"] / task_id
|
||||
|
||||
|
||||
def worker_state_path(project_root: Path, task_id: str) -> Path:
|
||||
return _task_dir(project_root, task_id) / "worker-state.json"
|
||||
|
||||
|
||||
def _load_task_record(project_root: Path, task_id: str):
|
||||
current_todo = todo_path(project_root)
|
||||
if not current_todo.exists():
|
||||
return None
|
||||
try:
|
||||
return find_task(parse_tasks(current_todo), task_id)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _worker_rules_block() -> List[str]:
|
||||
return [
|
||||
"## Worker Rules",
|
||||
"",
|
||||
"- Load `AirPlan/AGENTS.md`, `AirPlan/docs/architecture/adr/`, `AirPlan/docs/architecture/c4/module.md`, `AirPlan/plan.md`, and `AirPlan/todo.md` before doing work.",
|
||||
"- Keep the task scoped to this brief.",
|
||||
"- Preserve validation evidence on disk.",
|
||||
"- Do not directly take ownership of global `AirPlan/todo.md`, `AirPlan/AGENTS.md`, ADR, or C4 updates unless explicitly delegated.",
|
||||
"- If this task touches global docs, `result.json` must include executable `documentUpdates`, not just recommendations.",
|
||||
"- If this task is GUI or visual acceptance related, `result.json` must end with successful AirXDB evidence before it can close as done.",
|
||||
"- If the result is blocked, or a validation ends in failed/error, the worker auto-requests AirDbg before finalize.",
|
||||
"- If the task is GUI-related, the worker auto-captures AirXDB evidence before finalize.",
|
||||
"- If an active auto-repair attempt exists, continue repairing immediately instead of stopping at the debug request.",
|
||||
"- Do not stop at an 'about to implement', 'implementation plan ready', or similar midpoint status. Continue editing, validating, and finalizing unless a real blocker or user decision is required.",
|
||||
"- Do not return only a progress update when the task is actionable. Return only after finalize, or after recording a concrete blocked reason that needs intervention.",
|
||||
"- Fill `result.json`, then finalize it for engine merge.",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def _placeholder_result(project_root: Path, task_id: str) -> WorkerResult:
|
||||
task_summary = ""
|
||||
task = _load_task_record(project_root, task_id)
|
||||
if task:
|
||||
task_summary = task.task
|
||||
|
||||
result = default_worker_result(task_id, task_summary)
|
||||
if task and task.global_doc_paths:
|
||||
result.global_doc_paths = list(task.global_doc_paths)
|
||||
result.recommend_global_doc_updates = list(task.global_doc_paths)
|
||||
result.document_updates = []
|
||||
for doc_path in task.global_doc_paths:
|
||||
if doc_path == "AirPlan/docs/architecture/adr/":
|
||||
result.document_updates.append(
|
||||
DocumentUpdate(
|
||||
path="AirPlan/docs/architecture/adr/ADR-XXXX-task-sync.md",
|
||||
action="create_file",
|
||||
content=(
|
||||
"# ADR-XXXX: task-sync\n\n"
|
||||
"- Status: Accepted\n"
|
||||
"- Date: YYYY-MM-DD\n\n"
|
||||
"## Context\n"
|
||||
"Replace with the concrete context introduced by this task.\n\n"
|
||||
"## Decision\n"
|
||||
"Replace with the concrete decision introduced by this task.\n\n"
|
||||
"## Consequences\n"
|
||||
"- Replace with the concrete consequences introduced by this task.\n"
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
result.document_updates.append(
|
||||
DocumentUpdate(
|
||||
path=doc_path,
|
||||
action="replace_block",
|
||||
marker=f"{task_id}-{Path(doc_path).name}".replace(".", "-").upper(),
|
||||
content=(
|
||||
f"## Sync For {task_id}\n\n"
|
||||
"- Summary: Replace with a concrete summary.\n"
|
||||
"- Code Reality: Replace with the actual code outcome.\n"
|
||||
"- Validation: Replace with the actual validation result.\n"
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _is_untouched_placeholder_result(project_root: Path, result: WorkerResult) -> bool:
|
||||
placeholder = _placeholder_result(project_root, result.task_id)
|
||||
return result.to_dict() == placeholder.to_dict()
|
||||
|
||||
|
||||
def resolve_worker_result_path(project_root: Path, task_id: str) -> Path:
|
||||
state_path = worker_state_path(project_root, task_id)
|
||||
if state_path.exists():
|
||||
payload = json.loads(state_path.read_text(encoding="utf-8-sig"))
|
||||
raw_path = str(payload.get("resultPath", "")).strip()
|
||||
if raw_path:
|
||||
candidate = Path(raw_path).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = (project_root / candidate).resolve()
|
||||
return candidate
|
||||
return _task_dir(project_root, task_id) / "result.json"
|
||||
|
||||
|
||||
def _write_brief(project_root: Path, task_id: str) -> Path:
|
||||
task_folder = _task_dir(project_root, task_id)
|
||||
task_folder.mkdir(parents=True, exist_ok=True)
|
||||
task = _load_task_record(project_root, task_id)
|
||||
active_repair = load_active_repair_attempt(project_root, task_id)
|
||||
|
||||
lines: List[str] = [f"# Worker Brief: {task_id}", ""]
|
||||
if task:
|
||||
requires_xdb = task_requires_xdb(task, None)
|
||||
lines.extend(
|
||||
[
|
||||
f"- Module: `{task.module}`",
|
||||
f"- Status In TODO: `{task.status}`",
|
||||
f"- Task: {task.task}",
|
||||
f"- Write Scope: {', '.join(f'`{path}`' for path in task.write_paths) or '`(not declared)`'}",
|
||||
f"- Global Doc Paths: {', '.join(f'`{path}`' for path in task.global_doc_paths) or '`none`'}",
|
||||
f"- Validation: {task.validation or '(not declared)'}",
|
||||
f"- Document Sync Required: {'yes' if task.global_doc_paths else 'no'}",
|
||||
f"- AirXDB Required: {'yes' if requires_xdb else 'no'}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if active_repair:
|
||||
lines.extend(
|
||||
[
|
||||
"## Active Auto Repair",
|
||||
"",
|
||||
f"- Repair ID: `{active_repair.repair_id}`",
|
||||
f"- Attempt Index: `{active_repair.attempt_index}`",
|
||||
f"- Status: `{active_repair.status}`",
|
||||
f"- Reason: {active_repair.reason}",
|
||||
f"- Repair Brief: `{active_repair.repair_brief_path}`",
|
||||
f"- Debug Sessions: {', '.join(f'`{item}`' for item in active_repair.debug_session_ids) or '`none`'}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
lines.extend(_worker_rules_block())
|
||||
brief_path = task_folder / "brief.md"
|
||||
brief_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return brief_path
|
||||
|
||||
|
||||
def _write_handoff(project_root: Path, task_id: str, brief_path: Path, result_path: Path) -> Path:
|
||||
task = _load_task_record(project_root, task_id)
|
||||
current_worker_state_path = worker_state_path(project_root, task_id)
|
||||
handoff_path = _task_dir(project_root, task_id) / "subagent-handoff.md"
|
||||
lines = [
|
||||
f"# AirDo Subagent Handoff: {task_id}",
|
||||
"",
|
||||
"You are an isolated AirDo execution subagent launched by AirEng.",
|
||||
"",
|
||||
f"- Task ID: `{task_id}`",
|
||||
f"- Project Root: `{project_root}`",
|
||||
f"- Brief Path: `{brief_path}`",
|
||||
f"- Worker State Path: `{current_worker_state_path}`",
|
||||
f"- Template Result Path: `{result_path}`",
|
||||
f"- Task Summary: {task.task if task else '(load from brief)'}",
|
||||
f"- Write Scope: {', '.join(f'`{path}`' for path in (task.write_paths if task else [])) or '`(load from brief)`'}",
|
||||
"",
|
||||
"## Required Reads",
|
||||
"",
|
||||
f"- `{agents_path(project_root)}`",
|
||||
f"- `{architecture_adr_dir(project_root)}`",
|
||||
f"- `{architecture_c4_module_path(project_root)}`",
|
||||
f"- `{plan_path(project_root)}`",
|
||||
f"- `{todo_path(project_root)}`",
|
||||
f"- `{brief_path}`",
|
||||
"",
|
||||
"## Execution Contract",
|
||||
"",
|
||||
"- Stay inside this task scope and its declared write set.",
|
||||
"- Do not assume the parent thread history is available; build context from the files above.",
|
||||
"- Execute the task, gather validation evidence, and update `result.json` with honest status, files changed, risks, blockers, and document updates when required.",
|
||||
"- Run AirDbg or AirXDB automatically when the runtime rules require them.",
|
||||
"- Do not pause for intermediate implementation-status replies. Keep going until the task is finalized unless you hit a real blocker that requires external input.",
|
||||
"- Finalize the result before returning control to AirEng.",
|
||||
"",
|
||||
"## Finalize Command",
|
||||
"",
|
||||
f"`python \"$HOME/plugins/airdo/scripts/airdo_mode.py\" --mode finish --project . --task-id {task_id}`",
|
||||
"",
|
||||
"## Return Contract",
|
||||
"",
|
||||
"- Report the final status and the finalized result path.",
|
||||
"- After `finish`, treat `worker-state.json` `resultPath` as the canonical result location instead of re-reading the template `result.json`.",
|
||||
"- Do not merge global docs yourself unless the task explicitly delegated document updates through `result.json`.",
|
||||
"",
|
||||
]
|
||||
handoff_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return handoff_path
|
||||
|
||||
|
||||
def enter_worker(project_root: Path, task_id: str) -> Dict[str, object]:
|
||||
paths = _ensure_layout(project_root)
|
||||
task_folder = _task_dir(project_root, task_id)
|
||||
task_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result_path = task_folder / "result.json"
|
||||
if not result_path.exists():
|
||||
result = _placeholder_result(project_root, task_id)
|
||||
_json_dump(result_path, result.to_dict())
|
||||
|
||||
brief_path = _write_brief(project_root, task_id)
|
||||
handoff_path = _write_handoff(project_root, task_id, brief_path, result_path)
|
||||
state_path = worker_state_path(project_root, task_id)
|
||||
state = {
|
||||
"enabled": True,
|
||||
"updatedAt": now_iso(),
|
||||
"projectRoot": str(project_root),
|
||||
"artifactHealth": _artifact_health(project_root),
|
||||
"activeTaskId": task_id,
|
||||
}
|
||||
_json_dump(paths["state"], state)
|
||||
_json_dump(
|
||||
state_path,
|
||||
{
|
||||
"taskId": task_id,
|
||||
"status": "entered",
|
||||
"enteredAt": now_iso(),
|
||||
"templateResultPath": str(result_path),
|
||||
"resultPath": str(result_path),
|
||||
"briefPath": str(brief_path),
|
||||
"handoffPath": str(handoff_path),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"taskId": task_id,
|
||||
"briefPath": str(brief_path),
|
||||
"handoffPath": str(handoff_path),
|
||||
"resultPath": str(result_path),
|
||||
"workerStatePath": str(state_path),
|
||||
}
|
||||
|
||||
|
||||
def handoff_worker(project_root: Path, task_id: str) -> Dict[str, object]:
|
||||
entered = enter_worker(project_root, task_id)
|
||||
return {
|
||||
"taskId": task_id,
|
||||
"briefPath": entered["briefPath"],
|
||||
"handoffPath": entered["handoffPath"],
|
||||
"resultPath": entered["resultPath"],
|
||||
"workerStatePath": entered["workerStatePath"],
|
||||
}
|
||||
|
||||
|
||||
def finish_worker(project_root: Path, task_id: str, result_path: Path | None) -> Dict[str, object]:
|
||||
_ensure_layout(project_root)
|
||||
task_folder = _task_dir(project_root, task_id)
|
||||
if result_path is None:
|
||||
result_path = task_folder / "result.json"
|
||||
|
||||
result = WorkerResult.from_dict(json.loads(result_path.read_text(encoding="utf-8-sig")))
|
||||
if _is_untouched_placeholder_result(project_root, result):
|
||||
raise ValueError(
|
||||
"worker result is still the untouched default template; update result.json before finalize"
|
||||
)
|
||||
result.validate_for_finalize()
|
||||
ensure_xdb_sessions_for_result(project_root, result, "worker-finish")
|
||||
ensure_debug_sessions_for_result(project_root, result, "worker-finish")
|
||||
result.validate_for_finalize()
|
||||
enforce_doc_sync_requirements(project_root, result)
|
||||
if result.task_id != task_id:
|
||||
raise ValueError("result taskId does not match --task-id")
|
||||
if not result.finalized_at:
|
||||
result.finalized_at = now_iso()
|
||||
|
||||
finalized_path = _paths(project_root)["results"] / f"{task_id}.json"
|
||||
_json_dump(finalized_path, result.to_dict())
|
||||
previous_state: Dict[str, object] = {}
|
||||
current_state_path = worker_state_path(project_root, task_id)
|
||||
if current_state_path.exists():
|
||||
previous_state = json.loads(current_state_path.read_text(encoding="utf-8-sig"))
|
||||
_json_dump(
|
||||
current_state_path,
|
||||
{
|
||||
"taskId": task_id,
|
||||
"status": "completed",
|
||||
"enteredAt": previous_state.get("enteredAt", ""),
|
||||
"briefPath": previous_state.get("briefPath", ""),
|
||||
"handoffPath": previous_state.get("handoffPath", ""),
|
||||
"templateResultPath": previous_state.get("templateResultPath", str(task_folder / "result.json")),
|
||||
"finalizedAt": result.finalized_at,
|
||||
"resultPath": str(finalized_path),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"taskId": task_id,
|
||||
"status": result.status,
|
||||
"finalizedResultPath": str(finalized_path),
|
||||
"workerStatePath": str(current_state_path),
|
||||
}
|
||||
|
||||
|
||||
def status_worker(project_root: Path) -> Dict[str, object]:
|
||||
paths = _ensure_layout(project_root)
|
||||
state_path = paths["state"]
|
||||
enabled = state_path.exists()
|
||||
active_task_id = ""
|
||||
if enabled:
|
||||
payload = json.loads(state_path.read_text(encoding="utf-8-sig"))
|
||||
active_task_id = str(payload.get("activeTaskId", ""))
|
||||
|
||||
task_ids = sorted(path.name for path in paths["tasks"].iterdir() if path.is_dir())
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"projectRoot": str(project_root),
|
||||
"artifactHealth": _artifact_health(project_root),
|
||||
"activeTaskId": active_task_id,
|
||||
"taskIds": task_ids,
|
||||
}
|
||||
Reference in New Issue
Block a user