diff --git a/Containerfile b/Containerfile index 6332764..f7248e3 100644 --- a/Containerfile +++ b/Containerfile @@ -12,7 +12,10 @@ WORKDIR /app # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ curl \ + git \ + && update-ca-certificates \ && rm -rf /var/lib/apt/lists/* # Install dependencies diff --git a/ai_software_factory/agents/database_manager.py b/ai_software_factory/agents/database_manager.py index cbd6b75..a7a4dfc 100644 --- a/ai_software_factory/agents/database_manager.py +++ b/ai_software_factory/agents/database_manager.py @@ -2272,6 +2272,7 @@ class DatabaseManager: "timeline": [], "issues": [], "issue_work": [], + "ui_data": {}, } # Get logs @@ -2296,6 +2297,7 @@ class DatabaseManager: llm_traces = self.get_llm_traces(project_id=project_id) correlations = self.get_prompt_change_correlations(project_id=project_id) code_changes, local_only_code_changes, orphan_code_changes = self._partition_code_changes(raw_code_changes, commits) + ui_data = self._get_latest_ui_snapshot_data(history.id) repository = self._get_project_repository(history) timeline = self.get_project_timeline(project_id=project_id) repository_sync = self.get_repository_sync_status(project_id=project_id) @@ -2395,6 +2397,7 @@ class DatabaseManager: "repository_sync": repository_sync, "issues": issues, "issue_work": issue_work, + "ui_data": ui_data, } def get_prompt_events(self, project_id: str | None = None, limit: int = 100) -> list[dict]: diff --git a/ai_software_factory/agents/orchestrator.py b/ai_software_factory/agents/orchestrator.py index 7eca8a2..b36c6fd 100644 --- a/ai_software_factory/agents/orchestrator.py +++ b/ai_software_factory/agents/orchestrator.py @@ -388,7 +388,27 @@ class AgentOrchestrator: }, expect_json=True, ) + raw_generated_paths = self._extract_raw_generated_paths(content) generated_files = self._parse_generated_files(content) + accepted_paths = list(generated_files.keys()) + rejected_paths = [path for path in raw_generated_paths if path not in accepted_paths] + generation_debug = { + 'raw_paths': raw_generated_paths, + 'accepted_paths': accepted_paths, + 'rejected_paths': rejected_paths, + 'existing_workspace': has_existing_files, + } + self.ui_manager.ui_data['generation_debug'] = generation_debug + self._append_log( + 'LLM returned file candidates: ' + f"raw={raw_generated_paths or []}; accepted={accepted_paths or []}; rejected={rejected_paths or []}." + ) + self._log_system_debug( + 'generation', + 'LLM file candidates ' + f"raw={raw_generated_paths or []}; accepted={accepted_paths or []}; rejected={rejected_paths or []}; " + f"existing_workspace={has_existing_files}", + ) if has_existing_files: return generated_files, trace, True merged_files = {**fallback_files, **generated_files} @@ -647,6 +667,35 @@ class AgentOrchestrator: if self.db_manager and self.history: self.db_manager._log_action(self.history.id, "INFO", message) + def _log_system_debug(self, component: str, message: str, level: str = 'INFO') -> None: + """Persist a system-level debug breadcrumb for generation and git decisions.""" + if not self.db_manager: + return + self.db_manager.log_system_event(component=component, level=level, message=f"{self.project_id}: {message}") + + def _extract_raw_generated_paths(self, content: str | None) -> list[str]: + """Return all file paths proposed by the LLM response before safety filtering.""" + if not content: + return [] + try: + parsed = json.loads(content) + except Exception: + return [] + raw_paths: list[str] = [] + if isinstance(parsed, dict) and isinstance(parsed.get('files'), list): + for item in parsed['files']: + if not isinstance(item, dict): + continue + path = str(item.get('path') or '').strip() + if path: + raw_paths.append(path) + elif isinstance(parsed, dict): + for path in parsed.keys(): + normalized_path = str(path).strip() + if normalized_path: + raw_paths.append(normalized_path) + return raw_paths + def _update_progress(self, progress: int, step: str, message: str) -> None: self.progress = progress self.current_step = step @@ -810,11 +859,25 @@ class AgentOrchestrator: async def _commit_to_git(self) -> None: """Commit changes to git.""" unique_files = list(dict.fromkeys(self.changed_files)) + git_debug = self.ui_manager.ui_data.setdefault('git', {}) if not unique_files: + git_debug.update({ + 'commit_status': 'skipped', + 'early_exit_reason': 'changed_files_empty', + 'candidate_files': [], + }) + self._append_log('Git commit skipped: no generated files were marked as changed.') + self._log_system_debug('git', 'Commit exited early because changed_files was empty.') return if not self.git_manager.is_git_available(): - self.ui_manager.ui_data.setdefault('git', {})['error'] = 'git executable is not available in PATH' + git_debug.update({ + 'commit_status': 'error', + 'early_exit_reason': 'git_unavailable', + 'candidate_files': unique_files, + 'error': 'git executable is not available in PATH', + }) self._append_log('Git commit skipped: git executable is not available in PATH') + self._log_system_debug('git', 'Commit exited early because git is unavailable.', level='ERROR') return try: @@ -822,7 +885,23 @@ class AgentOrchestrator: self.git_manager.init_repo() base_commit = self.git_manager.current_head_or_none() self.git_manager.add_files(unique_files) - if not self.git_manager.get_status(): + status_after_add = self.git_manager.get_status() + if not status_after_add: + git_debug.update({ + 'commit_status': 'skipped', + 'early_exit_reason': 'clean_after_staging', + 'candidate_files': unique_files, + 'status_after_add': '', + }) + self._append_log( + 'Git commit skipped: working tree was clean after staging candidate files ' + f'{unique_files}. No repository diff was created.' + ) + self._log_system_debug( + 'git', + 'Commit exited early because git status was clean after staging ' + f'files={unique_files}', + ) return commit_message = f"AI generation for prompt: {self.project_name}" @@ -835,11 +914,17 @@ class AgentOrchestrator: "scope": "local", "branch": self.branch_name, } + git_debug.update({ + 'commit_status': 'committed', + 'early_exit_reason': None, + 'candidate_files': unique_files, + 'status_after_add': status_after_add, + }) remote_record = None try: remote_record = await self._push_remote_commit(commit_hash, commit_message, unique_files, base_commit) except (RuntimeError, subprocess.CalledProcessError, FileNotFoundError) as remote_exc: - self.ui_manager.ui_data.setdefault("git", {})["remote_error"] = str(remote_exc) + git_debug["remote_error"] = str(remote_exc) self._append_log(f"Remote git push skipped: {remote_exc}") if remote_record: @@ -849,8 +934,8 @@ class AgentOrchestrator: if remote_record.get('pull_request'): commit_record['pull_request'] = remote_record['pull_request'] self.ui_manager.ui_data['pull_request'] = remote_record['pull_request'] - self.ui_manager.ui_data.setdefault("git", {})["latest_commit"] = commit_record - self.ui_manager.ui_data.setdefault("git", {})["commits"] = [commit_record] + git_debug["latest_commit"] = commit_record + git_debug["commits"] = [commit_record] self._append_log(f"Recorded git commit {commit_hash[:12]} for generated files.") if self.db_manager: self.db_manager.log_commit( @@ -896,7 +981,12 @@ class AgentOrchestrator: commit_url=remote_record.get('commit_url') if remote_record else None, ) except (RuntimeError, subprocess.CalledProcessError, FileNotFoundError) as exc: - self.ui_manager.ui_data.setdefault("git", {})["error"] = str(exc) + git_debug.update({ + 'commit_status': 'error', + 'early_exit_reason': 'commit_exception', + 'candidate_files': unique_files, + 'error': str(exc), + }) self._append_log(f"Git commit skipped: {exc}") async def _create_pr(self) -> None: diff --git a/ai_software_factory/dashboard_ui.py b/ai_software_factory/dashboard_ui.py index 74959f2..dbbb3c1 100644 --- a/ai_software_factory/dashboard_ui.py +++ b/ai_software_factory/dashboard_ui.py @@ -214,6 +214,55 @@ def _render_commit_list(commits: list[dict]) -> None: ui.link('Open compare view', compare_url, new_tab=True) +def _render_generation_diagnostics(ui_data: dict | None) -> None: + """Render generation and git diagnostics from the latest UI snapshot.""" + snapshot = ui_data if isinstance(ui_data, dict) else {} + generation_debug = snapshot.get('generation_debug') if isinstance(snapshot.get('generation_debug'), dict) else {} + git_debug = snapshot.get('git') if isinstance(snapshot.get('git'), dict) else {} + + if not generation_debug and not git_debug: + ui.label('No generation diagnostics captured yet.').classes('factory-muted') + return + + def _render_path_row(label: str, values: list[str]) -> None: + text = ', '.join(values) if values else 'none' + ui.label(f'{label}: {text}').classes('factory-muted' if values else 'factory-code') + + with ui.column().classes('gap-3 w-full'): + if generation_debug: + with ui.column().classes('gap-1'): + ui.label('Generation filtering').style('font-weight: 700; color: #2f241d;') + ui.label( + 'Existing workspace: ' + + ('yes' if generation_debug.get('existing_workspace') else 'no') + ).classes('factory-muted') + _render_path_row('Raw paths', generation_debug.get('raw_paths') or []) + _render_path_row('Accepted paths', generation_debug.get('accepted_paths') or []) + _render_path_row('Rejected paths', generation_debug.get('rejected_paths') or []) + if git_debug: + with ui.column().classes('gap-1'): + ui.label('Git outcome').style('font-weight: 700; color: #2f241d;') + if git_debug.get('commit_status'): + with ui.row().classes('items-center gap-2'): + ui.label(git_debug['commit_status']).classes('factory-chip') + if git_debug.get('early_exit_reason'): + ui.label(git_debug['early_exit_reason']).classes('factory-chip') + if git_debug.get('candidate_files') is not None: + _render_path_row('Candidate files', git_debug.get('candidate_files') or []) + latest_commit = git_debug.get('latest_commit') if isinstance(git_debug.get('latest_commit'), dict) else {} + if latest_commit: + ui.label( + f"Latest commit: {(latest_commit.get('hash') or 'unknown')[:12]} ยท {latest_commit.get('scope') or 'local'}" + ).classes('factory-muted') + if git_debug.get('status_after_add'): + with ui.expansion('Git status after staging').classes('w-full q-mt-sm'): + ui.label(str(git_debug['status_after_add'])).classes('factory-code') + if git_debug.get('remote_error'): + ui.label(f"Remote push error: {git_debug['remote_error']}").classes('factory-code') + if git_debug.get('error'): + ui.label(f"Git error: {git_debug['error']}").classes('factory-code') + + def _render_timeline(events: list[dict]) -> None: """Render a mixed project timeline.""" if not events: @@ -1576,6 +1625,9 @@ def create_dashboard(): 'Sync Repo Activity', on_click=lambda _=None, project_id=project['project_id']: sync_project_repository_action(project_id), ).props('outline color=secondary').classes('q-mt-md') + with ui.card().classes('q-pa-md'): + ui.label('Generation Diagnostics').style('font-weight: 700; color: #3a281a;') + _render_generation_diagnostics(project_bundle.get('ui_data')) @ui.refreshable def render_archived_panel() -> None: @@ -1642,6 +1694,9 @@ def create_dashboard(): with ui.card().classes('q-pa-md'): ui.label('Repository').style('font-weight: 700; color: #3a281a;') _render_repository_block(project_bundle.get('repository') or project.get('repository')) + with ui.card().classes('q-pa-md'): + ui.label('Generation Diagnostics').style('font-weight: 700; color: #3a281a;') + _render_generation_diagnostics(project_bundle.get('ui_data')) with ui.card().classes('q-pa-md'): ui.label('Prompt').style('font-weight: 700; color: #3a281a;') prompts = project_bundle.get('prompts', []) diff --git a/ai_software_factory/main.py b/ai_software_factory/main.py index 036572c..1b1607e 100644 --- a/ai_software_factory/main.py +++ b/ai_software_factory/main.py @@ -348,6 +348,8 @@ async def _run_generation( response_data = _serialize_project(history) response_data['logs'] = [_serialize_project_log(log) for log in project_logs] response_data['ui_data'] = result.get('ui_data') + response_data['generation_debug'] = ((result.get('ui_data') or {}).get('generation_debug')) + response_data['git_debug'] = ((result.get('ui_data') or {}).get('git')) response_data['features'] = request.features response_data['tech_stack'] = request.tech_stack response_data['project_root'] = result.get('project_root', str(_project_root(project_id)))