2 Commits
0.7.0 ... 0.7.1

Author SHA1 Message Date
ed8dc48280 release: version 0.7.1 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 36s
Upload Python Package / deploy (push) Successful in 1m24s
2026-04-11 09:21:15 +02:00
c3cf8da42d fix: add additional deletion confirmation, refs NOISSUE 2026-04-11 09:21:12 +02:00
9 changed files with 405 additions and 24 deletions

View File

@@ -4,6 +4,15 @@ Changelog
(unreleased) (unreleased)
------------ ------------
Fix
~~~
- Add additional deletion confirmation, refs NOISSUE. [Simon
Diesenreiter]
0.7.0 (2026-04-10)
------------------
- Feat: gitea issue integration, refs NOISSUE. [Simon Diesenreiter] - Feat: gitea issue integration, refs NOISSUE. [Simon Diesenreiter]
- Feat: better history data, refs NOISSUE. [Simon Diesenreiter] - Feat: better history data, refs NOISSUE. [Simon Diesenreiter]

View File

@@ -1 +1 @@
0.7.0 0.7.1

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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}")

View File

@@ -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]:

View File

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

View File

@@ -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':