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)
------------
Fix
~~~
- Add additional deletion confirmation, refs NOISSUE. [Simon
Diesenreiter]
0.7.0 (2026-04-10)
------------------
- Feat: gitea issue integration, 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
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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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