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

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

351 lines
13 KiB
Python
Executable File

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)
)