diff --git a/ai_software_factory/agents/database_manager.py b/ai_software_factory/agents/database_manager.py index 3667d34..b5d9905 100644 --- a/ai_software_factory/agents/database_manager.py +++ b/ai_software_factory/agents/database_manager.py @@ -34,6 +34,7 @@ except ImportError: from datetime import datetime import json import re +import shutil class DatabaseMigrations: @@ -87,6 +88,11 @@ class DatabaseManager: self.db = db self.migrations = DatabaseMigrations(self.db) + @staticmethod + def _is_archived_status(status: str | None) -> bool: + """Return whether a project status represents an archived project.""" + return (status or '').strip().lower() == 'archived' + @staticmethod def _normalize_metadata(metadata: object) -> dict: """Normalize JSON-like metadata stored in audit columns.""" @@ -111,13 +117,15 @@ class DatabaseManager: sanitized = sanitized.replace('--', '-') return sanitized.strip('-') or 'external-project' - def get_project_by_repository(self, owner: str, repo_name: str) -> ProjectHistory | None: + def get_project_by_repository(self, owner: str, repo_name: str, include_archived: bool = False) -> ProjectHistory | None: """Return the project currently associated with a repository.""" normalized_owner = (owner or '').strip().lower() normalized_repo = (repo_name or '').strip().lower() if not normalized_owner or not normalized_repo: return None for history in self.db.query(ProjectHistory).order_by(ProjectHistory.updated_at.desc(), ProjectHistory.id.desc()).all(): + if not include_archived and self._is_archived_status(history.status): + continue repository = self._get_project_repository(history) or {} if (repository.get('owner') or '').strip().lower() == normalized_owner and (repository.get('name') or '').strip().lower() == normalized_repo: return history @@ -736,12 +744,6 @@ class DatabaseManager: self.db.commit() return updates - def get_latest_project_by_name(self, project_name: str) -> ProjectHistory | None: - """Return the most recently updated project with the requested name.""" - return self.db.query(ProjectHistory).filter( - ProjectHistory.project_name == project_name - ).order_by(ProjectHistory.updated_at.desc(), ProjectHistory.id.desc()).first() - def log_prompt_revert( self, project_id: str, @@ -813,9 +815,14 @@ class DatabaseManager: } return None - def get_project_by_id(self, project_id: str) -> ProjectHistory | None: + def get_project_by_id(self, project_id: str, include_archived: bool = True) -> ProjectHistory | None: """Get project by ID.""" - return self.db.query(ProjectHistory).filter(ProjectHistory.project_id == project_id).first() + history = self.db.query(ProjectHistory).filter(ProjectHistory.project_id == project_id).first() + if history is None: + return None + if not include_archived and self._is_archived_status(history.status): + return None + return history def get_recent_chat_history(self, chat_id: str, source: str = 'telegram', limit: int = 12) -> list[dict]: """Return recent prompt events for one chat/source conversation.""" @@ -832,6 +839,9 @@ class DatabaseManager: continue if str(source_context.get('chat_id') or '') != str(chat_id): continue + history = self.get_project_by_id(prompt.project_id) + if history is None or self._is_archived_status(history.status): + continue result.append( { 'prompt_id': prompt.id, @@ -875,9 +885,96 @@ class DatabaseManager: 'projects': projects, } - def get_all_projects(self) -> list[ProjectHistory]: - """Get all projects.""" - return self.db.query(ProjectHistory).all() + def get_all_projects(self, include_archived: bool = False, archived_only: bool = False) -> list[ProjectHistory]: + """Get tracked projects with optional archive filtering.""" + projects = self.db.query(ProjectHistory).order_by(ProjectHistory.updated_at.desc(), ProjectHistory.id.desc()).all() + if archived_only: + return [project for project in projects if self._is_archived_status(project.status)] + if include_archived: + return projects + return [project for project in projects if not self._is_archived_status(project.status)] + + def get_latest_project_by_name(self, project_name: str, include_archived: bool = False) -> ProjectHistory | None: + """Return the latest project matching a human-readable project name.""" + if not project_name: + return None + query = self.db.query(ProjectHistory).filter(ProjectHistory.project_name == project_name).order_by( + ProjectHistory.updated_at.desc(), ProjectHistory.id.desc() + ) + for history in query.all(): + if include_archived or not self._is_archived_status(history.status): + return history + return None + + def archive_project(self, project_id: str) -> dict: + """Archive a project so it no longer participates in active automation.""" + history = self.get_project_by_id(project_id) + if history is None: + return {'status': 'error', 'message': 'Project not found'} + if self._is_archived_status(history.status): + return {'status': 'success', 'message': 'Project already archived', 'project_id': project_id} + history.status = 'archived' + history.message = 'Project archived' + history.current_step = 'archived' + history.updated_at = datetime.utcnow() + self.db.commit() + self._log_audit_trail( + project_id=project_id, + action='PROJECT_ARCHIVED', + actor='user', + action_type='ARCHIVE', + details=f'Project {project_id} archived', + message='Project archived', + ) + return {'status': 'success', 'message': 'Project archived', 'project_id': project_id} + + def unarchive_project(self, project_id: str) -> dict: + """Restore an archived project to the active automation set.""" + history = self.get_project_by_id(project_id) + if history is None: + return {'status': 'error', 'message': 'Project not found'} + if not self._is_archived_status(history.status): + return {'status': 'success', 'message': 'Project is already active', 'project_id': project_id} + history.status = ProjectStatus.COMPLETED.value if history.completed_at else ProjectStatus.STARTED.value + history.message = 'Project restored from archive' + history.current_step = 'restored' + history.updated_at = datetime.utcnow() + self.db.commit() + self._log_audit_trail( + project_id=project_id, + action='PROJECT_UNARCHIVED', + actor='user', + action_type='RESTORE', + details=f'Project {project_id} restored from archive', + message='Project restored from archive', + ) + return {'status': 'success', 'message': 'Project restored from archive', 'project_id': project_id} + + def delete_project(self, project_id: str, delete_project_root: bool = True) -> dict: + """Delete a project and all project-scoped traces from the database.""" + history = self.get_project_by_id(project_id) + if history is None: + return {'status': 'error', 'message': 'Project not found'} + snapshot_data = self._get_latest_ui_snapshot_data(history.id) + project_root = snapshot_data.get('project_root') or str(settings.projects_root / project_id) + self.db.query(PromptCodeLink).filter(PromptCodeLink.history_id == history.id).delete() + self.db.query(PullRequest).filter(PullRequest.history_id == history.id).delete() + self.db.query(PullRequestData).filter(PullRequestData.history_id == history.id).delete() + self.db.query(UISnapshot).filter(UISnapshot.history_id == history.id).delete() + self.db.query(UserAction).filter(UserAction.history_id == history.id).delete() + self.db.query(ProjectLog).filter(ProjectLog.history_id == history.id).delete() + self.db.query(AuditTrail).filter(AuditTrail.project_id == project_id).delete() + self.db.delete(history) + self.db.commit() + if delete_project_root and project_root: + shutil.rmtree(project_root, ignore_errors=True) + return { + 'status': 'success', + 'message': 'Project deleted', + 'project_id': project_id, + 'project_root_deleted': bool(delete_project_root and project_root), + 'project_root': project_root, + } def get_project_logs(self, history_id: int, limit: int = 100) -> list[ProjectLog]: """Get project logs.""" @@ -1906,14 +2003,17 @@ class DatabaseManager: ) except Exception: pass - projects = self.db.query(ProjectHistory).order_by(ProjectHistory.updated_at.desc()).limit(limit).all() + active_projects = self.get_all_projects() + archived_projects = self.get_all_projects(archived_only=True) + projects = active_projects[:limit] system_logs = self.db.query(SystemLog).order_by(SystemLog.created_at.desc()).limit(limit).all() return { "summary": { - "total_projects": self.db.query(ProjectHistory).count(), - "running_projects": self.db.query(ProjectHistory).filter(ProjectHistory.status == ProjectStatus.RUNNING.value).count(), - "completed_projects": self.db.query(ProjectHistory).filter(ProjectHistory.status == ProjectStatus.COMPLETED.value).count(), - "error_projects": self.db.query(ProjectHistory).filter(ProjectHistory.status == ProjectStatus.ERROR.value).count(), + "total_projects": len(active_projects), + "archived_projects": len(archived_projects), + "running_projects": len([project for project in active_projects if project.status == ProjectStatus.RUNNING.value]), + "completed_projects": len([project for project in active_projects if project.status == ProjectStatus.COMPLETED.value]), + "error_projects": len([project for project in active_projects if project.status == ProjectStatus.ERROR.value]), "prompt_events": self.db.query(AuditTrail).filter(AuditTrail.action == "PROMPT_RECEIVED").count(), "code_changes": self.db.query(AuditTrail).filter(AuditTrail.action == "CODE_CHANGE").count(), "open_pull_requests": self.db.query(PullRequest).filter(PullRequest.pr_state == "open", PullRequest.merged.is_(False)).count(), @@ -1921,6 +2021,7 @@ class DatabaseManager: "issue_work_events": self.db.query(AuditTrail).filter(AuditTrail.action == "ISSUE_WORKED").count(), }, "projects": [self.get_project_audit_data(project.project_id) for project in projects], + "archived_projects": [self.get_project_audit_data(project.project_id) for project in archived_projects[:limit]], "system_logs": [ { "id": log.id, diff --git a/ai_software_factory/agents/git_manager.py b/ai_software_factory/agents/git_manager.py index 9354013..1527b29 100644 --- a/ai_software_factory/agents/git_manager.py +++ b/ai_software_factory/agents/git_manager.py @@ -1,6 +1,7 @@ """Git manager for project operations.""" import os +import shutil import subprocess import tempfile from pathlib import Path @@ -32,8 +33,18 @@ class GitManager: resolved = (base_root / project_id).resolve() self.project_dir = str(resolved) + def is_git_available(self) -> bool: + """Return whether the git executable is available in the current environment.""" + return shutil.which('git') is not None + + def _ensure_git_available(self) -> None: + """Raise a clear error when git is not installed in the runtime environment.""" + if not self.is_git_available(): + raise RuntimeError('git executable is not available in PATH') + def _run(self, args: list[str], env: dict | None = None, check: bool = True) -> subprocess.CompletedProcess: """Run a git command in the project directory.""" + self._ensure_git_available() return subprocess.run( args, check=check, diff --git a/ai_software_factory/agents/gitea.py b/ai_software_factory/agents/gitea.py index 4927296..616e9c8 100644 --- a/ai_software_factory/agents/gitea.py +++ b/ai_software_factory/agents/gitea.py @@ -156,6 +156,28 @@ class GiteaAPI: result.setdefault("status", "created") return result + async def delete_repo(self, owner: str | None = None, repo: str | None = None) -> dict: + """Delete a repository from the configured organization/user.""" + _owner = owner or self.owner + _repo = repo or self.repo + if not _owner or not _repo: + return {'error': 'Owner and repository name are required'} + result = await self._request('DELETE', f'repos/{_owner}/{_repo}') + if not result.get('error'): + result.setdefault('status', 'deleted') + return result + + def delete_repo_sync(self, owner: str | None = None, repo: str | None = None) -> dict: + """Synchronously delete a repository from the configured organization/user.""" + _owner = owner or self.owner + _repo = repo or self.repo + if not _owner or not _repo: + return {'error': 'Owner and repository name are required'} + result = self._request_sync('DELETE', f'repos/{_owner}/{_repo}') + if not result.get('error'): + result.setdefault('status', 'deleted') + return result + async def get_current_user(self) -> dict: """Get the user associated with the configured token.""" return await self._request("GET", "user") diff --git a/ai_software_factory/agents/orchestrator.py b/ai_software_factory/agents/orchestrator.py index 7be34e2..77dd117 100644 --- a/ai_software_factory/agents/orchestrator.py +++ b/ai_software_factory/agents/orchestrator.py @@ -322,6 +322,10 @@ class AgentOrchestrator: async def _prepare_git_workspace(self) -> None: """Initialize the local repo and ensure the PR branch exists before writing files.""" + if not self.git_manager.is_git_available(): + self.ui_manager.ui_data.setdefault('git', {})['error'] = 'git executable is not available in PATH' + self._append_log('Local git workspace skipped: git executable is not available in PATH') + return if not self.git_manager.has_repo(): self.git_manager.init_repo() @@ -606,6 +610,10 @@ class AgentOrchestrator: unique_files = list(dict.fromkeys(self.changed_files)) if not unique_files: return + if not self.git_manager.is_git_available(): + self.ui_manager.ui_data.setdefault('git', {})['error'] = 'git executable is not available in PATH' + self._append_log('Git commit skipped: git executable is not available in PATH') + return try: if not self.git_manager.has_repo(): @@ -668,7 +676,7 @@ class AgentOrchestrator: commit_hash=commit_hash, commit_url=remote_record.get('commit_url') if remote_record else None, ) - except (subprocess.CalledProcessError, FileNotFoundError) as exc: + except (RuntimeError, subprocess.CalledProcessError, FileNotFoundError) as exc: self.ui_manager.ui_data.setdefault("git", {})["error"] = str(exc) self._append_log(f"Git commit skipped: {exc}") diff --git a/ai_software_factory/agents/request_interpreter.py b/ai_software_factory/agents/request_interpreter.py index f74530d..7b926f4 100644 --- a/ai_software_factory/agents/request_interpreter.py +++ b/ai_software_factory/agents/request_interpreter.py @@ -183,8 +183,39 @@ class RequestInterpreter: def _derive_name(self, prompt_text: str) -> str: """Derive a stable project name when the LLM does not provide one.""" first_line = prompt_text.splitlines()[0].strip() + quoted = re.search(r'["\']([^"\']{3,80})["\']', first_line) + if quoted: + return self._humanize_name(quoted.group(1)) + + noun_phrase = re.search( + r'(?:build|create|start|make|develop|generate|design|need|want)\s+' + r'(?:me\s+|us\s+|an?\s+|the\s+|new\s+|internal\s+|simple\s+|lightweight\s+|modern\s+|web\s+|mobile\s+)*' + r'([a-z0-9][a-z0-9\s-]{2,80}?(?:portal|dashboard|app|application|service|tool|system|platform|api|bot|assistant|website|site|workspace|tracker|manager))\b', + first_line, + flags=re.IGNORECASE, + ) + if noun_phrase: + return self._humanize_name(noun_phrase.group(1)) + cleaned = re.sub(r'[^A-Za-z0-9 ]+', ' ', first_line) - words = [word.capitalize() for word in cleaned.split()[:4]] + stopwords = { + 'build', 'create', 'start', 'make', 'develop', 'generate', 'design', 'need', 'want', 'please', 'for', 'our', 'with', 'that', 'this', + 'new', 'internal', 'simple', 'modern', 'web', 'mobile', 'app', 'application', 'tool', 'system', + } + tokens = [word for word in cleaned.split() if word and word.lower() not in stopwords] + if tokens: + return self._humanize_name(' '.join(tokens[:4])) + return 'Generated Project' + + def _humanize_name(self, raw_name: str) -> str: + """Normalize a candidate project name into a readable title.""" + cleaned = re.sub(r'[^A-Za-z0-9\s-]+', ' ', raw_name).strip(' -') + cleaned = re.sub(r'\s+', ' ', cleaned) + special_upper = {'api', 'crm', 'erp', 'cms', 'hr', 'it', 'ui', 'qa'} + words = [] + for word in cleaned.split()[:6]: + lowered = word.lower() + words.append(lowered.upper() if lowered in special_upper else lowered.capitalize()) return ' '.join(words) or 'Generated Project' def _heuristic_fallback(self, prompt_text: str, context: dict | None = None) -> tuple[dict, dict]: diff --git a/ai_software_factory/dashboard_ui.py b/ai_software_factory/dashboard_ui.py index 71974ac..79a18c9 100644 --- a/ai_software_factory/dashboard_ui.py +++ b/ai_software_factory/dashboard_ui.py @@ -510,6 +510,22 @@ def _render_n8n_error_dialog(result: dict) -> None: dialog.open() +def _render_confirmation_dialog(title: str, message: str, confirm_label: str, on_confirm, color: str = 'negative') -> None: + """Render a reusable confirmation dialog for destructive or stateful actions.""" + with ui.dialog() as dialog, ui.card().classes('factory-panel q-pa-lg').style('max-width: 640px; width: min(92vw, 640px);'): + ui.label(title).style('font-size: 1.2rem; font-weight: 800; color: #5c2d1f;') + ui.label(message).classes('factory-muted') + + def _confirm() -> None: + dialog.close() + on_confirm() + + with ui.row().classes('justify-end w-full q-mt-md gap-2'): + ui.button('Cancel', on_click=dialog.close).props('outline color=dark') + ui.button(confirm_label, on_click=_confirm).props(f'unelevated color={color}') + dialog.open() + + def _render_health_panels() -> None: """Render application and n8n health panels.""" runtime = get_database_runtime_summary() @@ -722,6 +738,13 @@ def create_dashboard(): return with closing(db): manager = DatabaseManager(db) + history = manager.get_project_by_id(project_id) + if history is None: + ui.notify('Project not found', color='negative') + return + if history.status == 'archived': + ui.notify('Archived projects cannot be synced', color='negative') + return gitea_api = GiteaAPI( token=settings.GITEA_TOKEN, base_url=settings.GITEA_URL, @@ -807,6 +830,52 @@ def create_dashboard(): ui.notify(result.get('message', 'Prompt reverted') if result.get('status') != 'success' else 'Prompt changes reverted', color='positive' if result.get('status') == 'success' else 'negative') dashboard_body.refresh() + def archive_project_action(project_id: str) -> None: + db = get_db_sync() + if db is None: + ui.notify('Database session could not be created', color='negative') + return + with closing(db): + result = DatabaseManager(db).archive_project(project_id) + ui.notify(result.get('message', 'Project archived'), color='positive' if result.get('status') == 'success' else 'negative') + dashboard_body.refresh() + + def unarchive_project_action(project_id: str) -> None: + db = get_db_sync() + if db is None: + ui.notify('Database session could not be created', color='negative') + return + with closing(db): + result = DatabaseManager(db).unarchive_project(project_id) + ui.notify(result.get('message', 'Project restored'), color='positive' if result.get('status') == 'success' else 'negative') + dashboard_body.refresh() + + def delete_project_action(project_id: str) -> None: + db = get_db_sync() + if db is None: + ui.notify('Database session could not be created', color='negative') + return + with closing(db): + manager = DatabaseManager(db) + audit_data = manager.get_project_audit_data(project_id) + if audit_data.get('project') is None: + ui.notify('Project not found', color='negative') + return + repository = audit_data.get('repository') or audit_data['project'].get('repository') or {} + remote_delete = None + if repository and repository.get('mode') != 'shared' and repository.get('owner') and repository.get('name') and settings.gitea_url and settings.gitea_token: + gitea_api = GiteaAPI(token=settings.GITEA_TOKEN, base_url=settings.GITEA_URL, owner=settings.GITEA_OWNER, repo=settings.GITEA_REPO or '') + remote_delete = gitea_api.delete_repo_sync(owner=repository.get('owner'), repo=repository.get('name')) + if remote_delete.get('error') and remote_delete.get('status_code') not in {404, None}: + ui.notify(remote_delete.get('error', 'Remote repository deletion failed'), color='negative') + return + result = manager.delete_project(project_id) + message = result.get('message', 'Project deleted') + if remote_delete and not remote_delete.get('error'): + message = f"{message}; remote repository deleted" + ui.notify(message, color='positive' if result.get('status') == 'success' else 'negative') + dashboard_body.refresh() + @ui.refreshable def dashboard_body() -> None: snapshot = _load_dashboard_snapshot() @@ -819,6 +888,7 @@ def create_dashboard(): summary = snapshot['summary'] projects = snapshot['projects'] + archived_projects = snapshot.get('archived_projects', []) correlations = snapshot['correlations'] system_logs = snapshot['system_logs'] llm_stage_filter = _selected_llm_stage() @@ -857,6 +927,7 @@ def create_dashboard(): with ui.grid(columns=4).classes('w-full gap-4'): metrics = [ ('Projects', summary['total_projects'], 'Tracked generation requests'), + ('Archived', summary.get('archived_projects', 0), 'Excluded from active automation'), ('Completed', summary['completed_projects'], 'Finished project runs'), ('Prompts', summary['prompt_events'], 'Recorded originating prompts'), ('Open PRs', summary['open_pull_requests'], 'Unmerged review branches'), @@ -871,6 +942,7 @@ def create_dashboard(): with ui.tabs(value=selected_tab, on_change=_store_selected_tab).classes('w-full') as tabs: ui.tab('Overview').props('name=overview') ui.tab('Projects').props('name=projects') + ui.tab('Archived').props('name=archived') ui.tab('Prompt Trace').props('name=trace') ui.tab('Compare').props('name=compare') ui.tab('Timeline').props('name=timeline') @@ -915,6 +987,26 @@ def create_dashboard(): for project_bundle in projects: project = project_bundle['project'] with ui.expansion(f"{project['project_name']} · {project['status']}", icon='folder').classes('factory-panel w-full q-mb-md'): + with ui.row().classes('items-center gap-2 q-pa-md'): + ui.button( + 'Archive', + on_click=lambda _=None, project_id=project['project_id'], project_name=project['project_name']: _render_confirmation_dialog( + 'Archive project?', + f'Archive {project_name}? Archived projects remain visible in the dashboard but are excluded from automation, Telegram routing, sync, and undo actions.', + 'Archive', + lambda: archive_project_action(project_id), + color='warning', + ), + ).props('outline color=warning') + ui.button( + 'Delete', + on_click=lambda _=None, project_id=project['project_id'], project_name=project['project_name']: _render_confirmation_dialog( + 'Delete project permanently?', + f'Delete {project_name}? This removes the local project directory, project traces from the database, and any project-owned remote repository.', + 'Delete Permanently', + lambda: delete_project_action(project_id), + ), + ).props('outline color=negative') with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'): with ui.card().classes('q-pa-md'): ui.label('Repository').style('font-weight: 700; color: #3a281a;') @@ -924,6 +1016,52 @@ def create_dashboard(): 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.tab_panel('archived'): + if not archived_projects: + with ui.card().classes('factory-panel q-pa-lg'): + ui.label('No archived projects yet.').classes('factory-muted') + for project_bundle in archived_projects: + project = project_bundle['project'] + with ui.expansion(f"{project['project_name']} · archived", icon='archive').classes('factory-panel w-full q-mb-md'): + with ui.row().classes('items-center gap-2 q-pa-md'): + ui.button( + 'Restore', + on_click=lambda _=None, project_id=project['project_id'], project_name=project['project_name']: _render_confirmation_dialog( + 'Restore archived project?', + f'Restore {project_name} to the active project set so the factory can work on it again?', + 'Restore Project', + lambda: unarchive_project_action(project_id), + color='positive', + ), + ).props('outline color=positive') + ui.button( + 'Delete Permanently', + on_click=lambda _=None, project_id=project['project_id'], project_name=project['project_name']: _render_confirmation_dialog( + 'Delete archived project permanently?', + f'Delete {project_name}? This removes the archived project from both the database and filesystem, and deletes any project-owned remote repository.', + 'Delete Permanently', + lambda: delete_project_action(project_id), + ), + ).props('outline color=negative') + with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'): + 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('Prompt').style('font-weight: 700; color: #3a281a;') + prompts = project_bundle.get('prompts', []) + if prompts: + ui.label(prompts[0]['prompt_text']).classes('factory-code') + else: + ui.label('No prompt recorded.').classes('factory-muted') + with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'): + with ui.card().classes('q-pa-md'): + ui.label('Git Commits').style('font-weight: 700; color: #3a281a;') + _render_commit_list(project_bundle.get('commits', [])) + with ui.card().classes('q-pa-md'): + ui.label('Tracked Issues').style('font-weight: 700; color: #3a281a;') + _render_issue_list(project_bundle.get('issues', [])) + with ui.card().classes('q-pa-md'): ui.label('Repository Sync').style('font-weight: 700; color: #3a281a;') _render_repository_sync_block(project_bundle.get('repository_sync') or project.get('repository_sync')) diff --git a/ai_software_factory/main.py b/ai_software_factory/main.py index 29ef2f9..5e924cb 100644 --- a/ai_software_factory/main.py +++ b/ai_software_factory/main.py @@ -182,7 +182,7 @@ async def _run_generation( database_module.init_db() manager = DatabaseManager(db) - reusable_history = manager.get_project_by_id(preferred_project_id) if preferred_project_id else manager.get_latest_project_by_name(request.name) + reusable_history = manager.get_project_by_id(preferred_project_id, include_archived=False) if preferred_project_id else manager.get_latest_project_by_name(request.name) if reusable_history and database_module.settings.gitea_url and database_module.settings.gitea_token: try: from .agents.gitea import GiteaAPI @@ -338,6 +338,9 @@ def read_api_info(): '/audit/pull-requests', '/audit/lineage', '/audit/correlations', + '/projects/{project_id}/archive', + '/projects/{project_id}/unarchive', + '/projects/{project_id}', '/projects/{project_id}/prompts/{prompt_id}/undo', '/projects/{project_id}/sync-repository', '/gitea/repos', @@ -392,7 +395,7 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe context=interpreter_context, ) routing = interpretation_trace.get('routing') or {} - selected_history = manager.get_project_by_id(routing.get('project_id')) if routing.get('project_id') else None + selected_history = manager.get_project_by_id(routing.get('project_id'), include_archived=False) if routing.get('project_id') else None if selected_history is not None and routing.get('intent') != 'new_project': interpreted['name'] = selected_history.project_name interpreted['description'] = selected_history.description or interpreted['description'] @@ -440,10 +443,14 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe @app.get('/projects') -def list_projects(db: DbSession): +def list_projects( + db: DbSession, + include_archived: bool = Query(default=False), + archived_only: bool = Query(default=False), +): """List recorded projects.""" manager = DatabaseManager(db) - projects = manager.get_all_projects() + projects = manager.get_all_projects(include_archived=include_archived, archived_only=archived_only) return {'projects': [_serialize_project(project) for project in projects]} @@ -572,16 +579,70 @@ def get_pull_request_audit(db: DbSession, project_id: str | None = Query(default @app.post('/projects/{project_id}/prompts/{prompt_id}/undo') async def undo_prompt_changes(project_id: str, prompt_id: int, db: DbSession): """Undo all changes associated with a specific prompt.""" + manager = DatabaseManager(db) + history = manager.get_project_by_id(project_id) + if history is None: + raise HTTPException(status_code=404, detail='Project not found') + if history.status == 'archived': + raise HTTPException(status_code=400, detail='Archived projects cannot be modified') result = await PromptWorkflowManager(db).undo_prompt(project_id=project_id, prompt_id=prompt_id) if result.get('status') == 'error': raise HTTPException(status_code=400, detail=result.get('message', 'Undo failed')) return result +@app.post('/projects/{project_id}/archive') +def archive_project(project_id: str, db: DbSession): + """Archive a project so it no longer participates in active automation.""" + manager = DatabaseManager(db) + result = manager.archive_project(project_id) + if result.get('status') == 'error': + raise HTTPException(status_code=404, detail=result.get('message', 'Archive failed')) + return result + + +@app.post('/projects/{project_id}/unarchive') +def unarchive_project(project_id: str, db: DbSession): + """Restore an archived project back into the active automation set.""" + manager = DatabaseManager(db) + result = manager.unarchive_project(project_id) + if result.get('status') == 'error': + raise HTTPException(status_code=404, detail=result.get('message', 'Restore failed')) + return result + + +@app.delete('/projects/{project_id}') +def delete_project(project_id: str, db: DbSession): + """Delete a project, its local project directory, and project-scoped DB traces.""" + manager = DatabaseManager(db) + audit_data = manager.get_project_audit_data(project_id) + if audit_data.get('project') is None: + raise HTTPException(status_code=404, detail='Project not found') + + repository = audit_data.get('repository') or audit_data['project'].get('repository') or {} + remote_delete = None + if repository and repository.get('mode') != 'shared' and repository.get('owner') and repository.get('name') and database_module.settings.gitea_url and database_module.settings.gitea_token: + remote_delete = _create_gitea_api().delete_repo_sync(owner=repository.get('owner'), repo=repository.get('name')) + if remote_delete.get('error') and remote_delete.get('status_code') not in {404, None}: + raise HTTPException(status_code=502, detail=remote_delete.get('error')) + + result = manager.delete_project(project_id) + if result.get('status') == 'error': + raise HTTPException(status_code=400, detail=result.get('message', 'Project deletion failed')) + result['remote_repository_deleted'] = bool(remote_delete and not remote_delete.get('error')) + result['remote_repository'] = repository if repository else None + return result + + @app.post('/projects/{project_id}/sync-repository') def sync_project_repository(project_id: str, db: DbSession, commit_limit: int = Query(default=25, ge=1, le=200)): """Import recent repository activity from Gitea for a tracked project.""" manager = DatabaseManager(db) + history = manager.get_project_by_id(project_id) + if history is None: + raise HTTPException(status_code=404, detail='Project not found') + if history.status == 'archived': + raise HTTPException(status_code=400, detail='Archived projects cannot be synced') gitea_api = _create_gitea_api() result = manager.sync_repository_activity(project_id=project_id, gitea_api=gitea_api, commit_limit=commit_limit) if result.get('status') == 'error':