fix: add additional deletion confirmation, refs NOISSUE
This commit is contained in:
@@ -34,6 +34,7 @@ except ImportError:
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
|
||||||
class DatabaseMigrations:
|
class DatabaseMigrations:
|
||||||
@@ -87,6 +88,11 @@ class DatabaseManager:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.migrations = DatabaseMigrations(self.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
|
@staticmethod
|
||||||
def _normalize_metadata(metadata: object) -> dict:
|
def _normalize_metadata(metadata: object) -> dict:
|
||||||
"""Normalize JSON-like metadata stored in audit columns."""
|
"""Normalize JSON-like metadata stored in audit columns."""
|
||||||
@@ -111,13 +117,15 @@ class DatabaseManager:
|
|||||||
sanitized = sanitized.replace('--', '-')
|
sanitized = sanitized.replace('--', '-')
|
||||||
return sanitized.strip('-') or 'external-project'
|
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."""
|
"""Return the project currently associated with a repository."""
|
||||||
normalized_owner = (owner or '').strip().lower()
|
normalized_owner = (owner or '').strip().lower()
|
||||||
normalized_repo = (repo_name or '').strip().lower()
|
normalized_repo = (repo_name or '').strip().lower()
|
||||||
if not normalized_owner or not normalized_repo:
|
if not normalized_owner or not normalized_repo:
|
||||||
return None
|
return None
|
||||||
for history in self.db.query(ProjectHistory).order_by(ProjectHistory.updated_at.desc(), ProjectHistory.id.desc()).all():
|
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 {}
|
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:
|
if (repository.get('owner') or '').strip().lower() == normalized_owner and (repository.get('name') or '').strip().lower() == normalized_repo:
|
||||||
return history
|
return history
|
||||||
@@ -736,12 +744,6 @@ class DatabaseManager:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return updates
|
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(
|
def log_prompt_revert(
|
||||||
self,
|
self,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -813,9 +815,14 @@ class DatabaseManager:
|
|||||||
}
|
}
|
||||||
return None
|
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."""
|
"""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]:
|
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."""
|
"""Return recent prompt events for one chat/source conversation."""
|
||||||
@@ -832,6 +839,9 @@ class DatabaseManager:
|
|||||||
continue
|
continue
|
||||||
if str(source_context.get('chat_id') or '') != str(chat_id):
|
if str(source_context.get('chat_id') or '') != str(chat_id):
|
||||||
continue
|
continue
|
||||||
|
history = self.get_project_by_id(prompt.project_id)
|
||||||
|
if history is None or self._is_archived_status(history.status):
|
||||||
|
continue
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
'prompt_id': prompt.id,
|
'prompt_id': prompt.id,
|
||||||
@@ -875,9 +885,96 @@ class DatabaseManager:
|
|||||||
'projects': projects,
|
'projects': projects,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_all_projects(self) -> list[ProjectHistory]:
|
def get_all_projects(self, include_archived: bool = False, archived_only: bool = False) -> list[ProjectHistory]:
|
||||||
"""Get all projects."""
|
"""Get tracked projects with optional archive filtering."""
|
||||||
return self.db.query(ProjectHistory).all()
|
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]:
|
def get_project_logs(self, history_id: int, limit: int = 100) -> list[ProjectLog]:
|
||||||
"""Get project logs."""
|
"""Get project logs."""
|
||||||
@@ -1906,14 +2003,17 @@ class DatabaseManager:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
system_logs = self.db.query(SystemLog).order_by(SystemLog.created_at.desc()).limit(limit).all()
|
||||||
return {
|
return {
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_projects": self.db.query(ProjectHistory).count(),
|
"total_projects": len(active_projects),
|
||||||
"running_projects": self.db.query(ProjectHistory).filter(ProjectHistory.status == ProjectStatus.RUNNING.value).count(),
|
"archived_projects": len(archived_projects),
|
||||||
"completed_projects": self.db.query(ProjectHistory).filter(ProjectHistory.status == ProjectStatus.COMPLETED.value).count(),
|
"running_projects": len([project for project in active_projects if project.status == ProjectStatus.RUNNING.value]),
|
||||||
"error_projects": self.db.query(ProjectHistory).filter(ProjectHistory.status == ProjectStatus.ERROR.value).count(),
|
"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(),
|
"prompt_events": self.db.query(AuditTrail).filter(AuditTrail.action == "PROMPT_RECEIVED").count(),
|
||||||
"code_changes": self.db.query(AuditTrail).filter(AuditTrail.action == "CODE_CHANGE").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(),
|
"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(),
|
"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],
|
"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": [
|
"system_logs": [
|
||||||
{
|
{
|
||||||
"id": log.id,
|
"id": log.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Git manager for project operations."""
|
"""Git manager for project operations."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -32,8 +33,18 @@ class GitManager:
|
|||||||
resolved = (base_root / project_id).resolve()
|
resolved = (base_root / project_id).resolve()
|
||||||
self.project_dir = str(resolved)
|
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:
|
def _run(self, args: list[str], env: dict | None = None, check: bool = True) -> subprocess.CompletedProcess:
|
||||||
"""Run a git command in the project directory."""
|
"""Run a git command in the project directory."""
|
||||||
|
self._ensure_git_available()
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
args,
|
args,
|
||||||
check=check,
|
check=check,
|
||||||
|
|||||||
@@ -156,6 +156,28 @@ class GiteaAPI:
|
|||||||
result.setdefault("status", "created")
|
result.setdefault("status", "created")
|
||||||
return result
|
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:
|
async def get_current_user(self) -> dict:
|
||||||
"""Get the user associated with the configured token."""
|
"""Get the user associated with the configured token."""
|
||||||
return await self._request("GET", "user")
|
return await self._request("GET", "user")
|
||||||
|
|||||||
@@ -322,6 +322,10 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
async def _prepare_git_workspace(self) -> None:
|
async def _prepare_git_workspace(self) -> None:
|
||||||
"""Initialize the local repo and ensure the PR branch exists before writing files."""
|
"""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():
|
if not self.git_manager.has_repo():
|
||||||
self.git_manager.init_repo()
|
self.git_manager.init_repo()
|
||||||
|
|
||||||
@@ -606,6 +610,10 @@ class AgentOrchestrator:
|
|||||||
unique_files = list(dict.fromkeys(self.changed_files))
|
unique_files = list(dict.fromkeys(self.changed_files))
|
||||||
if not unique_files:
|
if not unique_files:
|
||||||
return
|
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:
|
try:
|
||||||
if not self.git_manager.has_repo():
|
if not self.git_manager.has_repo():
|
||||||
@@ -668,7 +676,7 @@ class AgentOrchestrator:
|
|||||||
commit_hash=commit_hash,
|
commit_hash=commit_hash,
|
||||||
commit_url=remote_record.get('commit_url') if remote_record else None,
|
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.ui_manager.ui_data.setdefault("git", {})["error"] = str(exc)
|
||||||
self._append_log(f"Git commit skipped: {exc}")
|
self._append_log(f"Git commit skipped: {exc}")
|
||||||
|
|
||||||
|
|||||||
@@ -183,8 +183,39 @@ class RequestInterpreter:
|
|||||||
def _derive_name(self, prompt_text: str) -> str:
|
def _derive_name(self, prompt_text: str) -> str:
|
||||||
"""Derive a stable project name when the LLM does not provide one."""
|
"""Derive a stable project name when the LLM does not provide one."""
|
||||||
first_line = prompt_text.splitlines()[0].strip()
|
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)
|
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'
|
return ' '.join(words) or 'Generated Project'
|
||||||
|
|
||||||
def _heuristic_fallback(self, prompt_text: str, context: dict | None = None) -> tuple[dict, dict]:
|
def _heuristic_fallback(self, prompt_text: str, context: dict | None = None) -> tuple[dict, dict]:
|
||||||
|
|||||||
@@ -510,6 +510,22 @@ def _render_n8n_error_dialog(result: dict) -> None:
|
|||||||
dialog.open()
|
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:
|
def _render_health_panels() -> None:
|
||||||
"""Render application and n8n health panels."""
|
"""Render application and n8n health panels."""
|
||||||
runtime = get_database_runtime_summary()
|
runtime = get_database_runtime_summary()
|
||||||
@@ -722,6 +738,13 @@ def create_dashboard():
|
|||||||
return
|
return
|
||||||
with closing(db):
|
with closing(db):
|
||||||
manager = DatabaseManager(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(
|
gitea_api = GiteaAPI(
|
||||||
token=settings.GITEA_TOKEN,
|
token=settings.GITEA_TOKEN,
|
||||||
base_url=settings.GITEA_URL,
|
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')
|
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()
|
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
|
@ui.refreshable
|
||||||
def dashboard_body() -> None:
|
def dashboard_body() -> None:
|
||||||
snapshot = _load_dashboard_snapshot()
|
snapshot = _load_dashboard_snapshot()
|
||||||
@@ -819,6 +888,7 @@ def create_dashboard():
|
|||||||
|
|
||||||
summary = snapshot['summary']
|
summary = snapshot['summary']
|
||||||
projects = snapshot['projects']
|
projects = snapshot['projects']
|
||||||
|
archived_projects = snapshot.get('archived_projects', [])
|
||||||
correlations = snapshot['correlations']
|
correlations = snapshot['correlations']
|
||||||
system_logs = snapshot['system_logs']
|
system_logs = snapshot['system_logs']
|
||||||
llm_stage_filter = _selected_llm_stage()
|
llm_stage_filter = _selected_llm_stage()
|
||||||
@@ -857,6 +927,7 @@ def create_dashboard():
|
|||||||
with ui.grid(columns=4).classes('w-full gap-4'):
|
with ui.grid(columns=4).classes('w-full gap-4'):
|
||||||
metrics = [
|
metrics = [
|
||||||
('Projects', summary['total_projects'], 'Tracked generation requests'),
|
('Projects', summary['total_projects'], 'Tracked generation requests'),
|
||||||
|
('Archived', summary.get('archived_projects', 0), 'Excluded from active automation'),
|
||||||
('Completed', summary['completed_projects'], 'Finished project runs'),
|
('Completed', summary['completed_projects'], 'Finished project runs'),
|
||||||
('Prompts', summary['prompt_events'], 'Recorded originating prompts'),
|
('Prompts', summary['prompt_events'], 'Recorded originating prompts'),
|
||||||
('Open PRs', summary['open_pull_requests'], 'Unmerged review branches'),
|
('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:
|
with ui.tabs(value=selected_tab, on_change=_store_selected_tab).classes('w-full') as tabs:
|
||||||
ui.tab('Overview').props('name=overview')
|
ui.tab('Overview').props('name=overview')
|
||||||
ui.tab('Projects').props('name=projects')
|
ui.tab('Projects').props('name=projects')
|
||||||
|
ui.tab('Archived').props('name=archived')
|
||||||
ui.tab('Prompt Trace').props('name=trace')
|
ui.tab('Prompt Trace').props('name=trace')
|
||||||
ui.tab('Compare').props('name=compare')
|
ui.tab('Compare').props('name=compare')
|
||||||
ui.tab('Timeline').props('name=timeline')
|
ui.tab('Timeline').props('name=timeline')
|
||||||
@@ -915,6 +987,26 @@ def create_dashboard():
|
|||||||
for project_bundle in projects:
|
for project_bundle in projects:
|
||||||
project = project_bundle['project']
|
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.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.grid(columns=2).classes('w-full gap-4 q-pa-md'):
|
||||||
with ui.card().classes('q-pa-md'):
|
with ui.card().classes('q-pa-md'):
|
||||||
ui.label('Repository').style('font-weight: 700; color: #3a281a;')
|
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),
|
on_click=lambda _=None, project_id=project['project_id']: sync_project_repository_action(project_id),
|
||||||
).props('outline color=secondary').classes('q-mt-md')
|
).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'):
|
with ui.card().classes('q-pa-md'):
|
||||||
ui.label('Repository Sync').style('font-weight: 700; color: #3a281a;')
|
ui.label('Repository Sync').style('font-weight: 700; color: #3a281a;')
|
||||||
_render_repository_sync_block(project_bundle.get('repository_sync') or project.get('repository_sync'))
|
_render_repository_sync_block(project_bundle.get('repository_sync') or project.get('repository_sync'))
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ async def _run_generation(
|
|||||||
database_module.init_db()
|
database_module.init_db()
|
||||||
|
|
||||||
manager = DatabaseManager(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:
|
if reusable_history and database_module.settings.gitea_url and database_module.settings.gitea_token:
|
||||||
try:
|
try:
|
||||||
from .agents.gitea import GiteaAPI
|
from .agents.gitea import GiteaAPI
|
||||||
@@ -338,6 +338,9 @@ def read_api_info():
|
|||||||
'/audit/pull-requests',
|
'/audit/pull-requests',
|
||||||
'/audit/lineage',
|
'/audit/lineage',
|
||||||
'/audit/correlations',
|
'/audit/correlations',
|
||||||
|
'/projects/{project_id}/archive',
|
||||||
|
'/projects/{project_id}/unarchive',
|
||||||
|
'/projects/{project_id}',
|
||||||
'/projects/{project_id}/prompts/{prompt_id}/undo',
|
'/projects/{project_id}/prompts/{prompt_id}/undo',
|
||||||
'/projects/{project_id}/sync-repository',
|
'/projects/{project_id}/sync-repository',
|
||||||
'/gitea/repos',
|
'/gitea/repos',
|
||||||
@@ -392,7 +395,7 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe
|
|||||||
context=interpreter_context,
|
context=interpreter_context,
|
||||||
)
|
)
|
||||||
routing = interpretation_trace.get('routing') or {}
|
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':
|
if selected_history is not None and routing.get('intent') != 'new_project':
|
||||||
interpreted['name'] = selected_history.project_name
|
interpreted['name'] = selected_history.project_name
|
||||||
interpreted['description'] = selected_history.description or interpreted['description']
|
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')
|
@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."""
|
"""List recorded projects."""
|
||||||
manager = DatabaseManager(db)
|
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]}
|
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')
|
@app.post('/projects/{project_id}/prompts/{prompt_id}/undo')
|
||||||
async def undo_prompt_changes(project_id: str, prompt_id: int, db: DbSession):
|
async def undo_prompt_changes(project_id: str, prompt_id: int, db: DbSession):
|
||||||
"""Undo all changes associated with a specific prompt."""
|
"""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)
|
result = await PromptWorkflowManager(db).undo_prompt(project_id=project_id, prompt_id=prompt_id)
|
||||||
if result.get('status') == 'error':
|
if result.get('status') == 'error':
|
||||||
raise HTTPException(status_code=400, detail=result.get('message', 'Undo failed'))
|
raise HTTPException(status_code=400, detail=result.get('message', 'Undo failed'))
|
||||||
return result
|
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')
|
@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)):
|
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."""
|
"""Import recent repository activity from Gitea for a tracked project."""
|
||||||
manager = DatabaseManager(db)
|
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()
|
gitea_api = _create_gitea_api()
|
||||||
result = manager.sync_repository_activity(project_id=project_id, gitea_api=gitea_api, commit_limit=commit_limit)
|
result = manager.sync_repository_activity(project_id=project_id, gitea_api=gitea_api, commit_limit=commit_limit)
|
||||||
if result.get('status') == 'error':
|
if result.get('status') == 'error':
|
||||||
|
|||||||
Reference in New Issue
Block a user