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"" end = f"" 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"" end = f"" 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) )