30 Commits
0.3.1 ... 0.6.4

Author SHA1 Message Date
b9faac8d16 release: version 0.6.4 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 52s
Upload Python Package / deploy (push) Successful in 2m14s
2026-04-10 21:47:54 +02:00
80d7716e65 fix: add Telegram helper functions, refs NOISSUE 2026-04-10 21:47:50 +02:00
321bf74aef release: version 0.6.3 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 15s
Upload Python Package / deploy (push) Successful in 1m11s
2026-04-10 21:24:44 +02:00
55ee75106c fix: n8n workflow generation, refs NOISSUE 2026-04-10 21:24:39 +02:00
b2829caa02 release: version 0.6.2 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 29s
Upload Python Package / deploy (push) Successful in 1m11s
2026-04-10 21:14:12 +02:00
d4b280cf75 fix: fix Quasar layout issues, refs NOISSUE 2026-04-10 21:14:09 +02:00
806db8537b release: version 0.6.1 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 22s
Upload Python Package / deploy (push) Successful in 37s
2026-04-10 21:00:30 +02:00
360ed5c6f3 fix: fix commit for version push, refs NOISSUE 2026-04-10 21:00:26 +02:00
4b9eb2f359 chore: add more health info for n8n, refs NOISSUE 2026-04-10 20:55:43 +02:00
ebfcfb969a release: version 0.6.0 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 17s
Upload Python Package / deploy (push) Successful in 42s
2026-04-10 20:43:36 +02:00
56b05eb686 feat(api): expose database target in health refs NOISSUE 2026-04-10 20:39:36 +02:00
59a7e9787e fix(db): prefer postgres config in production refs NOISSUE 2026-04-10 20:37:31 +02:00
a357a307a7 release: version 0.5.0 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 22s
Upload Python Package / deploy (push) Successful in 49s
2026-04-10 20:27:26 +02:00
af4247e657 feat(dashboard): expose repository urls refs NOISSUE 2026-04-10 20:27:08 +02:00
227ad1ad6f feat(factory): serve dashboard at root and create project repos refs NOISSUE 2026-04-10 20:23:07 +02:00
82e53a6651 release: version 0.4.1 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 31s
Upload Python Package / deploy (push) Successful in 1m2s
2026-04-10 19:59:04 +02:00
e9dc1ede55 fix(ci): pin docker api version for release builds refs NOISSUE 2026-04-10 19:58:38 +02:00
6ee1c46826 release: version 0.4.0 🚀
Some checks failed
Upload Python Package / Create Release (push) Successful in 16s
Upload Python Package / deploy (push) Failing after 1m0s
2026-04-10 19:40:17 +02:00
4f5c87bed9 chore(git): ignore local sqlite database refs NOISSUE 2026-04-10 19:39:39 +02:00
7180031d1f feat(factory): implement db-backed dashboard and workflow automation refs NOISSUE 2026-04-10 19:37:44 +02:00
de4feb61cd release: version 0.3.6 🚀 2026-04-05 01:00:05 +02:00
ddb9f2100b fix: rename gitea workflow, refs NOISSUE 2026-04-05 01:00:03 +02:00
034bb3eb63 release: version 0.3.5 🚀 2026-04-05 00:58:13 +02:00
06a50880b7 fix: some cleanup, refs NOISSUE 2026-04-05 00:58:09 +02:00
c66b57f9cb release: version 0.3.4 🚀 2026-04-05 00:19:31 +02:00
ba30f84f49 fix: fix database init, refs NOISSUE 2026-04-05 00:19:29 +02:00
81935daaf5 release: version 0.3.3 🚀 2026-04-04 23:53:04 +02:00
d2260ac797 fix: fix runtime errors, refs NOISSUE 2026-04-04 23:53:02 +02:00
ca6f39a3e8 release: version 0.3.2 🚀 2026-04-04 23:34:32 +02:00
5eb5bd426a fix: add back DB init endpoints, ref NOISSUE 2026-04-04 23:34:29 +02:00
30 changed files with 2804 additions and 1079 deletions

View File

@@ -4,6 +4,7 @@ permissions:
env: env:
SKIP_MAKE_SETUP_CHECK: 'true' SKIP_MAKE_SETUP_CHECK: 'true'
DOCKER_API_VERSION: '1.43'
on: on:
push: push:
@@ -49,11 +50,15 @@ jobs:
fi fi
- name: Login to Gitea container registry - name: Login to Gitea container registry
uses: docker/login-action@v3 uses: docker/login-action@v3
env:
DOCKER_API_VERSION: ${{ env.DOCKER_API_VERSION }}
with: with:
username: gitearobot username: gitearobot
password: ${{ secrets.PACKAGE_GITEA_PAT }} password: ${{ secrets.PACKAGE_GITEA_PAT }}
registry: git.disi.dev registry: git.disi.dev
- name: Build and publish - name: Build and publish
env:
DOCKER_API_VERSION: ${{ env.DOCKER_API_VERSION }}
run: | run: |
REPOSITORY_OWNER=$(echo "$GITHUB_REPOSITORY" | awk -F '/' '{print $1}' | tr '[:upper:]' '[:lower:]') REPOSITORY_OWNER=$(echo "$GITHUB_REPOSITORY" | awk -F '/' '{print $1}' | tr '[:upper:]' '[:lower:]')
REPOSITORY_NAME=$(echo "$GITHUB_REPOSITORY" | awk -F '/' '{print $2}' | tr '-' '_') REPOSITORY_NAME=$(echo "$GITHUB_REPOSITORY" | awk -F '/' '{print $2}' | tr '-' '_')

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
sqlite.db
.nicegui/

View File

@@ -40,4 +40,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1 CMD curl -f http://localhost:8000/health || exit 1
# Run application # Run application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -5,10 +5,141 @@ Changelog
(unreleased) (unreleased)
------------ ------------
Fix
~~~
- Add Telegram helper functions, refs NOISSUE. [Simon Diesenreiter]
0.6.3 (2026-04-10)
------------------
Fix
~~~
- N8n workflow generation, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.6.2 (2026-04-10)
------------------
Fix
~~~
- Fix Quasar layout issues, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.6.1 (2026-04-10)
------------------
Fix
~~~
- Fix commit for version push, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
- Chore: add more health info for n8n, refs NOISSUE. [Simon
Diesenreiter]
0.6.0 (2026-04-10)
------------------
- Feat(api): expose database target in health refs NOISSUE. [Simon
Diesenreiter]
- Fix(db): prefer postgres config in production refs NOISSUE. [Simon
Diesenreiter]
0.5.0 (2026-04-10)
------------------
- Feat(dashboard): expose repository urls refs NOISSUE. [Simon
Diesenreiter]
- Feat(factory): serve dashboard at root and create project repos refs
NOISSUE. [Simon Diesenreiter]
0.4.1 (2026-04-10)
------------------
- Fix(ci): pin docker api version for release builds refs NOISSUE.
[Simon Diesenreiter]
0.4.0 (2026-04-10)
------------------
- Chore(git): ignore local sqlite database refs NOISSUE. [Simon
Diesenreiter]
- Feat(factory): implement db-backed dashboard and workflow automation
refs NOISSUE. [Simon Diesenreiter]
0.3.6 (2026-04-04)
------------------
Fix
~~~
- Rename gitea workflow, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.3.5 (2026-04-04)
------------------
Fix
~~~
- Some cleanup, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.3.4 (2026-04-04)
------------------
Fix
~~~
- Fix database init, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.3.3 (2026-04-04)
------------------
Fix
~~~
- Fix runtime errors, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.3.2 (2026-04-04)
------------------
Fix
~~~
- Add back DB init endpoints, ref NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.3.1 (2026-04-04)
------------------
Fix Fix
~~~ ~~~
- Fix broken Docker build, refs NOISSUE. [Simon Diesenreiter] - Fix broken Docker build, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.3.0 (2026-04-04) 0.3.0 (2026-04-04)
------------------ ------------------

View File

@@ -1,5 +1,7 @@
.ONESHELL: .ONESHELL:
DOCKER_API_VERSION ?= 1.43
.PHONY: issetup .PHONY: issetup
issetup: issetup:
@[ -f .git/hooks/commit-msg ] || [ -z ${SKIP_MAKE_SETUP_CHECK+x} ] || (echo "You must run 'make setup' first to initialize the repo!" && exit 1) @[ -f .git/hooks/commit-msg ] || [ -z ${SKIP_MAKE_SETUP_CHECK+x} ] || (echo "You must run 'make setup' first to initialize the repo!" && exit 1)
@@ -42,7 +44,7 @@ release: issetup ## Create a new tag for release.
.PHONY: build .PHONY: build
build: issetup ## Create a new tag for release. build: issetup ## Create a new tag for release.
@docker build -t ai-software-factory:$(cat ai-software-factory/VERSION) -f Containerfile . @DOCKER_API_VERSION=$(DOCKER_API_VERSION) docker build -t ai-software-factory:$(cat ai_software_factory/VERSION) -f Containerfile .
# This project has been generated from rochacbruno/python-project-template # This project has been generated from rochacbruno/python-project-template
#igest__ = 'rochacbruno' #igest__ = 'rochacbruno'

View File

@@ -6,7 +6,7 @@ Automated software generation service powered by Ollama LLM. This service allows
- **Telegram Integration**: Receive software requests via Telegram bot - **Telegram Integration**: Receive software requests via Telegram bot
- **Ollama LLM**: Uses Ollama-hosted models for code generation - **Ollama LLM**: Uses Ollama-hosted models for code generation
- **Git Integration**: Automatically commits code to gitea - **Git Integration**: Creates a dedicated Gitea repository per generated project inside your organization
- **Pull Requests**: Creates PRs for user review before merging - **Pull Requests**: Creates PRs for user review before merging
- **Web UI**: Beautiful dashboard for monitoring project progress - **Web UI**: Beautiful dashboard for monitoring project progress
- **n8n Workflows**: Bridges Telegram with LLMs via n8n webhooks - **n8n Workflows**: Bridges Telegram with LLMs via n8n webhooks
@@ -49,9 +49,19 @@ OLLAMA_MODEL=llama3
# Gitea # Gitea
GITEA_URL=https://gitea.yourserver.com GITEA_URL=https://gitea.yourserver.com
GITEA_TOKEN= analyze your_gitea_api_token GITEA_TOKEN=your_gitea_api_token
GITEA_OWNER=ai-software-factory GITEA_OWNER=ai-software-factory
GITEA_REPO=ai-software-factory # Optional legacy fixed-repository mode. Leave empty to create one repo per project.
GITEA_REPO=
# Database
# In production, provide PostgreSQL settings. They take precedence over the SQLite default.
# Setting USE_SQLITE=false is still supported if you want to make the choice explicit.
POSTGRES_HOST=postgres.yourserver.com
POSTGRES_PORT=5432
POSTGRES_USER=ai_software_factory
POSTGRES_PASSWORD=change-me
POSTGRES_DB=ai_software_factory
# n8n # n8n
N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
@@ -65,7 +75,7 @@ TELEGRAM_CHAT_ID=your_chat_id
```bash ```bash
# Build Docker image # Build Docker image
docker build -t ai-software-factory -f Containerfile . DOCKER_API_VERSION=1.43 docker build -t ai-software-factory -f Containerfile .
# Run with Docker Compose # Run with Docker Compose
docker-compose up -d docker-compose up -d
@@ -83,17 +93,20 @@ docker-compose up -d
2. **Monitor progress via Web UI:** 2. **Monitor progress via Web UI:**
Open `http://yourserver:8000` to see real-time progress Open `http://yourserver:8000/` to see the dashboard and `http://yourserver:8000/api` for API metadata
3. **Review PRs in Gitea:** 3. **Review PRs in Gitea:**
Check your gitea repository for generated PRs Check your gitea repository for generated PRs
If you deploy the container with PostgreSQL environment variables set, the service now selects PostgreSQL automatically even though SQLite remains the default for local/test usage.
## API Endpoints ## API Endpoints
| Endpoint | Method | Description | | Endpoint | Method | Description |
|------|------|-------| |------|------|-------|
| `/` | GET | API information | | `/` | GET | Dashboard |
| `/api` | GET | API information |
| `/health` | GET | Health check | | `/health` | GET | Health check |
| `/generate` | POST | Generate new software | | `/generate` | POST | Generate new software |
| `/status/{project_id}` | GET | Get project status | | `/status/{project_id}` | GET | Get project status |

View File

@@ -15,7 +15,7 @@ OLLAMA_MODEL=llama3
GITEA_URL=https://gitea.yourserver.com GITEA_URL=https://gitea.yourserver.com
GITEA_TOKEN=your_gitea_api_token GITEA_TOKEN=your_gitea_api_token
GITEA_OWNER=your_organization_name GITEA_OWNER=your_organization_name
GITEA_REPO= (optional - leave empty for any repo, or specify a default) GITEA_REPO= (optional legacy fixed repository mode; leave empty to create one repo per project)
# n8n # n8n
# n8n webhook for Telegram integration # n8n webhook for Telegram integration
@@ -30,6 +30,8 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token
TELEGRAM_CHAT_ID=your_chat_id TELEGRAM_CHAT_ID=your_chat_id
# PostgreSQL # PostgreSQL
# In production, provide PostgreSQL settings below. They now take precedence over the SQLite default.
# You can also set USE_SQLITE=false explicitly if you want the intent to be obvious.
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_USER=ai_test POSTGRES_USER=ai_test

View File

@@ -0,0 +1 @@
{"dark_mode":false}

View File

@@ -1 +1 @@
0.3.1 0.6.4

View File

@@ -1,11 +1,11 @@
"""AI Software Factory agents.""" """AI Software Factory agents."""
from agents.orchestrator import AgentOrchestrator from .orchestrator import AgentOrchestrator
from agents.git_manager import GitManager from .git_manager import GitManager
from agents.ui_manager import UIManager from .ui_manager import UIManager
from agents.telegram import TelegramHandler from .telegram import TelegramHandler
from agents.gitea import GiteaAPI from .gitea import GiteaAPI
from agents.database_manager import DatabaseManager from .database_manager import DatabaseManager
__all__ = [ __all__ = [
"AgentOrchestrator", "AgentOrchestrator",

View File

@@ -2,10 +2,35 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
from database import get_db
from models import ( try:
ProjectHistory, ProjectLog, UISnapshot, PullRequestData, SystemLog, UserAction, AuditTrail, PullRequest, ProjectStatus from ..config import settings
) from ..models import (
AuditTrail,
ProjectHistory,
ProjectLog,
ProjectStatus,
PromptCodeLink,
PullRequest,
PullRequestData,
SystemLog,
UISnapshot,
UserAction,
)
except ImportError:
from config import settings
from models import (
AuditTrail,
ProjectHistory,
ProjectLog,
ProjectStatus,
PromptCodeLink,
PullRequest,
PullRequestData,
SystemLog,
UISnapshot,
UserAction,
)
from datetime import datetime from datetime import datetime
import json import json
@@ -61,6 +86,21 @@ class DatabaseManager:
self.db = db self.db = db
self.migrations = DatabaseMigrations(self.db) self.migrations = DatabaseMigrations(self.db)
@staticmethod
def _normalize_metadata(metadata: object) -> dict:
"""Normalize JSON-like metadata stored in audit columns."""
if metadata is None:
return {}
if isinstance(metadata, dict):
return metadata
if isinstance(metadata, str):
try:
parsed = json.loads(metadata)
return parsed if isinstance(parsed, dict) else {"value": parsed}
except json.JSONDecodeError:
return {"value": metadata}
return {"value": metadata}
def log_project_start(self, project_id: str, project_name: str, description: str) -> ProjectHistory: def log_project_start(self, project_id: str, project_name: str, description: str) -> ProjectHistory:
"""Log project start.""" """Log project start."""
history = ProjectHistory( history = ProjectHistory(
@@ -87,6 +127,63 @@ class DatabaseManager:
return history return history
def log_prompt_submission(
self,
history_id: int,
project_id: str,
prompt_text: str,
features: list[str] | None = None,
tech_stack: list[str] | None = None,
actor_name: str = "api",
actor_type: str = "user",
source: str = "generate-endpoint",
) -> AuditTrail | None:
"""Persist the originating prompt so later code changes can be correlated to it."""
history = self.db.query(ProjectHistory).filter(ProjectHistory.id == history_id).first()
if not history:
return None
feature_list = features or []
tech_list = tech_stack or []
history.features = json.dumps(feature_list)
history.current_step_description = "Prompt accepted"
history.current_step_details = prompt_text
self.db.commit()
self.log_user_action(
history_id=history_id,
action_type="PROMPT_SUBMITTED",
actor_type=actor_type,
actor_name=actor_name,
action_description="Submitted software generation request",
action_data={
"prompt": prompt_text,
"features": feature_list,
"tech_stack": tech_list,
"source": source,
},
)
audit = AuditTrail(
project_id=project_id,
action="PROMPT_RECEIVED",
actor=actor_name,
action_type="PROMPT",
details=prompt_text,
message="Software generation prompt received",
metadata_json={
"history_id": history_id,
"prompt_text": prompt_text,
"features": feature_list,
"tech_stack": tech_list,
"source": source,
},
)
self.db.add(audit)
self.db.commit()
self.db.refresh(audit)
return audit
def log_progress_update(self, history_id: int, progress: int, step: str, message: str) -> None: def log_progress_update(self, history_id: int, progress: int, step: str, message: str) -> None:
"""Log progress update.""" """Log progress update."""
history = self.db.query(ProjectHistory).filter( history = self.db.query(ProjectHistory).filter(
@@ -121,6 +218,8 @@ class DatabaseManager:
if history: if history:
history.status = ProjectStatus.COMPLETED.value history.status = ProjectStatus.COMPLETED.value
history.progress = 100
history.current_step = "Completed"
history.completed_at = datetime.utcnow() history.completed_at = datetime.utcnow()
history.message = message history.message = message
self.db.commit() self.db.commit()
@@ -210,6 +309,31 @@ class DatabaseManager:
self.db.refresh(pr) self.db.refresh(pr)
return pr return pr
def _get_latest_ui_snapshot_data(self, history_id: int) -> dict:
"""Return the latest stored UI snapshot payload for a project."""
snapshot = self.db.query(UISnapshot).filter(
UISnapshot.history_id == history_id
).order_by(UISnapshot.created_at.desc(), UISnapshot.id.desc()).first()
if not snapshot:
return {}
return self._normalize_metadata(snapshot.snapshot_data)
def _get_project_repository(self, history: ProjectHistory) -> dict | None:
"""Resolve repository metadata for a project."""
snapshot_data = self._get_latest_ui_snapshot_data(history.id)
repository = snapshot_data.get("repository")
if isinstance(repository, dict) and any(repository.values()):
return repository
if settings.gitea_owner and settings.gitea_repo and settings.gitea_url:
return {
"owner": settings.gitea_owner,
"name": settings.gitea_repo,
"url": f"{settings.gitea_url.rstrip('/')}/{settings.gitea_owner}/{settings.gitea_repo}",
"mode": "shared",
}
return None
def get_project_by_id(self, project_id: str) -> ProjectHistory | None: def get_project_by_id(self, project_id: str) -> ProjectHistory | None:
"""Get project by ID.""" """Get project by ID."""
return self.db.query(ProjectHistory).filter(ProjectHistory.project_id == project_id).first() return self.db.query(ProjectHistory).filter(ProjectHistory.project_id == project_id).first()
@@ -300,7 +424,7 @@ class DatabaseManager:
"actor": audit.actor, "actor": audit.actor,
"action_type": audit.action_type, "action_type": audit.action_type,
"details": audit.details, "details": audit.details,
"metadata_json": audit.metadata_json, "metadata_json": self._normalize_metadata(audit.metadata_json),
"timestamp": audit.created_at.isoformat() if audit.created_at else None "timestamp": audit.created_at.isoformat() if audit.created_at else None
} }
for audit in audits for audit in audits
@@ -317,7 +441,7 @@ class DatabaseManager:
"actor": audit.actor, "actor": audit.actor,
"action_type": audit.action_type, "action_type": audit.action_type,
"details": audit.details, "details": audit.details,
"metadata_json": audit.metadata_json, "metadata_json": self._normalize_metadata(audit.metadata_json),
"timestamp": audit.created_at.isoformat() if audit.created_at else None "timestamp": audit.created_at.isoformat() if audit.created_at else None
} }
for audit in audits for audit in audits
@@ -387,7 +511,9 @@ class DatabaseManager:
] ]
def log_code_change(self, project_id: str, change_type: str, file_path: str, def log_code_change(self, project_id: str, change_type: str, file_path: str,
actor: str, actor_type: str, details: str) -> AuditTrail: actor: str, actor_type: str, details: str,
history_id: int | None = None, prompt_id: int | None = None,
diff_summary: str | None = None) -> AuditTrail:
"""Log a code change.""" """Log a code change."""
audit = AuditTrail( audit = AuditTrail(
project_id=project_id, project_id=project_id,
@@ -396,12 +522,168 @@ class DatabaseManager:
action_type=change_type, action_type=change_type,
details=f"File {file_path} {change_type}", details=f"File {file_path} {change_type}",
message=f"Code change: {file_path}", message=f"Code change: {file_path}",
metadata_json=json.dumps({"file": file_path, "change_type": change_type, "actor": actor}) metadata_json={
"file": file_path,
"change_type": change_type,
"actor": actor,
"actor_type": actor_type,
"history_id": history_id,
"prompt_id": prompt_id,
"details": details,
"diff_summary": diff_summary,
}
) )
self.db.add(audit) self.db.add(audit)
self.db.commit() self.db.commit()
self.db.refresh(audit)
if history_id is not None and prompt_id is not None:
link = PromptCodeLink(
history_id=history_id,
project_id=project_id,
prompt_audit_id=prompt_id,
code_change_audit_id=audit.id,
file_path=file_path,
change_type=change_type,
)
self.db.add(link)
self.db.commit()
return audit return audit
def get_prompt_change_links(self, project_id: str | None = None, limit: int = 200) -> list[dict]:
"""Return stored prompt/code lineage rows."""
query = self.db.query(PromptCodeLink)
if project_id:
query = query.filter(PromptCodeLink.project_id == project_id)
links = query.order_by(PromptCodeLink.created_at.desc()).limit(limit).all()
return [
{
"id": link.id,
"history_id": link.history_id,
"project_id": link.project_id,
"prompt_audit_id": link.prompt_audit_id,
"code_change_audit_id": link.code_change_audit_id,
"file_path": link.file_path,
"change_type": link.change_type,
"created_at": link.created_at.isoformat() if link.created_at else None,
}
for link in links
]
def _build_correlations_from_links(self, project_id: str | None = None, limit: int = 100) -> list[dict]:
"""Build prompt-change correlations from explicit lineage rows."""
prompt_events = self.get_prompt_events(project_id=project_id, limit=limit)
if not prompt_events:
return []
links = self.get_prompt_change_links(project_id=project_id, limit=limit * 10)
if not links:
return []
prompt_map = {prompt["id"]: {**prompt, "changes": []} for prompt in prompt_events}
change_map = {change["id"]: change for change in self.get_code_changes(project_id=project_id, limit=limit * 10)}
for link in links:
prompt = prompt_map.get(link["prompt_audit_id"])
change = change_map.get(link["code_change_audit_id"])
if prompt is None or change is None:
continue
prompt["changes"].append(
{
"id": change["id"],
"file_path": link["file_path"] or change["file_path"],
"change_type": link["change_type"] or change["action_type"],
"details": change["details"],
"diff_summary": change["diff_summary"],
"timestamp": change["timestamp"],
}
)
correlations = [
{
"project_id": prompt["project_id"],
"prompt_id": prompt["id"],
"prompt_text": prompt["prompt_text"],
"features": prompt["features"],
"tech_stack": prompt["tech_stack"],
"timestamp": prompt["timestamp"],
"changes": prompt["changes"],
}
for prompt in prompt_map.values()
]
correlations.sort(key=lambda item: item["timestamp"] or "", reverse=True)
return correlations[:limit]
def _build_correlations_from_audit_fallback(self, project_id: str | None = None, limit: int = 100) -> list[dict]:
"""Fallback correlation builder for older rows without explicit lineage."""
query = self.db.query(AuditTrail)
if project_id:
query = query.filter(AuditTrail.project_id == project_id)
events = query.filter(
AuditTrail.action.in_(["PROMPT_RECEIVED", "CODE_CHANGE"])
).order_by(AuditTrail.project_id.asc(), AuditTrail.created_at.asc(), AuditTrail.id.asc()).all()
grouped: dict[str, list[AuditTrail]] = {}
for event in events:
grouped.setdefault(event.project_id or "", []).append(event)
correlations: list[dict] = []
for grouped_project_id, project_events in grouped.items():
current_prompt: AuditTrail | None = None
current_changes: list[AuditTrail] = []
for event in project_events:
if event.action == "PROMPT_RECEIVED":
if current_prompt is not None:
prompt_metadata = self._normalize_metadata(current_prompt.metadata_json)
correlations.append({
"project_id": grouped_project_id,
"prompt_id": current_prompt.id,
"prompt_text": prompt_metadata.get("prompt_text", current_prompt.details),
"features": prompt_metadata.get("features", []),
"tech_stack": prompt_metadata.get("tech_stack", []),
"timestamp": current_prompt.created_at.isoformat() if current_prompt.created_at else None,
"changes": [
{
"id": change.id,
"file_path": self._normalize_metadata(change.metadata_json).get("file"),
"change_type": change.action_type,
"details": self._normalize_metadata(change.metadata_json).get("details", change.details),
"diff_summary": self._normalize_metadata(change.metadata_json).get("diff_summary"),
"timestamp": change.created_at.isoformat() if change.created_at else None,
}
for change in current_changes
],
})
current_prompt = event
current_changes = []
elif event.action == "CODE_CHANGE" and current_prompt is not None:
current_changes.append(event)
if current_prompt is not None:
prompt_metadata = self._normalize_metadata(current_prompt.metadata_json)
correlations.append({
"project_id": grouped_project_id,
"prompt_id": current_prompt.id,
"prompt_text": prompt_metadata.get("prompt_text", current_prompt.details),
"features": prompt_metadata.get("features", []),
"tech_stack": prompt_metadata.get("tech_stack", []),
"timestamp": current_prompt.created_at.isoformat() if current_prompt.created_at else None,
"changes": [
{
"id": change.id,
"file_path": self._normalize_metadata(change.metadata_json).get("file"),
"change_type": change.action_type,
"details": self._normalize_metadata(change.metadata_json).get("details", change.details),
"diff_summary": self._normalize_metadata(change.metadata_json).get("diff_summary"),
"timestamp": change.created_at.isoformat() if change.created_at else None,
}
for change in current_changes
],
})
correlations.sort(key=lambda item: item["timestamp"] or "", reverse=True)
return correlations[:limit]
def log_commit(self, project_id: str, commit_message: str, actor: str, def log_commit(self, project_id: str, commit_message: str, actor: str,
actor_type: str = "agent") -> AuditTrail: actor_type: str = "agent") -> AuditTrail:
"""Log a git commit.""" """Log a git commit."""
@@ -429,7 +711,10 @@ class DatabaseManager:
"project": None, "project": None,
"logs": [], "logs": [],
"actions": [], "actions": [],
"audit_trail": [] "audit_trail": [],
"prompts": [],
"code_changes": [],
"prompt_change_correlations": [],
} }
# Get logs # Get logs
@@ -447,6 +732,11 @@ class DatabaseManager:
AuditTrail.project_id == project_id AuditTrail.project_id == project_id
).order_by(AuditTrail.created_at.desc()).all() ).order_by(AuditTrail.created_at.desc()).all()
prompts = self.get_prompt_events(project_id=project_id)
code_changes = self.get_code_changes(project_id=project_id)
correlations = self.get_prompt_change_correlations(project_id=project_id)
repository = self._get_project_repository(history)
return { return {
"project": { "project": {
"id": history.id, "id": history.id,
@@ -458,6 +748,7 @@ class DatabaseManager:
"message": history.message, "message": history.message,
"error_message": history.error_message, "error_message": history.error_message,
"current_step": history.current_step, "current_step": history.current_step,
"repository": repository,
"completed_at": history.completed_at.isoformat() if history.completed_at else None, "completed_at": history.completed_at.isoformat() if history.completed_at else None,
"created_at": history.started_at.isoformat() if history.started_at else None "created_at": history.started_at.isoformat() if history.started_at else None
}, },
@@ -489,13 +780,103 @@ class DatabaseManager:
"actor": audit.actor, "actor": audit.actor,
"action_type": audit.action_type, "action_type": audit.action_type,
"details": audit.details, "details": audit.details,
"metadata": self._normalize_metadata(audit.metadata_json),
"timestamp": audit.created_at.isoformat() if audit.created_at else None "timestamp": audit.created_at.isoformat() if audit.created_at else None
} }
for audit in audit_trails for audit in audit_trails
],
"prompts": prompts,
"code_changes": code_changes,
"prompt_change_correlations": correlations,
"repository": repository,
}
def get_prompt_events(self, project_id: str | None = None, limit: int = 100) -> list[dict]:
"""Return prompt receipt events from the audit trail."""
query = self.db.query(AuditTrail).filter(AuditTrail.action == "PROMPT_RECEIVED")
if project_id:
query = query.filter(AuditTrail.project_id == project_id)
prompts = query.order_by(AuditTrail.created_at.desc()).limit(limit).all()
return [
{
"id": prompt.id,
"project_id": prompt.project_id,
"actor": prompt.actor,
"message": prompt.message,
"prompt_text": self._normalize_metadata(prompt.metadata_json).get("prompt_text", prompt.details),
"features": self._normalize_metadata(prompt.metadata_json).get("features", []),
"tech_stack": self._normalize_metadata(prompt.metadata_json).get("tech_stack", []),
"history_id": self._normalize_metadata(prompt.metadata_json).get("history_id"),
"timestamp": prompt.created_at.isoformat() if prompt.created_at else None,
}
for prompt in prompts
] ]
def get_code_changes(self, project_id: str | None = None, limit: int = 100) -> list[dict]:
"""Return code change events from the audit trail."""
query = self.db.query(AuditTrail).filter(AuditTrail.action == "CODE_CHANGE")
if project_id:
query = query.filter(AuditTrail.project_id == project_id)
changes = query.order_by(AuditTrail.created_at.desc()).limit(limit).all()
return [
{
"id": change.id,
"project_id": change.project_id,
"action_type": change.action_type,
"actor": change.actor,
"details": change.details,
"file_path": self._normalize_metadata(change.metadata_json).get("file"),
"prompt_id": self._normalize_metadata(change.metadata_json).get("prompt_id"),
"history_id": self._normalize_metadata(change.metadata_json).get("history_id"),
"diff_summary": self._normalize_metadata(change.metadata_json).get("diff_summary"),
"timestamp": change.created_at.isoformat() if change.created_at else None,
}
for change in changes
]
def get_prompt_change_correlations(self, project_id: str | None = None, limit: int = 100) -> list[dict]:
"""Correlate prompts with the concrete code changes that followed them."""
correlations = self._build_correlations_from_links(project_id=project_id, limit=limit)
if correlations:
return correlations
return self._build_correlations_from_audit_fallback(project_id=project_id, limit=limit)
def get_dashboard_snapshot(self, limit: int = 8) -> dict:
"""Return DB-backed dashboard data for the UI."""
projects = self.db.query(ProjectHistory).order_by(ProjectHistory.updated_at.desc()).limit(limit).all()
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(),
"prompt_events": self.db.query(AuditTrail).filter(AuditTrail.action == "PROMPT_RECEIVED").count(),
"code_changes": self.db.query(AuditTrail).filter(AuditTrail.action == "CODE_CHANGE").count(),
},
"projects": [self.get_project_audit_data(project.project_id) for project in projects],
"system_logs": [
{
"id": log.id,
"component": log.component,
"level": log.log_level,
"message": log.log_message,
"timestamp": log.created_at.isoformat() if log.created_at else None,
}
for log in system_logs
],
"lineage_links": self.get_prompt_change_links(limit=limit * 10),
"correlations": self.get_prompt_change_correlations(limit=limit),
} }
def cleanup_audit_trail(self) -> None: def cleanup_audit_trail(self) -> None:
"""Clear all audit trail entries.""" """Clear audit-related test data across all related tables."""
self.db.query(PromptCodeLink).delete()
self.db.query(PullRequest).delete()
self.db.query(UISnapshot).delete()
self.db.query(UserAction).delete()
self.db.query(ProjectLog).delete()
self.db.query(AuditTrail).delete() self.db.query(AuditTrail).delete()
self.db.query(SystemLog).delete()
self.db.query(ProjectHistory).delete()
self.db.commit() self.db.commit()

View File

@@ -2,8 +2,14 @@
import os import os
import subprocess import subprocess
from pathlib import Path
from typing import Optional from typing import Optional
try:
from ..config import settings
except ImportError:
from config import settings
class GitManager: class GitManager:
"""Manages git operations for the project.""" """Manages git operations for the project."""
@@ -12,7 +18,15 @@ class GitManager:
if not project_id: if not project_id:
raise ValueError("project_id cannot be empty or None") raise ValueError("project_id cannot be empty or None")
self.project_id = project_id self.project_id = project_id
self.project_dir = f"{os.path.dirname(__file__)}/../../test-project/{project_id}" project_path = Path(project_id)
if project_path.is_absolute() or len(project_path.parts) > 1:
resolved = project_path.expanduser().resolve()
else:
base_root = settings.projects_root
if base_root.name != "test-project":
base_root = base_root / "test-project"
resolved = (base_root / project_id).resolve()
self.project_dir = str(resolved)
def init_repo(self): def init_repo(self):
"""Initialize git repository.""" """Initialize git repository."""

View File

@@ -1,7 +1,6 @@
"""Gitea API integration for commits and PRs.""" """Gitea API integration for repository and pull request operations."""
import os import os
from typing import Optional
class GiteaAPI: class GiteaAPI:
@@ -14,7 +13,7 @@ class GiteaAPI:
self.repo = repo self.repo = repo
self.headers = { self.headers = {
"Authorization": f"token {token}", "Authorization": f"token {token}",
"Content-Type": "application/json" "Content-Type": "application/json",
} }
def get_config(self) -> dict: def get_config(self) -> dict:
@@ -23,60 +22,91 @@ class GiteaAPI:
token = os.getenv("GITEA_TOKEN", "") token = os.getenv("GITEA_TOKEN", "")
owner = os.getenv("GITEA_OWNER", "ai-test") owner = os.getenv("GITEA_OWNER", "ai-test")
repo = os.getenv("GITEA_REPO", "") repo = os.getenv("GITEA_REPO", "")
# Allow empty repo for any repo mode (org/repo pattern)
if not repo:
repo = "any-repo" # Use this as a placeholder for org/repo operations
# Check for repo suffix pattern (e.g., repo-* for multiple repos)
repo_suffix = os.getenv("GITEA_REPO_SUFFIX", "")
return { return {
"base_url": base_url.rstrip("/"), "base_url": base_url.rstrip("/"),
"token": token, "token": token,
"owner": owner, "owner": owner,
"repo": repo, "repo": repo,
"repo_suffix": repo_suffix, "supports_project_repos": not bool(repo),
"supports_any_repo": not repo or repo_suffix
} }
def get_auth_headers(self) -> dict: def get_auth_headers(self) -> dict:
"""Get authentication headers.""" """Get authentication headers."""
return { return {
"Authorization": f"token {self.token}", "Authorization": f"token {self.token}",
"Content-Type": "application/json" "Content-Type": "application/json",
} }
async def create_branch(self, branch: str, base: str = "main", owner: str | None = None, repo: str | None = None): def _api_url(self, path: str) -> str:
"""Create a new branch. """Build a Gitea API URL from a relative path."""
return f"{self.base_url}/api/v1/{path.lstrip('/')}"
Args:
branch: Branch name to create
base: Base branch to create from (default: "main")
owner: Organization/owner name (optional, falls back to configured owner)
repo: Repository name (optional, falls back to configured repo)
Returns:
API response or error message
"""
# Use provided owner/repo or fall back to configured values
_owner = owner or self.owner
_repo = repo or self.repo
url = f"{self.base_url}/repos/{_owner}/{_repo}/branches/{branch}"
payload = {"base": base}
async def _request(self, method: str, path: str, payload: dict | None = None) -> dict:
"""Perform a Gitea API request and normalize the response."""
try: try:
import aiohttp import aiohttp
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post(url, headers=self.get_auth_headers(), json=payload) as resp: async with session.request(
if resp.status == 201: method,
self._api_url(path),
headers=self.get_auth_headers(),
json=payload,
) as resp:
if resp.status in (200, 201):
return await resp.json() return await resp.json()
else: return {"error": await resp.text(), "status_code": resp.status}
return {"error": await resp.text()}
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
def build_project_repo_name(self, project_id: str, project_name: str | None = None) -> str:
"""Build a repository name for a generated project."""
preferred = (project_name or project_id or "project").strip().lower().replace(" ", "-")
sanitized = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in preferred)
while "--" in sanitized:
sanitized = sanitized.replace("--", "-")
return sanitized.strip("-") or project_id
async def create_repo(
self,
repo_name: str,
owner: str | None = None,
description: str | None = None,
private: bool = False,
auto_init: bool = True,
) -> dict:
"""Create a repository inside the configured organization."""
_owner = owner or self.owner
if not _owner:
return {"error": "Owner or organization is required"}
payload = {
"name": repo_name,
"description": description or f"AI-generated project repository for {repo_name}",
"private": private,
"auto_init": auto_init,
"default_branch": "main",
}
result = await self._request("POST", f"orgs/{_owner}/repos", payload)
if result.get("status_code") == 409:
existing = await self.get_repo_info(owner=_owner, repo=repo_name)
if not existing.get("error"):
existing["status"] = "exists"
return existing
if not result.get("error"):
result.setdefault("status", "created")
return result
async def create_branch(self, branch: str, base: str = "main", owner: str | None = None, repo: str | None = None):
"""Create a new branch."""
_owner = owner or self.owner
_repo = repo or self.repo
return await self._request(
"POST",
f"repos/{_owner}/{_repo}/branches",
{"new_branch_name": branch, "old_ref_name": base},
)
async def create_pull_request( async def create_pull_request(
self, self,
title: str, title: str,
@@ -84,43 +114,18 @@ class GiteaAPI:
owner: str, owner: str,
repo: str, repo: str,
base: str = "main", base: str = "main",
head: str | None = None head: str | None = None,
) -> dict: ) -> dict:
"""Create a pull request. """Create a pull request."""
Args:
title: PR title
body: PR description
owner: Organization/owner name
repo: Repository name
base: Base branch (default: "main")
head: Head branch (optional, auto-generated if not provided)
Returns:
API response or error message
"""
_owner = owner or self.owner _owner = owner or self.owner
_repo = repo or self.repo _repo = repo or self.repo
url = f"{self.base_url}/repos/{_owner}/{_repo}/pulls"
payload = { payload = {
"title": title, "title": title,
"body": body, "body": body,
"base": {"branch": base}, "base": base,
"head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}" "head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}",
} }
return await self._request("POST", f"repos/{_owner}/{_repo}/pulls", payload)
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=self.get_auth_headers(), json=payload) as resp:
if resp.status == 201:
return await resp.json()
else:
return {"error": await resp.text()}
except Exception as e:
return {"error": str(e)}
async def push_commit( async def push_commit(
self, self,
@@ -128,25 +133,13 @@ class GiteaAPI:
files: list[dict], files: list[dict],
message: str, message: str,
owner: str | None = None, owner: str | None = None,
repo: str | None = None repo: str | None = None,
) -> dict: ) -> dict:
""" """Push files to a branch.
Push files to a branch.
In production, this would use gitea's API or git push. In production, this would use gitea's API or git push.
For now, we'll simulate the operation. For now, this remains simulated.
Args:
branch: Branch name
files: List of files to push
message: Commit message
owner: Organization/owner name (optional, falls back to configured owner)
repo: Repository name (optional, falls back to configured repo)
Returns:
Status response
""" """
# Use provided owner/repo or fall back to configured values
_owner = owner or self.owner _owner = owner or self.owner
_repo = repo or self.repo _repo = repo or self.repo
@@ -156,35 +149,15 @@ class GiteaAPI:
"message": message, "message": message,
"files": files, "files": files,
"owner": _owner, "owner": _owner,
"repo": _repo "repo": _repo,
} }
async def get_repo_info(self, owner: str | None = None, repo: str | None = None) -> dict: async def get_repo_info(self, owner: str | None = None, repo: str | None = None) -> dict:
"""Get repository information. """Get repository information."""
Args:
owner: Organization/owner name (optional, falls back to configured owner)
repo: Repository name (optional, falls back to configured repo)
Returns:
Repository info or error message
"""
# Use provided owner/repo or fall back to configured values
_owner = owner or self.owner _owner = owner or self.owner
_repo = repo or self.repo _repo = repo or self.repo
if not _repo: if not _repo:
return {"error": "Repository name required for org operations"} return {"error": "Repository name required for org operations"}
url = f"{self.base_url}/repos/{_owner}/{_repo}" return await self._request("GET", f"repos/{_owner}/{_repo}")
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self.get_auth_headers()) as resp:
if resp.status == 200:
return await resp.json()
else:
return {"error": await resp.text()}
except Exception as e:
return {"error": str(e)}

View File

@@ -1,8 +1,14 @@
"""n8n setup agent for automatic webhook configuration.""" """n8n setup agent for automatic webhook configuration."""
import json import json
from urllib import error as urllib_error
from urllib import request as urllib_request
from typing import Optional from typing import Optional
from config import settings
try:
from ..config import settings
except ImportError:
from config import settings
class N8NSetupAgent: class N8NSetupAgent:
@@ -22,95 +28,423 @@ class N8NSetupAgent:
self.webhook_token = webhook_token self.webhook_token = webhook_token
self.session = None self.session = None
def _api_path(self, path: str) -> str:
"""Build a full n8n API URL for a given endpoint path."""
if path.startswith("http://") or path.startswith("https://"):
return path
trimmed = path.lstrip("/")
if trimmed.startswith("api/"):
return f"{self.api_url}/{trimmed}"
return f"{self.api_url}/api/v1/{trimmed}"
def get_auth_headers(self) -> dict: def get_auth_headers(self) -> dict:
"""Get authentication headers for n8n API using webhook token.""" """Get authentication headers for n8n API using webhook token."""
return { headers = {
"n8n-no-credentials": "true", "n8n-no-credentials": "true",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "AI-Software-Factory" "User-Agent": "AI-Software-Factory"
} }
if self.webhook_token:
headers["X-N8N-API-KEY"] = self.webhook_token
return headers
def _extract_message(self, payload: object) -> str:
"""Extract a useful message from an n8n response payload."""
if isinstance(payload, dict):
for key in ("message", "error", "reason", "hint", "text"):
value = payload.get(key)
if value:
return str(value)
if payload:
return json.dumps(payload)
if payload is None:
return "No response body"
return str(payload)
def _normalize_success(self, method: str, url: str, status_code: int, payload: object) -> dict:
"""Normalize a successful n8n API response."""
if isinstance(payload, dict):
response = dict(payload)
response.setdefault("status_code", status_code)
response.setdefault("url", url)
response.setdefault("method", method)
return response
return {"data": payload, "status_code": status_code, "url": url, "method": method}
def _normalize_error(self, method: str, url: str, status_code: int | None, payload: object) -> dict:
"""Normalize an error response with enough detail for diagnostics."""
message = self._extract_message(payload)
prefix = f"{method} {url}"
if status_code is not None:
return {
"error": f"{prefix} returned {status_code}: {message}",
"message": message,
"status_code": status_code,
"url": url,
"method": method,
"payload": payload,
}
return {
"error": f"{prefix} failed: {message}",
"message": message,
"status_code": None,
"url": url,
"method": method,
"payload": payload,
}
def _health_check_row(self, name: str, result: dict) -> dict:
"""Convert a raw request result into a UI/API-friendly health check row."""
return {
"name": name,
"ok": not bool(result.get("error")),
"url": result.get("url"),
"method": result.get("method", "GET"),
"status_code": result.get("status_code"),
"message": result.get("message") or ("ok" if not result.get("error") else result.get("error")),
}
def _health_suggestion(self, checks: list[dict]) -> str | None:
"""Return a suggestion based on failed n8n health checks."""
status_codes = {check.get("status_code") for check in checks if check.get("status_code") is not None}
if status_codes and status_codes.issubset({404}):
return "Verify N8N_API_URL points to the base n8n URL, for example http://host:5678, not /api/v1 or a webhook URL."
if status_codes & {401, 403}:
return "Check the configured n8n API key or authentication method."
return "Verify the n8n URL, API key, and that the n8n API is reachable from this container."
def _build_health_result(self, healthz_result: dict, workflows_result: dict) -> dict:
"""Build a consolidated health result from the performed checks."""
checks = [
self._health_check_row("healthz", healthz_result),
self._health_check_row("workflows", workflows_result),
]
if not healthz_result.get("error"):
return {
"status": "ok",
"message": "n8n is reachable via /healthz.",
"api_url": self.api_url,
"auth_configured": bool(self.webhook_token),
"checked_via": "healthz",
"checks": checks,
}
if not workflows_result.get("error"):
workflows = workflows_result.get("data")
workflow_count = len(workflows) if isinstance(workflows, list) else None
return {
"status": "ok",
"message": "n8n is reachable via the workflows API, but /healthz is unavailable.",
"api_url": self.api_url,
"auth_configured": bool(self.webhook_token),
"checked_via": "workflows",
"workflow_count": workflow_count,
"checks": checks,
}
suggestion = self._health_suggestion(checks)
return {
"status": "error",
"error": "n8n health checks failed",
"message": "n8n health checks failed.",
"api_url": self.api_url,
"auth_configured": bool(self.webhook_token),
"checked_via": "none",
"checks": checks,
"suggestion": suggestion,
}
async def _request(self, method: str, path: str, **kwargs) -> dict:
"""Send a request to n8n and normalize the response."""
import aiohttp
headers = kwargs.pop("headers", None) or self.get_auth_headers()
url = self._api_path(path)
try:
async with aiohttp.ClientSession() as session:
async with session.request(method, url, headers=headers, **kwargs) as resp:
content_type = resp.headers.get("Content-Type", "")
if "application/json" in content_type:
payload = await resp.json()
else:
payload = {"text": await resp.text()}
if 200 <= resp.status < 300:
return self._normalize_success(method, url, resp.status, payload)
return self._normalize_error(method, url, resp.status, payload)
except Exception as e:
return self._normalize_error(method, url, None, {"message": str(e)})
def _request_sync(self, method: str, path: str, **kwargs) -> dict:
"""Send a synchronous request to n8n for dashboard health snapshots."""
headers = kwargs.pop("headers", None) or self.get_auth_headers()
payload = kwargs.pop("json", None)
timeout = kwargs.pop("timeout", 5)
url = self._api_path(path)
data = None
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib_request.Request(url, data=data, headers=headers, method=method)
try:
with urllib_request.urlopen(req, timeout=timeout) as resp:
raw_body = resp.read().decode("utf-8")
content_type = resp.headers.get("Content-Type", "")
if "application/json" in content_type and raw_body:
parsed = json.loads(raw_body)
elif raw_body:
parsed = {"text": raw_body}
else:
parsed = {}
return self._normalize_success(method, url, resp.status, parsed)
except urllib_error.HTTPError as exc:
raw_body = exc.read().decode("utf-8") if exc.fp else ""
try:
parsed = json.loads(raw_body) if raw_body else {}
except json.JSONDecodeError:
parsed = {"text": raw_body} if raw_body else {}
return self._normalize_error(method, url, exc.code, parsed)
except Exception as exc:
return self._normalize_error(method, url, None, {"message": str(exc)})
async def get_workflow(self, workflow_name: str) -> Optional[dict]: async def get_workflow(self, workflow_name: str) -> Optional[dict]:
"""Get a workflow by name.""" """Get a workflow by name."""
import aiohttp workflows = await self.list_workflows()
try: if isinstance(workflows, dict) and workflows.get("error"):
async with aiohttp.ClientSession() as session: return workflows
# Use the webhook URL directly for workflow operations for workflow in workflows:
# n8n supports calling workflows via /webhook/ path with query params if workflow.get("name") == workflow_name:
# For API token auth, n8n checks the token against webhook credentials return workflow
headers = self.get_auth_headers()
# Try standard workflow endpoint first (for API token setup)
async with session.get(
f"{self.api_url}/workflow/{workflow_name}.json",
headers=headers
) as resp:
if resp.status == 200:
return await resp.json()
elif resp.status == 404:
return None return None
else:
return {"error": f"Status {resp.status}"}
except Exception as e:
return {"error": str(e)}
async def create_workflow(self, workflow_json: dict) -> dict: async def create_workflow(self, workflow_json: dict) -> dict:
"""Create or update a workflow.""" """Create or update a workflow."""
import aiohttp return await self._request("POST", "workflows", json=self._workflow_payload(workflow_json))
try:
async with aiohttp.ClientSession() as session:
# Use POST to create/update workflow
headers = self.get_auth_headers()
async with session.post( def _workflow_payload(self, workflow_json: dict) -> dict:
f"{self.api_url}/workflow", """Return a workflow payload without server-managed read-only fields."""
headers=headers, payload = dict(workflow_json)
json=workflow_json payload.pop("active", None)
) as resp: payload.pop("id", None)
if resp.status == 200 or resp.status == 201: payload.pop("createdAt", None)
return await resp.json() payload.pop("updatedAt", None)
else: payload.pop("versionId", None)
return {"error": f"Status {resp.status}: {await resp.text()}"} return payload
except Exception as e:
return {"error": str(e)} async def _update_workflow_via_put(self, workflow_id: str, workflow_json: dict) -> dict:
"""Fallback update path for n8n instances that only support PUT."""
return await self._request("PUT", f"workflows/{workflow_id}", json=self._workflow_payload(workflow_json))
async def update_workflow(self, workflow_id: str, workflow_json: dict) -> dict:
"""Update an existing workflow."""
result = await self._request("PATCH", f"workflows/{workflow_id}", json=self._workflow_payload(workflow_json))
if result.get("status_code") == 405:
fallback = await self._update_workflow_via_put(workflow_id, workflow_json)
if not fallback.get("error") and isinstance(fallback, dict):
fallback.setdefault("method", "PUT")
return fallback
return result
async def enable_workflow(self, workflow_id: str) -> dict: async def enable_workflow(self, workflow_id: str) -> dict:
"""Enable a workflow.""" """Enable a workflow."""
import aiohttp result = await self._request("POST", f"workflows/{workflow_id}/activate")
try: if result.get("error"):
async with aiohttp.ClientSession() as session: fallback = await self._request("PATCH", f"workflows/{workflow_id}", json={"active": True})
headers = self.get_auth_headers() if fallback.get("error"):
if fallback.get("status_code") == 405:
async with session.post( put_fallback = await self._request("PUT", f"workflows/{workflow_id}", json={"active": True})
f"{self.api_url}/workflow/{workflow_id}/toggle", if put_fallback.get("error"):
headers=headers, return put_fallback
json={"state": True} return {"success": True, "id": workflow_id, "method": "put"}
) as resp: return fallback
if resp.status in (200, 201): return {"success": True, "id": workflow_id, "method": "patch"}
return {"success": True, "id": workflow_id} return {"success": True, "id": workflow_id, "method": "activate"}
else:
return {"error": f"Status {resp.status}: {await resp.text()}"}
except Exception as e:
return {"error": str(e)}
async def list_workflows(self) -> list: async def list_workflows(self) -> list:
"""List all workflows.""" """List all workflows."""
import aiohttp result = await self._request("GET", "workflows")
try: if result.get("error"):
async with aiohttp.ClientSession() as session: return result
headers = self.get_auth_headers() if isinstance(result, list):
return result
if isinstance(result, dict):
for key in ("data", "workflows"):
value = result.get(key)
if isinstance(value, list):
return value
return []
async with session.get( def build_telegram_workflow(self, webhook_path: str, backend_url: str) -> dict:
f"{self.api_url}/workflow", """Build the Telegram-to-backend workflow definition."""
headers=headers normalized_path = webhook_path.strip().strip("/") or "telegram"
) as resp: return {
if resp.status == 200: "name": "Telegram to AI Software Factory",
return await resp.json() "settings": {"executionOrder": "v1"},
else: "nodes": [
{
"id": "webhook-node",
"name": "Telegram Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [-520, 120],
"parameters": {
"httpMethod": "POST",
"path": normalized_path,
"responseMode": "responseNode",
"options": {},
},
},
{
"id": "parse-node",
"name": "Prepare Software Request",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-200, 120],
"parameters": {
"language": "javaScript",
"jsCode": "const body = $json.body ?? $json;\nconst message = body.message ?? body;\nconst text = String(message.text ?? '').trim();\nconst lines = text.split(/\\r?\\n/);\nconst request = { name: null, description: '', features: [], tech_stack: [] };\nlet nameIndex = -1;\nlet featuresIndex = -1;\nlet techIndex = -1;\nfor (let i = 0; i < lines.length; i += 1) {\n const line = lines[i].trim();\n if (line.toLowerCase().startsWith('name:')) { request.name = line.split(':', 2)[1]?.trim() || null; nameIndex = i; }\n if (line.toLowerCase().startsWith('features:') && featuresIndex === -1) { featuresIndex = i; }\n if (line.toLowerCase().startsWith('tech stack:') && techIndex === -1) { techIndex = i; }\n}\nif (nameIndex >= 0) {\n const descriptionEnd = featuresIndex >= 0 ? featuresIndex : (techIndex >= 0 ? techIndex : lines.length);\n request.description = lines.slice(nameIndex + 1, descriptionEnd).join('\\n').replace(/^description:\\s*/i, '').trim();\n}\nfunction collectList(startIndex, fieldName) {\n if (startIndex < 0) return;\n const firstLine = lines[startIndex].split(':').slice(1).join(':').trim();\n if (firstLine && !firstLine.startsWith('-') && !firstLine.startsWith('*')) {\n request[fieldName].push(...firstLine.split(',').map(item => item.trim()).filter(Boolean));\n }\n for (const rawLine of lines.slice(startIndex + 1)) {\n const line = rawLine.trim();\n if (!line) continue;\n if (/^[A-Za-z ]+:/.test(line)) break;\n if (line.startsWith('-') || line.startsWith('*')) {\n const value = line.slice(1).trim();\n if (value) request[fieldName].push(value);\n }\n }\n}\ncollectList(featuresIndex, 'features');\ncollectList(techIndex, 'tech_stack');\nif (!request.name || request.features.length === 0) { throw new Error('Could not parse software request from Telegram message'); }\nreturn [{ json: { ...request, _source: { raw_text: text, chat_id: message.chat?.id ?? null } } }];",
},
},
{
"id": "api-node",
"name": "AI Software Factory API",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [120, 120],
"parameters": {
"method": "POST",
"url": backend_url,
"sendBody": True,
"specifyBody": "json",
"jsonBody": "={{ $json }}",
"options": {"response": {"response": {"fullResponse": False}}},
},
},
{
"id": "response-node",
"name": "Respond to Telegram Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.2,
"position": [420, 120],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
},
},
],
"connections": {
"Telegram Webhook": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]},
"Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]},
"AI Software Factory API": {"main": [[{"node": "Respond to Telegram Webhook", "type": "main", "index": 0}]]},
},
}
def build_telegram_trigger_workflow(
self,
backend_url: str,
credential_name: str,
) -> dict:
"""Build a production Telegram Trigger based workflow."""
return {
"name": "Telegram to AI Software Factory",
"settings": {"executionOrder": "v1"},
"nodes": [
{
"id": "telegram-trigger-node",
"name": "Telegram Trigger",
"type": "n8n-nodes-base.telegramTrigger",
"typeVersion": 1,
"position": [-520, 120],
"parameters": {"updates": ["message"]},
"credentials": {"telegramApi": {"name": credential_name}},
},
{
"id": "parse-node",
"name": "Prepare Software Request",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-180, 120],
"parameters": {
"language": "javaScript",
"jsCode": "const message = $json.message ?? $json;\nconst text = String(message.text ?? '').trim();\nconst lines = text.split(/\\r?\\n/);\nconst request = { name: null, description: '', features: [], tech_stack: [], _source: { raw_text: text, chat_id: message.chat?.id ?? null } };\nlet nameIndex = -1;\nlet featuresIndex = -1;\nlet techIndex = -1;\nfor (let i = 0; i < lines.length; i += 1) {\n const line = lines[i].trim();\n if (line.toLowerCase().startsWith('name:')) { request.name = line.split(':', 2)[1]?.trim() || null; nameIndex = i; }\n if (line.toLowerCase().startsWith('features:') && featuresIndex === -1) { featuresIndex = i; }\n if (line.toLowerCase().startsWith('tech stack:') && techIndex === -1) { techIndex = i; }\n}\nif (nameIndex >= 0) {\n const descriptionEnd = featuresIndex >= 0 ? featuresIndex : (techIndex >= 0 ? techIndex : lines.length);\n request.description = lines.slice(nameIndex + 1, descriptionEnd).join('\\n').replace(/^description:\\s*/i, '').trim();\n}\nfunction collectList(startIndex, fieldName) {\n if (startIndex < 0) return;\n const firstLine = lines[startIndex].split(':').slice(1).join(':').trim();\n if (firstLine && !firstLine.startsWith('-') && !firstLine.startsWith('*')) {\n request[fieldName].push(...firstLine.split(',').map(item => item.trim()).filter(Boolean));\n }\n for (const rawLine of lines.slice(startIndex + 1)) {\n const line = rawLine.trim();\n if (!line) continue;\n if (/^[A-Za-z ]+:/.test(line)) break;\n if (line.startsWith('-') || line.startsWith('*')) {\n const value = line.slice(1).trim();\n if (value) request[fieldName].push(value);\n }\n }\n}\ncollectList(featuresIndex, 'features');\ncollectList(techIndex, 'tech_stack');\nif (!request.name || request.features.length === 0) { throw new Error('Could not parse software request from Telegram message'); }\nreturn [{ json: request }];",
},
},
{
"id": "api-node",
"name": "AI Software Factory API",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [120, 120],
"parameters": {
"method": "POST",
"url": backend_url,
"sendBody": True,
"specifyBody": "json",
"jsonBody": "={{ $json }}",
"options": {"response": {"response": {"fullResponse": False}}},
},
},
{
"id": "reply-node",
"name": "Send Telegram Update",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1,
"position": [420, 120],
"parameters": {
"resource": "message",
"operation": "sendMessage",
"chatId": "={{ $('Telegram Trigger').item.json.message.chat.id }}",
"text": "={{ $json.data ? `Generated ${$json.data.name} (${($json.data.changed_files || []).length} files)` : ($json.message || 'Software generation request accepted') }}",
},
"credentials": {"telegramApi": {"name": credential_name}},
},
],
"connections": {
"Telegram Trigger": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]},
"Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]},
"AI Software Factory API": {"main": [[{"node": "Send Telegram Update", "type": "main", "index": 0}]]},
},
}
async def list_credentials(self) -> list:
"""List n8n credentials."""
result = await self._request("GET", "credentials")
if result.get("error"):
return [] return []
except Exception as e: if isinstance(result, list):
return result
if isinstance(result, dict):
for key in ("data", "credentials"):
value = result.get(key)
if isinstance(value, list):
return value
return [] return []
async def get_credential(self, credential_name: str, credential_type: str = "telegramApi") -> Optional[dict]:
"""Get an existing credential by name and type."""
credentials = await self.list_credentials()
for credential in credentials:
if credential.get("name") == credential_name and credential.get("type") == credential_type:
return credential
return None
async def create_credential(self, name: str, credential_type: str, data: dict) -> dict:
"""Create an n8n credential."""
payload = {"name": name, "type": credential_type, "data": data}
return await self._request("POST", "credentials", json=payload)
async def ensure_telegram_credential(self, bot_token: str, credential_name: str) -> dict:
"""Ensure a Telegram credential exists for the workflow trigger."""
existing = await self.get_credential(credential_name)
if existing:
return existing
return await self.create_credential(
name=credential_name,
credential_type="telegramApi",
data={"accessToken": bot_token},
)
async def setup_telegram_workflow(self, webhook_path: str) -> dict: async def setup_telegram_workflow(self, webhook_path: str) -> dict:
"""Setup the Telegram webhook workflow in n8n. """Setup the Telegram webhook workflow in n8n.
@@ -120,117 +454,93 @@ class N8NSetupAgent:
Returns: Returns:
Result of setup operation Result of setup operation
""" """
import os return await self.setup(
webhook_token = os.getenv("TELEGRAM_BOT_TOKEN", "") webhook_path=webhook_path,
backend_url=f"{settings.backend_public_url}/generate",
# Define the workflow using n8n's Telegram trigger force_update=False,
workflow = { )
"name": "Telegram to AI Software Factory",
"nodes": [
{
"parameters": {
"httpMethod": "post",
"responseMode": "response",
"path": webhook_path or "telegram",
"httpBody": "={{ json.stringify($json) }}",
"httpAuthType": "headerParam",
"headerParams": {
"x-n8n-internal": "true",
"content-type": "application/json"
}
},
"id": "webhook-node",
"name": "Telegram Webhook"
},
{
"parameters": {
"operation": "editFields",
"fields": "json",
"editFieldsValue": "={{ json.parse($json.text) }}",
"options": {}
},
"id": "parse-node",
"name": "Parse Message"
},
{
"parameters": {
"url": "http://localhost:8000/generate",
"method": "post",
"sendBody": True,
"responseMode": "onReceived",
"ignoreSSL": True,
"retResponse": True,
"sendQueryParams": False
},
"id": "api-node",
"name": "AI Software Factory API"
},
{
"parameters": {
"operation": "editResponse",
"editResponseValue": "={{ $json }}"
},
"id": "response-node",
"name": "Response Builder"
}
],
"connections": {
"Telegram Webhook": {
"webhook": ["parse"]
},
"Parse Message": {
"API Call": ["POST"]
},
"Response Builder": {
"respondToWebhook": ["response"]
}
},
"settings": {
"executionOrder": "v1"
}
}
# Create the workflow
result = await self.create_workflow(workflow)
if result.get("success") or result.get("id"):
# Try to enable the workflow
enable_result = await self.enable_workflow(result.get("id", ""))
result.update(enable_result)
return result
async def health_check(self) -> dict: async def health_check(self) -> dict:
"""Check n8n API health.""" """Check n8n API health."""
import aiohttp result = await self._request("GET", f"{self.api_url}/healthz")
try: fallback = await self._request("GET", "workflows")
async with aiohttp.ClientSession() as session: return self._build_health_result(result, fallback)
headers = self.get_auth_headers()
async with session.get( def health_check_sync(self) -> dict:
f"{self.api_url}/api/v1/workflow", """Synchronously check n8n API health for UI rendering."""
headers=headers result = self._request_sync("GET", f"{self.api_url}/healthz")
) as resp: fallback = self._request_sync("GET", "workflows")
if resp.status == 200: return self._build_health_result(result, fallback)
return {"status": "ok"}
else:
return {"error": f"Status {resp.status}"}
except Exception as e:
return {"error": str(e)}
async def setup(self) -> dict: async def setup(
self,
webhook_path: str = "telegram",
backend_url: str | None = None,
force_update: bool = False,
use_telegram_trigger: bool | None = None,
telegram_bot_token: str | None = None,
telegram_credential_name: str | None = None,
) -> dict:
"""Setup n8n webhooks automatically.""" """Setup n8n webhooks automatically."""
# First, verify n8n is accessible # First, verify n8n is accessible
health = await self.health_check() health = await self.health_check()
if health.get("error"): if health.get("error"):
return {"status": "error", "message": health.get("error")} return {
"status": "error",
"message": health.get("message") or health.get("error"),
"health": health,
"checks": health.get("checks", []),
"suggestion": health.get("suggestion"),
}
# Try to get existing telegram workflow effective_backend_url = backend_url or f"{settings.backend_public_url}/generate"
existing = await self.get_workflow("Telegram to AI Software Factory") effective_bot_token = telegram_bot_token or settings.telegram_bot_token
if existing and not existing.get("error"): effective_credential_name = telegram_credential_name or settings.n8n_telegram_credential_name
# Enable existing workflow trigger_mode = use_telegram_trigger if use_telegram_trigger is not None else bool(effective_bot_token)
return await self.enable_workflow(existing.get("id", ""))
# Create new workflow if trigger_mode:
result = await self.setup_telegram_workflow("/webhook/telegram") credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name)
return result if credential.get("error"):
return {"status": "error", "message": credential["error"], "details": credential}
workflow = self.build_telegram_trigger_workflow(
backend_url=effective_backend_url,
credential_name=effective_credential_name,
)
else:
workflow = self.build_telegram_workflow(
webhook_path=webhook_path,
backend_url=effective_backend_url,
)
existing = await self.get_workflow(workflow["name"])
if isinstance(existing, dict) and existing.get("error"):
return {"status": "error", "message": existing["error"], "details": existing}
workflow_id = None
if existing and existing.get("id"):
workflow_id = str(existing["id"])
if force_update:
result = await self.update_workflow(workflow_id, workflow)
else:
result = existing
else:
result = await self.create_workflow(workflow)
workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None
if isinstance(result, dict) and result.get("error"):
return {"status": "error", "message": result["error"], "details": result}
workflow_id = workflow_id or str(result.get("id", ""))
enable_result = await self.enable_workflow(workflow_id)
if enable_result.get("error"):
return {"status": "error", "message": enable_result["error"], "workflow": result, "details": enable_result}
return {
"status": "success",
"message": f'Workflow "{workflow["name"]}" is active',
"workflow_id": workflow_id,
"workflow_name": workflow["name"],
"webhook_path": webhook_path.strip().strip("/") or "telegram",
"backend_url": effective_backend_url,
"trigger_mode": "telegram" if trigger_mode else "webhook",
}

View File

@@ -1,14 +1,23 @@
"""Agent orchestrator for software generation.""" """Agent orchestrator for software generation."""
import asyncio from __future__ import annotations
import py_compile
from typing import Optional from typing import Optional
from agents.git_manager import GitManager
from agents.ui_manager import UIManager
from agents.gitea import GiteaAPI
from agents.database_manager import DatabaseManager
from config import settings
from datetime import datetime from datetime import datetime
import os
try:
from ..config import settings
from .database_manager import DatabaseManager
from .git_manager import GitManager
from .gitea import GiteaAPI
from .ui_manager import UIManager
except ImportError:
from config import settings
from agents.database_manager import DatabaseManager
from agents.git_manager import GitManager
from agents.gitea import GiteaAPI
from agents.ui_manager import UIManager
class AgentOrchestrator: class AgentOrchestrator:
@@ -21,7 +30,9 @@ class AgentOrchestrator:
description: str, description: str,
features: list, features: list,
tech_stack: list, tech_stack: list,
db = None db=None,
prompt_text: str | None = None,
prompt_actor: str = "api",
): ):
"""Initialize orchestrator.""" """Initialize orchestrator."""
self.project_id = project_id self.project_id = project_id
@@ -36,16 +47,24 @@ class AgentOrchestrator:
self.logs = [] self.logs = []
self.ui_data = {} self.ui_data = {}
self.db = db self.db = db
self.prompt_text = prompt_text
# Initialize agents self.prompt_actor = prompt_actor
self.git_manager = GitManager(project_id) self.changed_files: list[str] = []
self.ui_manager = UIManager(project_id)
self.gitea_api = GiteaAPI( self.gitea_api = GiteaAPI(
token=settings.GITEA_TOKEN, token=settings.GITEA_TOKEN,
base_url=settings.GITEA_URL, base_url=settings.GITEA_URL,
owner=settings.GITEA_OWNER, owner=settings.GITEA_OWNER,
repo=settings.GITEA_REPO or "" repo=settings.GITEA_REPO or ""
) )
self.project_root = settings.projects_root / project_id
self.prompt_audit = None
self.repo_name = settings.gitea_repo or self.gitea_api.build_project_repo_name(project_id, project_name)
self.repo_owner = settings.gitea_owner
self.repo_url = self._build_repo_url(self.repo_owner, self.repo_name)
# Initialize agents
self.git_manager = GitManager(project_id)
self.ui_manager = UIManager(project_id)
# Initialize database manager if db session provided # Initialize database manager if db session provided
self.db_manager = None self.db_manager = None
@@ -60,52 +79,164 @@ class AgentOrchestrator:
) )
# Re-fetch with new history_id # Re-fetch with new history_id
self.db_manager = DatabaseManager(db) self.db_manager = DatabaseManager(db)
if self.prompt_text:
self.prompt_audit = self.db_manager.log_prompt_submission(
history_id=self.history.id,
project_id=project_id,
prompt_text=self.prompt_text,
features=self.features,
tech_stack=self.tech_stack,
actor_name=self.prompt_actor,
)
self.ui_manager.ui_data["project_root"] = str(self.project_root)
self.ui_manager.ui_data["features"] = list(self.features)
self.ui_manager.ui_data["tech_stack"] = list(self.tech_stack)
self.ui_manager.ui_data["repository"] = {
"owner": self.repo_owner,
"name": self.repo_name,
"url": self.repo_url,
"mode": "project" if settings.use_project_repositories else "shared",
}
def _build_repo_url(self, owner: str | None, repo: str | None) -> str | None:
if not owner or not repo or not settings.gitea_url:
return None
return f"{settings.gitea_url.rstrip('/')}/{owner}/{repo}"
async def _ensure_remote_repository(self) -> None:
if not settings.use_project_repositories:
return
if not self.repo_owner or not settings.gitea_token or not settings.gitea_url:
return
repo_name = self.repo_name
result = await self.gitea_api.create_repo(
repo_name=repo_name,
owner=self.repo_owner,
description=f"AI-generated project for {self.project_name}",
)
if result.get("status") == "exists" and repo_name == self.gitea_api.build_project_repo_name(self.project_id, self.project_name):
repo_name = f"{repo_name}-{self.project_id.split('-')[-1]}"
result = await self.gitea_api.create_repo(
repo_name=repo_name,
owner=self.repo_owner,
description=f"AI-generated project for {self.project_name}",
)
self.repo_name = repo_name
self.ui_manager.ui_data["repository"]["name"] = repo_name
if self.db_manager:
self.db_manager.log_system_event(
component="gitea",
level="ERROR" if result.get("error") else "INFO",
message=(
f"Repository setup failed for {self.repo_owner}/{self.repo_name}: {result.get('error')}"
if result.get("error")
else f"Prepared repository {self.repo_owner}/{self.repo_name}"
),
)
self.ui_manager.ui_data["repository"]["status"] = result.get("status", "error" if result.get("error") else "ready")
if result.get("html_url"):
self.repo_url = result["html_url"]
self.ui_manager.ui_data["repository"]["url"] = self.repo_url
def _append_log(self, message: str) -> None:
timestamped = f"[{datetime.utcnow().isoformat()}] {message}"
self.logs.append(timestamped)
if self.db_manager and self.history:
self.db_manager._log_action(self.history.id, "INFO", message)
def _update_progress(self, progress: int, step: str, message: str) -> None:
self.progress = progress
self.current_step = step
self.message = message
self.ui_manager.update_status(self.status, progress, message)
if self.db_manager and self.history:
self.db_manager.log_progress_update(
history_id=self.history.id,
progress=progress,
step=step,
message=message,
)
def _write_file(self, relative_path: str, content: str) -> None:
target = self.project_root / relative_path
target.parent.mkdir(parents=True, exist_ok=True)
change_type = "UPDATE" if target.exists() else "CREATE"
target.write_text(content, encoding="utf-8")
self.changed_files.append(relative_path)
if self.db_manager and self.history:
self.db_manager.log_code_change(
project_id=self.project_id,
change_type=change_type,
file_path=relative_path,
actor="orchestrator",
actor_type="agent",
details=f"{change_type.title()}d generated artifact {relative_path}",
history_id=self.history.id,
prompt_id=self.prompt_audit.id if self.prompt_audit else None,
diff_summary=f"Wrote {len(content.splitlines())} lines to {relative_path}",
)
def _template_files(self) -> dict[str, str]:
feature_section = "\n".join(f"- {feature}" for feature in self.features) or "- None specified"
tech_section = "\n".join(f"- {tech}" for tech in self.tech_stack) or "- Python"
return {
".gitignore": "__pycache__/\n*.pyc\n.venv/\n.pytest_cache/\n.mypy_cache/\n",
"README.md": (
f"# {self.project_name}\n\n"
f"{self.description}\n\n"
"## Features\n"
f"{feature_section}\n\n"
"## Tech Stack\n"
f"{tech_section}\n"
),
"requirements.txt": "fastapi\nuvicorn\npytest\n",
"main.py": (
"from fastapi import FastAPI\n\n"
"app = FastAPI(title=\"Generated App\")\n\n"
"@app.get('/')\n"
"def read_root():\n"
f" return {{'name': '{self.project_name}', 'status': 'generated', 'features': {self.features!r}}}\n"
),
"tests/test_app.py": (
"from main import read_root\n\n"
"def test_read_root():\n"
f" assert read_root()['name'] == '{self.project_name}'\n"
),
}
async def run(self) -> dict: async def run(self) -> dict:
"""Run the software generation process with full audit logging.""" """Run the software generation process with full audit logging."""
try: try:
# Step 1: Initialize project # Step 1: Initialize project
self.progress = 5 self.status = "running"
self.current_step = "Initializing project" self._update_progress(5, "initializing", "Setting up project structure...")
self.message = "Setting up project structure..." self._append_log("Initializing project.")
self.logs.append(f"[{datetime.utcnow().isoformat()}] Initializing project.")
await self._ensure_remote_repository()
# Step 2: Create project structure (skip git operations) # Step 2: Create project structure (skip git operations)
self.progress = 15 self._update_progress(20, "project-structure", "Creating project files...")
self.current_step = "Creating project structure"
self.message = "Creating project files..."
await self._create_project_structure() await self._create_project_structure()
# Step 3: Generate initial code # Step 3: Generate initial code
self.progress = 25 self._update_progress(55, "code-generation", "Generating project entrypoint and tests...")
self.current_step = "Generating initial code"
self.message = "Generating initial code with Ollama..."
await self._generate_code() await self._generate_code()
# Step 4: Test the code # Step 4: Test the code
self.progress = 50 self._update_progress(80, "validation", "Validating generated code...")
self.current_step = "Testing code"
self.message = "Running tests..."
await self._run_tests() await self._run_tests()
# Step 5: Commit to git (skip in test env)
self.progress = 75
self.current_step = "Committing to git"
self.message = "Skipping git operations in test environment..."
# Step 6: Create PR (skip in test env)
self.progress = 90
self.current_step = "Creating PR"
self.message = "Skipping PR creation in test environment..."
# Step 7: Complete # Step 7: Complete
self.progress = 100 self.status = "completed"
self.current_step = "Completed" self._update_progress(100, "completed", "Software generation complete!")
self.message = "Software generation complete!" self._append_log("Software generation complete!")
self.logs.append(f"[{datetime.utcnow().isoformat()}] Software generation complete!") self.ui_manager.ui_data["changed_files"] = list(dict.fromkeys(self.changed_files))
# Log completion to database if available # Log completion to database if available
if self.db_manager and self.history: if self.db_manager and self.history:
self.db_manager.save_ui_snapshot(self.history.id, self.ui_manager.get_ui_data())
self.db_manager.log_project_complete( self.db_manager.log_project_complete(
history_id=self.history.id, history_id=self.history.id,
message="Software generation complete!" message="Software generation complete!"
@@ -118,13 +249,16 @@ class AgentOrchestrator:
"current_step": self.current_step, "current_step": self.current_step,
"logs": self.logs, "logs": self.logs,
"ui_data": self.ui_manager.ui_data, "ui_data": self.ui_manager.ui_data,
"history_id": self.history.id if self.history else None "history_id": self.history.id if self.history else None,
"project_root": str(self.project_root),
"changed_files": list(dict.fromkeys(self.changed_files)),
"repository": self.ui_manager.ui_data.get("repository"),
} }
except Exception as e: except Exception as e:
self.status = "error" self.status = "error"
self.message = f"Error: {str(e)}" self.message = f"Error: {str(e)}"
self.logs.append(f"[{datetime.utcnow().isoformat()}] Error: {str(e)}") self._append_log(f"Error: {str(e)}")
# Log error to database if available # Log error to database if available
if self.db_manager and self.history: if self.db_manager and self.history:
@@ -141,64 +275,33 @@ class AgentOrchestrator:
"logs": self.logs, "logs": self.logs,
"error": str(e), "error": str(e),
"ui_data": self.ui_manager.ui_data, "ui_data": self.ui_manager.ui_data,
"history_id": self.history.id if self.history else None "history_id": self.history.id if self.history else None,
"project_root": str(self.project_root),
"changed_files": list(dict.fromkeys(self.changed_files)),
"repository": self.ui_manager.ui_data.get("repository"),
} }
async def _create_project_structure(self) -> None: async def _create_project_structure(self) -> None:
"""Create initial project structure.""" """Create initial project structure."""
project_dir = self.project_id self.project_root.mkdir(parents=True, exist_ok=True)
for relative_path, content in self._template_files().items():
# Create .gitignore if relative_path.startswith("main.py") or relative_path.startswith("tests/"):
gitignore_path = f"{project_dir}/.gitignore" continue
try: self._write_file(relative_path, content)
os.makedirs(project_dir, exist_ok=True) self._append_log(f"Project structure created under {self.project_root}.")
with open(gitignore_path, "w") as f:
f.write("# Python\n__pycache__/\n*.pyc\n*.pyo\n*.pyd\n.Python\n*.env\n.venv/\nnode_modules/\n.env\nbuild/\ndist/\n.pytest_cache/\n.mypy_cache/\n.coverage\nhtmlcov/\n.idea/\n.vscode/\n*.swp\n*.swo\n*~\n.DS_Store\n.git\n")
except Exception as e:
self.logs.append(f"[{datetime.utcnow().isoformat()}] Failed to create .gitignore: {str(e)}")
# Create README.md
readme_path = f"{project_dir}/README.md"
try:
with open(readme_path, "w") as f:
f.write(f"# {self.project_name}\n\n{self.description}\n\n## Features\n")
for feature in self.features:
f.write(f"- {feature}\n")
f.write(f"\n## Tech Stack\n")
for tech in self.tech_stack:
f.write(f"- {tech}\n")
except Exception as e:
self.logs.append(f"[{datetime.utcnow().isoformat()}] Failed to create README.md: {str(e)}")
async def _generate_code(self) -> None: async def _generate_code(self) -> None:
"""Generate code using Ollama.""" """Generate code using Ollama."""
# This would call Ollama API to generate code for relative_path, content in self._template_files().items():
# For now, create a placeholder file if relative_path in {"main.py", "tests/test_app.py"}:
try: self._write_file(relative_path, content)
main_py_path = f"{self.project_id}/main.py" self._append_log("Application entrypoint and smoke test generated.")
os.makedirs(self.project_id, exist_ok=True)
with open(main_py_path, "w") as f:
f.write("# Generated by AI Software Factory\n")
f.write("print('Hello, World!')\n")
except Exception as e:
self.logs.append(f"[{datetime.utcnow().isoformat()}] Failed to create main.py: {str(e)}")
# Log code change to audit trail
if self.db_manager and self.history:
self.db_manager.log_code_change(
project_id=self.project_id,
change_type="CREATE",
file_path="main.py",
actor="agent",
actor_type="agent",
details="Generated main.py file"
)
async def _run_tests(self) -> None: async def _run_tests(self) -> None:
"""Run tests for the generated code.""" """Run tests for the generated code."""
# This would run pytest or other test framework py_compile.compile(str(self.project_root / "main.py"), doraise=True)
# For now, simulate test success py_compile.compile(str(self.project_root / "tests/test_app.py"), doraise=True)
pass self._append_log("Generated Python files compiled successfully.")
async def _commit_to_git(self) -> None: async def _commit_to_git(self) -> None:
"""Commit changes to git.""" """Commit changes to git."""

View File

@@ -1,8 +1,6 @@
"""Telegram bot integration for n8n webhook.""" """Telegram bot integration for n8n webhook."""
import asyncio import asyncio
import json
import re
from typing import Optional from typing import Optional
@@ -13,6 +11,62 @@ class TelegramHandler:
self.webhook_url = webhook_url self.webhook_url = webhook_url
self.api_url = "https://api.telegram.org/bot" self.api_url = "https://api.telegram.org/bot"
def build_prompt_guide_message(self, backend_url: str | None = None) -> str:
"""Build a Telegram message explaining the expected prompt format."""
lines = [
"AI Software Factory is listening in this chat.",
"",
"Send prompts in this format:",
"Name: Inventory Portal",
"Description: Internal tool for stock management and purchase tracking",
"Features:",
"- role-based login",
"- inventory dashboard",
"- purchase order workflow",
"Tech Stack:",
"- fastapi",
"- postgresql",
"- nicegui",
]
if backend_url:
lines.extend(["", f"Backend target: {backend_url}"])
return "\n".join(lines)
async def send_message(self, bot_token: str, chat_id: str | int, text: str) -> dict:
"""Send a direct Telegram message using the configured bot."""
if not bot_token:
return {"status": "error", "message": "Telegram bot token is not configured"}
if chat_id in (None, ""):
return {"status": "error", "message": "Telegram chat id is not configured"}
api_endpoint = f"{self.api_url}{bot_token}/sendMessage"
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
api_endpoint,
json={
"chat_id": str(chat_id),
"text": text,
},
) as resp:
payload = await resp.json()
if 200 <= resp.status < 300 and payload.get("ok"):
return {
"status": "success",
"message": "Telegram prompt guide sent successfully",
"payload": payload,
}
description = payload.get("description") or payload.get("message") or str(payload)
return {
"status": "error",
"message": f"Telegram API returned {resp.status}: {description}",
"payload": payload,
}
except Exception as exc:
return {"status": "error", "message": str(exc)}
async def handle_message(self, message_data: dict) -> dict: async def handle_message(self, message_data: dict) -> dict:
"""Handle incoming Telegram message.""" """Handle incoming Telegram message."""
text = message_data.get("text", "") text = message_data.get("text", "")

View File

@@ -1,5 +1,6 @@
"""UI manager for web dashboard with audit trail display.""" """UI manager for web dashboard with audit trail display."""
import html
import json import json
from typing import Optional, List from typing import Optional, List
@@ -50,14 +51,7 @@ class UIManager:
"""Escape HTML special characters for safe display.""" """Escape HTML special characters for safe display."""
if text is None: if text is None:
return "" return ""
safe_chars = { return html.escape(str(text), quote=True)
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '&#x27;'
}
return ''.join(safe_chars.get(c, c) for c in str(text))
def render_dashboard(self, audit_trail: Optional[List[dict]] = None, def render_dashboard(self, audit_trail: Optional[List[dict]] = None,
actions: Optional[List[dict]] = None, actions: Optional[List[dict]] = None,

View File

@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
sqlalchemy.url = sqlite:////tmp/ai_software_factory_test.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View File

@@ -0,0 +1,50 @@
"""Alembic environment for AI Software Factory."""
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
try:
from ai_software_factory.models import Base
except ImportError:
from models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in offline mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in online mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,17 @@
"""${message}"""
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
from alembic import op
import sqlalchemy as sa
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,164 @@
"""initial schema
Revision ID: 20260410_01
Revises:
Create Date: 2026-04-10 00:00:00
"""
from alembic import op
import sqlalchemy as sa
revision = "20260410_01"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"agent_actions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("agent_name", sa.String(length=100), nullable=False),
sa.Column("action_type", sa.String(length=100), nullable=False),
sa.Column("success", sa.Boolean(), nullable=True),
sa.Column("message", sa.String(length=500), nullable=True),
sa.Column("timestamp", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"audit_trail",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("component", sa.String(length=50), nullable=True),
sa.Column("log_level", sa.String(length=50), nullable=True),
sa.Column("message", sa.String(length=500), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("project_id", sa.String(length=255), nullable=True),
sa.Column("action", sa.String(length=100), nullable=True),
sa.Column("actor", sa.String(length=100), nullable=True),
sa.Column("action_type", sa.String(length=50), nullable=True),
sa.Column("details", sa.Text(), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"project_history",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("project_id", sa.String(length=255), nullable=False),
sa.Column("project_name", sa.String(length=255), nullable=True),
sa.Column("features", sa.Text(), nullable=True),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=50), nullable=True),
sa.Column("progress", sa.Integer(), nullable=True),
sa.Column("message", sa.String(length=500), nullable=True),
sa.Column("current_step", sa.String(length=255), nullable=True),
sa.Column("total_steps", sa.Integer(), nullable=True),
sa.Column("current_step_description", sa.String(length=1024), nullable=True),
sa.Column("current_step_details", sa.Text(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"system_logs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("component", sa.String(length=50), nullable=False),
sa.Column("log_level", sa.String(length=50), nullable=True),
sa.Column("log_message", sa.String(length=500), nullable=False),
sa.Column("user_agent", sa.String(length=255), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"project_logs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("history_id", sa.Integer(), nullable=False),
sa.Column("log_level", sa.String(length=50), nullable=True),
sa.Column("log_message", sa.String(length=500), nullable=False),
sa.Column("timestamp", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"prompt_code_links",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("history_id", sa.Integer(), nullable=False),
sa.Column("project_id", sa.String(length=255), nullable=False),
sa.Column("prompt_audit_id", sa.Integer(), nullable=False),
sa.Column("code_change_audit_id", sa.Integer(), nullable=False),
sa.Column("file_path", sa.String(length=500), nullable=True),
sa.Column("change_type", sa.String(length=50), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"pull_request_data",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("history_id", sa.Integer(), nullable=False),
sa.Column("pr_number", sa.Integer(), nullable=False),
sa.Column("pr_title", sa.String(length=500), nullable=False),
sa.Column("pr_body", sa.Text(), nullable=True),
sa.Column("pr_state", sa.String(length=50), nullable=False),
sa.Column("pr_url", sa.String(length=500), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"pull_requests",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("history_id", sa.Integer(), nullable=False),
sa.Column("pr_number", sa.Integer(), nullable=False),
sa.Column("pr_title", sa.String(length=500), nullable=False),
sa.Column("pr_body", sa.Text(), nullable=True),
sa.Column("base", sa.String(length=255), nullable=False),
sa.Column("user", sa.String(length=255), nullable=False),
sa.Column("pr_url", sa.String(length=500), nullable=False),
sa.Column("merged", sa.Boolean(), nullable=True),
sa.Column("merged_at", sa.DateTime(), nullable=True),
sa.Column("pr_state", sa.String(length=50), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"ui_snapshots",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("history_id", sa.Integer(), nullable=False),
sa.Column("snapshot_data", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"user_actions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("history_id", sa.Integer(), nullable=True),
sa.Column("user_id", sa.String(length=100), nullable=True),
sa.Column("action_type", sa.String(length=100), nullable=True),
sa.Column("actor_type", sa.String(length=50), nullable=True),
sa.Column("actor_name", sa.String(length=100), nullable=True),
sa.Column("action_description", sa.String(length=500), nullable=True),
sa.Column("action_data", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("user_actions")
op.drop_table("ui_snapshots")
op.drop_table("pull_requests")
op.drop_table("pull_request_data")
op.drop_table("prompt_code_links")
op.drop_table("project_logs")
op.drop_table("system_logs")
op.drop_table("project_history")
op.drop_table("audit_trail")
op.drop_table("agent_actions")

View File

@@ -4,12 +4,18 @@ import os
from typing import Optional from typing import Optional
from pathlib import Path from pathlib import Path
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application settings loaded from environment variables.""" """Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Server settings # Server settings
HOST: str = "0.0.0.0" HOST: str = "0.0.0.0"
PORT: int = 8000 PORT: int = 8000
@@ -23,14 +29,20 @@ class Settings(BaseSettings):
GITEA_URL: str = "https://gitea.yourserver.com" GITEA_URL: str = "https://gitea.yourserver.com"
GITEA_TOKEN: str = "" GITEA_TOKEN: str = ""
GITEA_OWNER: str = "ai-software-factory" GITEA_OWNER: str = "ai-software-factory"
GITEA_REPO: str = "ai-software-factory" GITEA_REPO: str = ""
# n8n settings # n8n settings
N8N_WEBHOOK_URL: str = "" N8N_WEBHOOK_URL: str = ""
N8N_API_URL: str = "" N8N_API_URL: str = ""
N8N_API_KEY: str = ""
N8N_TELEGRAM_CREDENTIAL_NAME: str = "AI Software Factory Telegram"
N8N_USER: str = "" N8N_USER: str = ""
N8N_PASSWORD: str = "" N8N_PASSWORD: str = ""
# Runtime integration settings
BACKEND_PUBLIC_URL: str = "http://localhost:8000"
PROJECTS_ROOT: str = ""
# Telegram settings # Telegram settings
TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = "" TELEGRAM_CHAT_ID: str = ""
@@ -54,6 +66,32 @@ class Settings(BaseSettings):
DB_POOL_RECYCLE: int = 3600 DB_POOL_RECYCLE: int = 3600
DB_POOL_TIMEOUT: int = 30 DB_POOL_TIMEOUT: int = 30
@property
def postgres_url(self) -> str:
"""Get PostgreSQL URL with trimmed whitespace."""
return (self.POSTGRES_URL or "").strip()
@property
def postgres_env_configured(self) -> bool:
"""Whether PostgreSQL was explicitly configured via environment variables."""
if self.postgres_url:
return True
postgres_env_keys = (
"POSTGRES_HOST",
"POSTGRES_PORT",
"POSTGRES_USER",
"POSTGRES_PASSWORD",
"POSTGRES_DB",
)
return any(bool(os.environ.get(key, "").strip()) for key in postgres_env_keys)
@property
def use_sqlite(self) -> bool:
"""Whether SQLite should be used as the active database backend."""
if not self.USE_SQLITE:
return False
return not self.postgres_env_configured
@property @property
def pool(self) -> dict: def pool(self) -> dict:
"""Get database pool configuration.""" """Get database pool configuration."""
@@ -67,8 +105,10 @@ class Settings(BaseSettings):
@property @property
def database_url(self) -> str: def database_url(self) -> str:
"""Get database connection URL.""" """Get database connection URL."""
if self.USE_SQLITE: if self.use_sqlite:
return f"sqlite:///{self.SQLITE_DB_PATH}" return f"sqlite:///{self.SQLITE_DB_PATH}"
if self.postgres_url:
return self.postgres_url
return ( return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
@@ -77,8 +117,10 @@ class Settings(BaseSettings):
@property @property
def test_database_url(self) -> str: def test_database_url(self) -> str:
"""Get test database connection URL.""" """Get test database connection URL."""
if self.USE_SQLITE: if self.use_sqlite:
return f"sqlite:///{self.SQLITE_DB_PATH}" return f"sqlite:///{self.SQLITE_DB_PATH}"
if self.postgres_url:
return self.postgres_url
return ( return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_TEST_DB}" f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_TEST_DB}"
@@ -99,11 +141,41 @@ class Settings(BaseSettings):
"""Get Gitea token with trimmed whitespace.""" """Get Gitea token with trimmed whitespace."""
return self.GITEA_TOKEN.strip() return self.GITEA_TOKEN.strip()
@property
def gitea_owner(self) -> str:
"""Get Gitea owner/organization with trimmed whitespace."""
return self.GITEA_OWNER.strip()
@property
def gitea_repo(self) -> str:
"""Get the optional fixed Gitea repository name with trimmed whitespace."""
return self.GITEA_REPO.strip()
@property
def use_project_repositories(self) -> bool:
"""Whether the service should create one repository per generated project."""
return not bool(self.gitea_repo)
@property @property
def n8n_webhook_url(self) -> str: def n8n_webhook_url(self) -> str:
"""Get n8n webhook URL with trimmed whitespace.""" """Get n8n webhook URL with trimmed whitespace."""
return self.N8N_WEBHOOK_URL.strip() return self.N8N_WEBHOOK_URL.strip()
@property
def n8n_api_url(self) -> str:
"""Get n8n API URL with trimmed whitespace."""
return self.N8N_API_URL.strip()
@property
def n8n_api_key(self) -> str:
"""Get n8n API key with trimmed whitespace."""
return self.N8N_API_KEY.strip()
@property
def n8n_telegram_credential_name(self) -> str:
"""Get the preferred n8n Telegram credential name."""
return self.N8N_TELEGRAM_CREDENTIAL_NAME.strip() or "AI Software Factory Telegram"
@property @property
def telegram_bot_token(self) -> str: def telegram_bot_token(self) -> str:
"""Get Telegram bot token with trimmed whitespace.""" """Get Telegram bot token with trimmed whitespace."""
@@ -114,6 +186,18 @@ class Settings(BaseSettings):
"""Get Telegram chat ID with trimmed whitespace.""" """Get Telegram chat ID with trimmed whitespace."""
return self.TELEGRAM_CHAT_ID.strip() return self.TELEGRAM_CHAT_ID.strip()
@property
def backend_public_url(self) -> str:
"""Get backend public URL with trimmed whitespace."""
return self.BACKEND_PUBLIC_URL.strip().rstrip("/")
@property
def projects_root(self) -> Path:
"""Get the root directory for generated project artifacts."""
if self.PROJECTS_ROOT.strip():
return Path(self.PROJECTS_ROOT).expanduser().resolve()
return Path(__file__).resolve().parent.parent / "test-project"
@property @property
def postgres_host(self) -> str: def postgres_host(self) -> str:
"""Get PostgreSQL host.""" """Get PostgreSQL host."""
@@ -144,11 +228,5 @@ class Settings(BaseSettings):
"""Get test PostgreSQL database name.""" """Get test PostgreSQL database name."""
return self.POSTGRES_TEST_DB.strip() return self.POSTGRES_TEST_DB.strip()
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore"
# Create instance for module-level access # Create instance for module-level access
settings = Settings() settings = Settings()

View File

@@ -1,194 +1,487 @@
"""NiceGUI dashboard for AI Software Factory with real-time database data.""" """NiceGUI dashboard backed by real database state."""
from nicegui import ui from __future__ import annotations
from database import get_db, get_engine, init_db, get_db_sync
from models import ProjectHistory, ProjectLog, AuditTrail, UserAction, SystemLog, AgentAction
from datetime import datetime
import logging
logger = logging.getLogger(__name__) from contextlib import closing
from html import escape
from nicegui import app, ui
try:
from .agents.database_manager import DatabaseManager
from .agents.n8n_setup import N8NSetupAgent
from .agents.telegram import TelegramHandler
from .config import settings
from .database import get_database_runtime_summary, get_db_sync, init_db
except ImportError:
from agents.database_manager import DatabaseManager
from agents.n8n_setup import N8NSetupAgent
from agents.telegram import TelegramHandler
from config import settings
from database import get_database_runtime_summary, get_db_sync, init_db
def _resolve_n8n_api_url() -> str:
"""Resolve the configured n8n API base URL."""
if settings.n8n_api_url:
return settings.n8n_api_url
if settings.n8n_webhook_url:
return settings.n8n_webhook_url.split('/webhook', 1)[0].rstrip('/')
return ''
def _render_repository_block(repository: dict | None) -> None:
"""Render repository details and URL when available."""
if not repository:
ui.label('Repository URL not available yet.').classes('factory-muted')
return
owner = repository.get('owner') or 'unknown-owner'
name = repository.get('name') or 'unknown-repo'
mode = repository.get('mode') or 'project'
status = repository.get('status')
repo_url = repository.get('url')
with ui.column().classes('gap-1'):
with ui.row().classes('items-center gap-2'):
ui.label(f'{owner}/{name}').style('font-weight: 700; color: #2f241d;')
ui.label(mode).classes('factory-chip')
if status:
ui.label(status).classes('factory-chip')
if repo_url:
ui.link(repo_url, repo_url, new_tab=True).classes('factory-code')
else:
ui.label('Repository URL not available yet.').classes('factory-muted')
def _load_dashboard_snapshot() -> dict:
"""Load dashboard data from the database."""
db = get_db_sync()
if db is None:
return {'error': 'Database session could not be created'}
with closing(db):
manager = DatabaseManager(db)
try:
return manager.get_dashboard_snapshot(limit=8)
except Exception as exc:
return {'error': f'Database error: {exc}'}
def _load_n8n_health_snapshot() -> dict:
"""Load an n8n health snapshot for UI rendering."""
api_url = _resolve_n8n_api_url()
if not api_url:
return {
'status': 'error',
'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured.',
'api_url': 'Not configured',
'auth_configured': bool(settings.n8n_api_key),
'checks': [],
'suggestion': 'Set N8N_API_URL to the base n8n address before provisioning workflows.',
}
try:
return N8NSetupAgent(api_url=api_url, webhook_token=settings.n8n_api_key).health_check_sync()
except Exception as exc:
return {
'status': 'error',
'message': f'Unable to run n8n health checks: {exc}',
'api_url': api_url,
'auth_configured': bool(settings.n8n_api_key),
'checks': [],
}
def _add_dashboard_styles() -> None:
"""Register shared dashboard styles."""
ui.add_head_html(
"""
<style>
body { background: radial-gradient(circle at top, #f4efe7 0%, #e9e1d4 38%, #d7cec1 100%); }
.factory-shell { max-width: 1240px; margin: 0 auto; }
.factory-panel { background: rgba(255,255,255,0.78); backdrop-filter: blur(18px); border: 1px solid rgba(73,54,40,0.10); border-radius: 24px; box-shadow: 0 24px 60px rgba(84,55,24,0.14); }
.factory-kpi { background: linear-gradient(145deg, rgba(63,94,78,0.94), rgba(29,52,45,0.92)); color: #f8f3eb; border-radius: 18px; padding: 18px; min-height: 128px; }
.factory-muted { color: #745e4c; }
.factory-code { font-family: 'IBM Plex Mono', 'Fira Code', monospace; background: rgba(32,26,20,0.92); color: #f4efe7; border-radius: 14px; padding: 12px; white-space: pre-wrap; }
.factory-chip { background: rgba(173, 129, 82, 0.14); color: #6b4b2e; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
</style>
"""
)
def _render_n8n_error_dialog(result: dict) -> None:
"""Render a detailed n8n failure dialog."""
health = result.get('health', {}) if isinstance(result.get('health'), dict) else {}
checks = result.get('checks') or health.get('checks') or []
details = result.get('details') if isinstance(result.get('details'), dict) else {}
with ui.dialog() as dialog, ui.card().classes('factory-panel q-pa-lg').style('max-width: 840px; width: min(92vw, 840px);'):
ui.label('n8n provisioning failed').style('font-size: 1.35rem; font-weight: 800; color: #5c2d1f;')
ui.label(result.get('message', 'No error message returned.')).classes('factory-muted')
if result.get('suggestion') or health.get('suggestion'):
ui.label(result.get('suggestion') or health.get('suggestion')).classes('factory-chip q-mt-sm')
if checks:
ui.label('Health checks').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;')
for check in checks:
status = 'OK' if check.get('ok') else 'FAIL'
message = check.get('message') or 'No detail available'
ui.markdown(
f"- **{escape(check.get('name', 'check'))}** · {status} · {escape(str(check.get('status_code') or 'n/a'))} · {escape(check.get('url') or 'unknown url')}"
)
ui.label(message).classes('factory-muted')
if details:
ui.label('API response').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;')
ui.label(str(details)).classes('factory-code')
with ui.row().classes('justify-end w-full q-mt-md'):
ui.button('Close', on_click=dialog.close).props('unelevated color=dark')
dialog.open()
def _render_health_panels() -> None:
"""Render application and n8n health panels."""
runtime = get_database_runtime_summary()
n8n_health = _load_n8n_health_snapshot()
with ui.grid(columns=2).classes('w-full gap-4'):
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('Application Health').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
rows = [
('Status', 'healthy'),
('Database Backend', runtime['backend']),
('Database Target', runtime['target']),
('Database Name', runtime['database']),
('Backend URL', settings.backend_public_url),
('Projects Root', str(settings.projects_root)),
]
for label, value in rows:
with ui.row().classes('justify-between w-full q-mt-sm'):
ui.label(label).classes('factory-muted')
ui.label(str(value)).style('font-weight: 600; color: #3a281a;')
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('n8n Connection Status').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
status_label = n8n_health.get('status', 'unknown').upper()
ui.label(status_label).classes('factory-chip')
ui.label(n8n_health.get('message', 'No n8n status available.')).classes('factory-muted q-mt-sm')
rows = [
('API URL', n8n_health.get('api_url') or 'Not configured'),
('Auth Configured', 'yes' if n8n_health.get('auth_configured') else 'no'),
('Checked Via', n8n_health.get('checked_via') or 'none'),
('Telegram Chat ID', settings.telegram_chat_id or 'Not configured'),
]
if n8n_health.get('workflow_count') is not None:
rows.append(('Workflow Count', str(n8n_health['workflow_count'])))
for label, value in rows:
with ui.row().classes('justify-between w-full q-mt-sm'):
ui.label(label).classes('factory-muted')
ui.label(str(value)).style('font-weight: 600; color: #3a281a;')
if n8n_health.get('suggestion'):
ui.label(n8n_health['suggestion']).classes('factory-chip q-mt-md')
checks = n8n_health.get('checks', [])
if checks:
ui.label('Checks').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;')
for check in checks:
status = 'OK' if check.get('ok') else 'FAIL'
ui.markdown(
f"- **{escape(check.get('name', 'check'))}** · {status} · {escape(str(check.get('status_code') or 'n/a'))} · {escape(check.get('url') or 'unknown url')}"
)
if check.get('message'):
ui.label(check['message']).classes('factory-muted')
def create_health_page() -> None:
"""Create a dedicated health page for runtime diagnostics."""
_add_dashboard_styles()
with ui.column().classes('factory-shell w-full gap-4 q-pa-lg'):
with ui.card().classes('factory-panel w-full q-pa-lg'):
with ui.row().classes('items-center justify-between w-full'):
with ui.column().classes('gap-1'):
ui.label('Factory Health').style('font-size: 2rem; font-weight: 800; color: #302116;')
ui.label('Current application and n8n connectivity diagnostics.').classes('factory-muted')
with ui.row().classes('items-center gap-2'):
ui.link('Back to Dashboard', '/')
ui.link('Refresh Health', '/health-ui')
_render_health_panels()
def create_dashboard(): def create_dashboard():
"""Create and configure the NiceGUI dashboard with real-time data from database.""" """Create the main NiceGUI dashboard."""
_add_dashboard_styles()
active_tab_key = 'dashboard.active_tab'
# Get database session directly for NiceGUI (not a FastAPI dependency) def _selected_tab_name() -> str:
db_session = get_db_sync() """Return the persisted active dashboard tab."""
return app.storage.user.get(active_tab_key, 'overview')
if db_session is None: def _store_selected_tab(event) -> None:
ui.label('Database session could not be created. Check configuration and restart the server.') """Persist the active dashboard tab across refreshes."""
app.storage.user[active_tab_key] = event.value or 'overview'
async def setup_n8n_workflow_action() -> None:
api_url = _resolve_n8n_api_url()
if not api_url:
ui.notify('Configure N8N_API_URL or N8N_WEBHOOK_URL first', color='negative')
return return
try: agent = N8NSetupAgent(api_url=api_url, webhook_token=settings.n8n_api_key)
# Fetch current project result = await agent.setup(
current_project = db_session.query(ProjectHistory).order_by(ProjectHistory.created_at.desc()).first() webhook_path='telegram',
backend_url=f'{settings.backend_public_url}/generate',
force_update=True,
)
# Fetch recent audit trail entries db = get_db_sync()
recent_audits = db_session.query(AuditTrail).order_by(AuditTrail.created_at.desc()).limit(10).all() if db is not None:
with closing(db):
DatabaseManager(db).log_system_event(
component='n8n',
level='INFO' if result.get('status') == 'success' else 'ERROR',
message=result.get('message', str(result)),
)
# Fetch recent project history entries if result.get('status') == 'error':
recent_projects = db_session.query(ProjectHistory).order_by(ProjectHistory.created_at.desc()).limit(5).all() _render_n8n_error_dialog(result)
ui.notify(result.get('message', 'n8n setup finished'), color='positive' if result.get('status') == 'success' else 'negative')
dashboard_body.refresh()
# Fetch recent system logs async def send_telegram_prompt_guide_action() -> None:
recent_logs = db_session.query(SystemLog).order_by(SystemLog.created_at.desc()).limit(5).all() if not settings.telegram_bot_token:
ui.notify('Configure TELEGRAM_BOT_TOKEN first', color='negative')
return
if not settings.telegram_chat_id:
ui.notify('Configure TELEGRAM_CHAT_ID to message the prompt channel', color='negative')
return
# Create main card handler = TelegramHandler(settings.n8n_webhook_url or _resolve_n8n_api_url())
with ui.card().col().classes('w-full max-w-6xl mx-auto').props('elevated').style('max-width: 1200px; margin: 0 auto;') as main_card: message = handler.build_prompt_guide_message(settings.backend_public_url)
# Header section result = await handler.send_message(
with ui.row().classes('items-center gap-4 mb-6').style('padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; color: white;') as header_row: bot_token=settings.telegram_bot_token,
title = ui.label('AI Software Factory').style('font-size: 28px; font-weight: bold; margin: 0;') chat_id=settings.telegram_chat_id,
subtitle = ui.label('Real-time Dashboard & Audit Trail Display').style('font-size: 14px; opacity: 0.9; margin-top: 5px;') text=message,
)
# Stats grid db = get_db_sync()
with ui.grid(columns=4, cols=4).props('gutter=1').style('margin-top: 15px;') as stats_grid: if db is not None:
# Current Project with closing(db):
with ui.column().classes('text-center').style('background: rgba(255, 255, 255, 0.1); padding: 15px; border-radius: 8px;') as card1: DatabaseManager(db).log_system_event(
ui.label('Current Project').style('font-size: 12px; text-transform: uppercase; opacity: 0.8;') component='telegram',
project_name = current_project.project_name if current_project else 'No active project' level='INFO' if result.get('status') == 'success' else 'ERROR',
ui.label(project_name).style('font-size: 20px; font-weight: bold; margin-top: 5px;') message=result.get('message', str(result)),
)
# Active Projects count ui.notify(result.get('message', 'Telegram message sent'), color='positive' if result.get('status') == 'success' else 'negative')
with ui.column().classes('text-center').style('background: rgba(255, 255, 255, 0.1); padding: 15px; border-radius: 8px;') as card2: dashboard_body.refresh()
ui.label('Active Projects').style('font-size: 12px; text-transform: uppercase; opacity: 0.8;')
active_count = len(recent_projects)
ui.label(str(active_count)).style('font-size: 20px; font-weight: bold; margin-top: 5px; color: #00ff88;')
# Code Generated (calculated from history entries) def init_db_action() -> None:
with ui.column().classes('text-center').style('background: rgba(255, 255, 255, 0.1); padding: 15px; border-radius: 8px;') as card3: result = init_db()
ui.label('Code Generated').style('font-size: 12px; text-transform: uppercase; opacity: 0.8;') ui.notify(result.get('message', 'Database initialized'), color='positive' if result.get('status') == 'success' else 'negative')
# Count .py files from history dashboard_body.refresh()
code_count = sum(1 for p in recent_projects if 'Generated' in p.message)
code_size = sum(p.progress for p in recent_projects) if recent_projects else 0
ui.label(f'{code_count} files ({code_size}% total)').style('font-size: 20px; font-weight: bold; margin-top: 5px; color: #ffd93d;')
# Status @ui.refreshable
with ui.column().classes('text-center').style('background: rgba(255, 255, 255, 0.1); padding: 15px; border-radius: 8px;') as card4: def dashboard_body() -> None:
ui.label('Status').style('font-size: 12px; text-transform: uppercase; opacity: 0.8;') snapshot = _load_dashboard_snapshot()
status = current_project.status if current_project else 'No active project' if snapshot.get('error'):
ui.label(status).style('font-size: 20px; font-weight: bold; margin-top: 5px; color: #00d4ff;') with ui.card().classes('factory-panel w-full max-w-4xl mx-auto q-pa-xl'):
ui.label('Dashboard unavailable').style('font-size: 1.5rem; font-weight: 700; color: #5c2d1f;')
ui.label(snapshot['error']).classes('factory-muted')
ui.button('Initialize Database', on_click=init_db_action).props('unelevated')
return
# Separator summary = snapshot['summary']
ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);') projects = snapshot['projects']
correlations = snapshot['correlations']
# Current Status Panel system_logs = snapshot['system_logs']
with ui.column().style('background: rgba(255, 255, 255, 0.08); padding: 20px; border-radius: 12px; margin-bottom: 15px;') as status_panel: project_repository_map = {
ui.label('📊 Current Status').style('font-size: 18px; font-weight: bold; color: #4fc3f7; margin-bottom: 10px;') project_bundle['project']['project_id']: {
'project_name': project_bundle['project']['project_name'],
with ui.row().classes('items-center gap-4').style('margin-top: 10px;') as progress_row: 'repository': project_bundle.get('repository') or project_bundle['project'].get('repository'),
if current_project:
ui.label('Progress:').style('color: #bdbdbd;')
ui.label(str(current_project.progress) + '%').style('color: #4fc3f7; font-weight: bold;')
ui.label('').style('color: #bdbdbd;')
else:
ui.label('No active project').style('color: #bdbdbd;')
if current_project:
ui.label(current_project.message).style('color: #888; margin-top: 8px; font-size: 13px;')
ui.label('Last update: ' + current_project.updated_at.strftime('%H:%M:%S')).style('color: #bdbdbd; font-size: 12px; margin-top: 5px;')
else:
ui.label('Waiting for a new project...').style('color: #888; margin-top: 8px; font-size: 13px;')
# Separator
ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);')
# Active Projects Section
with ui.column().style('background: rgba(255, 255, 255, 0.08); padding: 20px; border-radius: 12px; margin-bottom: 15px;') as projects_section:
ui.label('📁 Active Projects').style('font-size: 18px; font-weight: bold; color: #81c784; margin-bottom: 10px;')
with ui.row().style('gap: 10px;') as projects_list:
for i, project in enumerate(recent_projects[:3], 1):
with ui.card().props('elevated rounded').style('background: rgba(0, 255, 136, 0.15); border: 1px solid rgba(0, 255, 136, 0.4);') as project_item:
ui.label(str(i + len(recent_projects)) + '. ' + project.project_name).style('font-size: 16px; font-weight: bold; color: white;')
ui.label('• Agent: Orchestrator').style('font-size: 12px; color: #bdbdbd;')
ui.label('• Status: ' + project.status).style('font-size: 11px; color: #81c784; margin-top: 3px;')
if not recent_projects:
ui.label('No active projects yet.').style('font-size: 14px; color: #bdbdbd;')
# Separator
ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);')
# Audit Trail Section
with ui.column().style('background: rgba(255, 255, 255, 0.08); padding: 20px; border-radius: 12px; margin-bottom: 15px;') as audit_section:
ui.label('📜 Audit Trail').style('font-size: 18px; font-weight: bold; color: #ffe082; margin-bottom: 10px;')
with ui.data_table(
headers=['Timestamp', 'Component', 'Action', 'Level'],
columns=[
{'name': 'Timestamp', 'field': 'created_at', 'width': '180px'},
{'name': 'Component', 'field': 'component', 'width': '150px'},
{'name': 'Action', 'field': 'action', 'width': '250px'},
{'name': 'Level', 'field': 'log_level', 'width': '100px'},
],
row_height=36,
) as table:
# Populate table with audit trail data
audit_rows = []
for audit in recent_audits:
status = 'Success' if audit.log_level.upper() in ['INFO', 'SUCCESS'] else audit.log_level.upper()
audit_rows.append({
'created_at': audit.created_at.strftime('%Y-%m-%d %H:%M:%S'),
'component': audit.component or 'System',
'action': audit.action or audit.message[:50],
'log_level': status[:15],
})
table.rows = audit_rows
if not recent_audits:
ui.label('No audit trail entries yet.').style('font-size: 12px; color: #bdbdbd;')
# Separator
ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);')
# System Logs Section
with ui.column().style('background: rgba(255, 255, 255, 0.08); padding: 20px; border-radius: 12px;') as logs_section:
ui.label('⚙️ System Logs').style('font-size: 18px; font-weight: bold; color: #ff8a80; margin-bottom: 10px;')
with ui.data_table(
headers=['Component', 'Level', 'Message'],
columns=[
{'name': 'Component', 'field': 'component', 'width': '150px'},
{'name': 'Level', 'field': 'log_level', 'width': '100px'},
{'name': 'Message', 'field': 'log_message', 'width': '450px'},
],
row_height=32,
) as logs_table:
logs_table.rows = [
{
'component': log.component,
'log_level': log.log_level,
'log_message': log.log_message[:50] + '...' if len(log.log_message) > 50 else log.log_message
} }
for log in recent_logs for project_bundle in projects
if project_bundle.get('project')
}
with ui.column().classes('factory-shell w-full gap-4 q-pa-lg'):
with ui.card().classes('factory-panel w-full q-pa-lg'):
with ui.row().classes('items-center justify-between w-full'):
with ui.column().classes('gap-1'):
ui.label('AI Software Factory').style('font-size: 2.3rem; font-weight: 800; color: #302116;')
ui.label('Operational dashboard with project audit, prompt traces, and n8n controls.').classes('factory-muted')
with ui.row().classes('items-center gap-2'):
ui.button('Refresh', on_click=dashboard_body.refresh).props('outline')
ui.button('Initialize DB', on_click=init_db_action).props('unelevated color=dark')
ui.button('Provision n8n Workflow', on_click=setup_n8n_workflow_action).props('unelevated color=accent')
ui.button('Message Prompt Channel', on_click=send_telegram_prompt_guide_action).props('outline color=secondary')
with ui.grid(columns=4).classes('w-full gap-4'):
metrics = [
('Projects', summary['total_projects'], 'Tracked generation requests'),
('Completed', summary['completed_projects'], 'Finished project runs'),
('Prompts', summary['prompt_events'], 'Recorded originating prompts'),
('Code Changes', summary['code_changes'], 'Audited generated file writes'),
] ]
for title, value, subtitle in metrics:
with ui.card().classes('factory-kpi'):
ui.label(title).style('font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.8;')
ui.label(str(value)).style('font-size: 2.1rem; font-weight: 800; margin-top: 6px;')
ui.label(subtitle).style('font-size: 0.9rem; opacity: 0.78; margin-top: 8px;')
if not recent_logs: selected_tab = _selected_tab_name()
ui.label('No system logs yet.').style('font-size: 12px; color: #bdbdbd;') 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('Prompt Trace').props('name=trace')
ui.tab('System').props('name=system')
ui.tab('Health').props('name=health')
# Separator with ui.tab_panels(tabs, value=selected_tab).classes('w-full'):
ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);') with ui.tab_panel('overview'):
with ui.grid(columns=2).classes('w-full gap-4'):
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('Project Pipeline').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
if projects:
for project_bundle in projects[:4]:
project = project_bundle['project']
with ui.column().classes('gap-1 q-mt-md'):
with ui.row().classes('justify-between items-center'):
ui.label(project['project_name']).style('font-weight: 700; color: #2f241d;')
ui.label(project['status']).classes('factory-chip')
ui.linear_progress(value=(project['progress'] or 0) / 100, show_value=False).classes('w-full')
ui.label(project['message'] or 'No status message').classes('factory-muted')
else:
ui.label('No projects in the database yet.').classes('factory-muted')
# API Endpoints Section with ui.card().classes('factory-panel q-pa-lg'):
with ui.expansion_group('🔗 Available API Endpoints', default_open=True).props('dense') as api_section: ui.label('n8n and Runtime').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
with ui.column().style('font-size: 12px; color: #78909c;') as endpoint_list: rows = [
('Backend URL', settings.backend_public_url),
('Project Root', str(settings.projects_root)),
('n8n API URL', _resolve_n8n_api_url() or 'Not configured'),
('Running Projects', str(summary['running_projects'])),
('Errored Projects', str(summary['error_projects'])),
]
for label, value in rows:
with ui.row().classes('justify-between w-full q-mt-sm'):
ui.label(label).classes('factory-muted')
ui.label(value).style('font-weight: 600; color: #3a281a;')
with ui.tab_panel('projects'):
if not projects:
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('No project data available yet.').classes('factory-muted')
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.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:
prompt = prompts[0]
ui.markdown(f"**Requested features:** {', '.join(prompt['features']) or 'None'}")
ui.markdown(f"**Tech stack:** {', '.join(prompt['tech_stack']) or 'None'}")
ui.label(prompt['prompt_text']).classes('factory-code')
else:
ui.label('No prompt recorded.').classes('factory-muted')
with ui.card().classes('q-pa-md'):
ui.label('Generated Changes').style('font-weight: 700; color: #3a281a;')
changes = project_bundle.get('code_changes', [])
if changes:
for change in changes:
with ui.row().classes('justify-between items-start w-full q-mt-sm'):
ui.label(change['file_path'] or 'unknown file').style('font-weight: 600; color: #2f241d;')
ui.label(change['action_type']).classes('factory-chip')
ui.label(change['diff_summary'] or change['details']).classes('factory-muted')
else:
ui.label('No code changes 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('Recent Logs').style('font-weight: 700; color: #3a281a;')
logs = project_bundle.get('logs', [])[:6]
if logs:
for log in logs:
ui.markdown(f"- {log['timestamp'] or 'n/a'} · {log['level']} · {log['message']}")
else:
ui.label('No project logs yet.').classes('factory-muted')
with ui.card().classes('q-pa-md'):
ui.label('Audit Trail').style('font-weight: 700; color: #3a281a;')
audits = project_bundle.get('audit_trail', [])[:6]
if audits:
for audit in audits:
ui.markdown(f"- {audit['timestamp'] or 'n/a'} · {audit['action']} · {audit['details']}")
else:
ui.label('No audit events yet.').classes('factory-muted')
with ui.tab_panel('trace'):
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('Prompt to Code Correlation').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
ui.label('Each prompt entry is linked to the generated files recorded after that prompt for the same project.').classes('factory-muted')
if correlations:
for correlation in correlations:
correlation_project = project_repository_map.get(correlation['project_id'], {})
with ui.card().classes('q-pa-md q-mt-md'):
ui.label(correlation_project.get('project_name') or correlation['project_id']).style('font-size: 1rem; font-weight: 700; color: #2f241d;')
_render_repository_block(correlation_project.get('repository'))
ui.label(correlation['prompt_text']).classes('factory-code q-mt-sm')
if correlation['changes']:
for change in correlation['changes']:
ui.markdown(
f"- **{change['file_path'] or 'unknown'}** · {change['change_type']} · {change['diff_summary'] or change['details']}"
)
else:
ui.label('No code changes correlated to this prompt yet.').classes('factory-muted')
else:
ui.label('No prompt traces recorded yet.').classes('factory-muted')
with ui.tab_panel('system'):
with ui.grid(columns=2).classes('w-full gap-4'):
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('System Logs').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
if system_logs:
for log in system_logs:
ui.markdown(f"- {log['timestamp'] or 'n/a'} · **{log['component']}** · {log['level']} · {log['message']}")
else:
ui.label('No system logs yet.').classes('factory-muted')
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('Important Endpoints').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
endpoints = [ endpoints = [
['/ (root)', 'Dashboard'], '/health',
['/generate', 'Generate new software (POST)'], '/generate',
['/health', 'Health check'], '/projects',
['/projects', 'List all projects'], '/audit/projects',
['/status/{project_id}', 'Get project status'], '/audit/prompts',
['/audit/projects', 'Get project audit data'], '/audit/changes',
['/audit/logs', 'Get system logs'], '/audit/correlations',
['/audit/trail', 'Get audit trail'], '/n8n/health',
['/audit/actions', 'Get user actions'], '/n8n/setup',
['/audit/history', 'Get project history'],
['/audit/prompts', 'Get prompts'],
['/audit/changes', 'Get code changes'],
['/init-db', 'Initialize database (POST)'],
] ]
for endpoint, desc in endpoints: for endpoint in endpoints:
ui.label(f'{endpoint:<30} {desc}') ui.label(endpoint).classes('factory-code q-mt-sm')
finally:
db_session.close() with ui.tab_panel('health'):
with ui.card().classes('factory-panel q-pa-lg q-mb-md'):
ui.label('Health and Diagnostics').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
ui.label('Use this page to verify runtime configuration, n8n API connectivity, and likely causes of provisioning failures.').classes('factory-muted')
ui.link('Open dedicated health page', '/health-ui')
with ui.card().classes('factory-panel q-pa-lg q-mb-md'):
ui.label('Telegram Prompt Channel').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
ui.label('Send a guide message into the same Telegram chat/channel where the bot is expected to receive prompts.').classes('factory-muted')
with ui.row().classes('justify-between w-full q-mt-sm'):
ui.label('Configured Chat ID').classes('factory-muted')
ui.label(settings.telegram_chat_id or 'Not configured').style('font-weight: 600; color: #3a281a;')
with ui.row().classes('items-center gap-2 q-mt-md'):
ui.button('Send Prompt Guide', on_click=send_telegram_prompt_guide_action).props('unelevated color=secondary')
_render_health_panels()
dashboard_body()
ui.timer(10.0, dashboard_body.refresh)
def run_app(port=None, reload=False, browser=True, storage_secret=None): def run_app(port=None, reload=False, browser=True, storage_secret=None):
@@ -196,7 +489,6 @@ def run_app(port=None, reload=False, browser=True, storage_secret=None):
ui.run(title='AI Software Factory Dashboard', port=port, reload=reload, browser=browser, storage_secret=storage_secret) ui.run(title='AI Software Factory Dashboard', port=port, reload=reload, browser=browser, storage_secret=storage_secret)
# Create and run the app
if __name__ in {'__main__', '__console__'}: if __name__ in {'__main__', '__console__'}:
create_dashboard() create_dashboard()
run_app() run_app()

View File

@@ -1,16 +1,50 @@
"""Database connection and session management.""" """Database connection and session management."""
from sqlalchemy import create_engine, event from collections.abc import Generator
from sqlalchemy.orm import sessionmaker, Session from pathlib import Path
from config import settings from urllib.parse import urlparse
from models import Base
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, event, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
try:
from .config import settings
from .models import Base
except ImportError:
from config import settings
from models import Base
def get_engine() -> create_engine: def get_database_runtime_summary() -> dict[str, str]:
"""Return a human-readable summary of the effective database backend."""
if settings.use_sqlite:
db_path = str(Path(settings.SQLITE_DB_PATH or "/tmp/ai_software_factory_test.db").expanduser().resolve())
return {
"backend": "sqlite",
"target": db_path,
"database": db_path,
}
parsed = urlparse(settings.database_url)
database_name = parsed.path.lstrip("/") or "unknown"
host = parsed.hostname or "unknown-host"
port = str(parsed.port or 5432)
return {
"backend": parsed.scheme.split("+", 1)[0] or "postgresql",
"target": f"{host}:{port}/{database_name}",
"database": database_name,
}
def get_engine() -> Engine:
"""Create and return SQLAlchemy engine with connection pooling.""" """Create and return SQLAlchemy engine with connection pooling."""
# Use SQLite for tests, PostgreSQL for production # Use SQLite for tests, PostgreSQL for production
if settings.USE_SQLITE: if settings.use_sqlite:
db_path = settings.SQLITE_DB_PATH or "/tmp/ai_software_factory_test.db" db_path = settings.SQLITE_DB_PATH or "/tmp/ai_software_factory_test.db"
Path(db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
db_url = f"sqlite:///{db_path}" db_url = f"sqlite:///{db_path}"
# SQLite-specific configuration - no pooling for SQLite # SQLite-specific configuration - no pooling for SQLite
engine = create_engine( engine = create_engine(
@@ -19,7 +53,7 @@ def get_engine() -> create_engine:
echo=settings.LOG_LEVEL == "DEBUG" echo=settings.LOG_LEVEL == "DEBUG"
) )
else: else:
db_url = settings.POSTGRES_URL or settings.database_url db_url = settings.database_url
# PostgreSQL-specific configuration # PostgreSQL-specific configuration
engine = create_engine( engine = create_engine(
db_url, db_url,
@@ -31,7 +65,7 @@ def get_engine() -> create_engine:
) )
# Event listener for connection checkout (PostgreSQL only) # Event listener for connection checkout (PostgreSQL only)
if not settings.USE_SQLITE: if not settings.use_sqlite:
@event.listens_for(engine, "checkout") @event.listens_for(engine, "checkout")
def receive_checkout(dbapi_connection, connection_record, connection_proxy): def receive_checkout(dbapi_connection, connection_record, connection_proxy):
"""Log connection checkout for audit purposes.""" """Log connection checkout for audit purposes."""
@@ -47,12 +81,11 @@ def get_engine() -> create_engine:
return engine return engine
def get_session() -> Session: def get_session() -> Generator[Session, None, None]:
"""Create and return database session factory.""" """Yield a managed database session."""
engine = get_engine() engine = get_engine()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def session_factory() -> Session:
session = SessionLocal() session = SessionLocal()
try: try:
yield session yield session
@@ -63,19 +96,10 @@ def get_session() -> Session:
finally: finally:
session.close() session.close()
return session_factory
def get_db() -> Generator[Session, None, None]:
def get_db() -> Session:
"""Dependency for FastAPI routes that need database access.""" """Dependency for FastAPI routes that need database access."""
engine = get_engine() yield from get_session()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = SessionLocal()
try:
yield session
finally:
session.close()
def get_db_sync() -> Session: def get_db_sync() -> Session:
@@ -92,43 +116,115 @@ def get_db_session() -> Session:
return session return session
def init_db() -> None: def get_alembic_config(database_url: str | None = None) -> Config:
"""Initialize database tables.""" """Return an Alembic config bound to the active database URL."""
package_root = Path(__file__).resolve().parent
alembic_ini = package_root / "alembic.ini"
config = Config(str(alembic_ini))
config.set_main_option("script_location", str(package_root / "alembic"))
config.set_main_option("sqlalchemy.url", database_url or settings.database_url)
return config
def run_migrations(database_url: str | None = None) -> dict:
"""Apply Alembic migrations to the configured database."""
try:
config = get_alembic_config(database_url)
command.upgrade(config, "head")
return {"status": "success", "message": "Database migrations applied."}
except Exception as exc:
return {"status": "error", "message": str(exc)}
def init_db() -> dict:
"""Initialize database tables and database if needed."""
if settings.use_sqlite:
result = run_migrations()
if result["status"] == "success":
print("SQLite database migrations applied successfully.")
return {"status": "success", "message": "SQLite database initialized via migrations."}
engine = get_engine() engine = get_engine()
try:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
print("Database tables created successfully.") print("SQLite database tables created successfully.")
return {"status": "success", "message": "SQLite database initialized with metadata fallback."}
except Exception as e:
print(f"Error initializing SQLite database: {str(e)}")
return {'status': 'error', 'message': f'Error: {str(e)}'}
else:
# PostgreSQL
db_url = settings.database_url
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
try:
# Create engine to check/create database
engine = create_engine(db_url)
# Try to create database if it doesn't exist
try:
with engine.connect() as conn:
# Check if database exists
result = conn.execute(text(f"SELECT 1 FROM {db_name} WHERE 1=0"))
# If no error, database exists
conn.commit()
print(f"PostgreSQL database '{db_name}' already exists.")
except Exception as e:
# Database doesn't exist or has different error - try to create it
error_msg = str(e).lower()
# Only create if it's a relation does not exist error or similar
if "does not exist" in error_msg or "database" in error_msg:
try:
conn = engine.connect()
conn.execute(text(f"CREATE DATABASE {db_name}"))
conn.commit()
print(f"PostgreSQL database '{db_name}' created.")
except Exception as db_error:
print(f"Could not create database: {str(db_error)}")
# Try to connect anyway - maybe using existing db name
engine = create_engine(db_url.replace(f'/{db_name}', '/postgres'))
with engine.connect() as conn:
# Just create tables in postgres database for now
print(f"Using existing 'postgres' database.")
migration_result = run_migrations(db_url)
if migration_result["status"] == "success":
print(f"PostgreSQL database '{db_name}' migrations applied successfully.")
return {'status': 'success', 'message': f'PostgreSQL database "{db_name}" initialized via migrations.'}
Base.metadata.create_all(bind=engine)
print(f"PostgreSQL database '{db_name}' tables created successfully.")
return {'status': 'success', 'message': f'PostgreSQL database "{db_name}" initialized with metadata fallback.'}
except Exception as e:
print(f"Error initializing PostgreSQL database: {str(e)}")
return {'status': 'error', 'message': f'Error: {str(e)}'}
def drop_db() -> None: def drop_db() -> dict:
"""Drop all database tables (use with caution!).""" """Drop all database tables (use with caution!)."""
if settings.use_sqlite:
engine = get_engine() engine = get_engine()
try:
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
print("Database tables dropped successfully.") print("SQLite database tables dropped successfully.")
return {'status': 'success', 'message': 'SQLite tables dropped.'}
except Exception as e:
print(f"Error dropping SQLite tables: {str(e)}")
return {'status': 'error', 'message': str(e)}
else:
db_url = settings.database_url
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
try:
engine = create_engine(db_url)
Base.metadata.drop_all(bind=engine)
print(f"PostgreSQL database '{db_name}' tables dropped successfully.")
return {'status': 'success', 'message': f'PostgreSQL "{db_name}" tables dropped.'}
except Exception as e:
print(f"Error dropping PostgreSQL tables: {str(e)}")
return {'status': 'error', 'message': str(e)}
def create_migration_script() -> str: def create_migration_script() -> str:
"""Generate a migration script for database schema changes.""" """Generate a migration script for database schema changes."""
return '''-- Migration script for AI Software Factory database return """See ai_software_factory/alembic/versions for managed schema migrations."""
-- Generated automatically - review before applying
-- Add new columns to existing tables if needed
-- This is a placeholder for future migrations
-- Example: Add audit_trail_index for better query performance
CREATE INDEX IF NOT EXISTS idx_audit_trail_timestamp ON audit_trail(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_trail_action ON audit_trail(action);
CREATE INDEX IF NOT EXISTS idx_audit_trail_project ON audit_trail(project_id);
-- Example: Add user_actions_index for better query performance
CREATE INDEX IF NOT EXISTS idx_user_actions_timestamp ON user_actions(timestamp);
CREATE INDEX IF NOT EXISTS idx_user_actions_actor ON user_actions(actor_type, actor_name);
CREATE INDEX IF NOT EXISTS idx_user_actions_history ON user_actions(history_id);
-- Example: Add project_logs_index for better query performance
CREATE INDEX IF NOT EXISTS idx_project_logs_timestamp ON project_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_project_logs_level ON project_logs(log_level);
-- Example: Add system_logs_index for better query performance
CREATE INDEX IF NOT EXISTS idx_system_logs_timestamp ON system_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_system_logs_component ON system_logs(component);
'''

View File

@@ -5,9 +5,14 @@ The dashboard shown is from dashboard_ui.py with real-time database data.
""" """
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from nicegui import app, ui from nicegui import app, ui
from dashboard_ui import create_dashboard
try:
from .dashboard_ui import create_dashboard, create_health_page
except ImportError:
from dashboard_ui import create_dashboard, create_health_page
def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None: def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
@@ -18,14 +23,29 @@ def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
storage_secret: Optional secret for persistent user storage. storage_secret: Optional secret for persistent user storage.
""" """
@ui.page('/show') def render_dashboard_page() -> None:
def show():
create_dashboard() create_dashboard()
# NOTE dark mode will be persistent for each user across tabs and server restarts # NOTE dark mode will be persistent for each user across tabs and server restarts
ui.dark_mode().bind_value(app.storage.user, 'dark_mode') ui.dark_mode().bind_value(app.storage.user, 'dark_mode')
ui.checkbox('dark mode').bind_value(app.storage.user, 'dark_mode') ui.checkbox('dark mode').bind_value(app.storage.user, 'dark_mode')
@ui.page('/')
def home() -> None:
render_dashboard_page()
@ui.page('/show')
def show() -> None:
render_dashboard_page()
@ui.page('/health-ui')
def health_ui() -> None:
create_health_page()
@fastapi_app.get('/dashboard', include_in_schema=False)
def dashboard_redirect() -> RedirectResponse:
return RedirectResponse(url='/', status_code=307)
ui.run_with( ui.run_with(
fastapi_app, fastapi_app,
storage_secret=storage_secret, # NOTE setting a secret is optional but allows for persistent storage per user storage_secret=storage_secret, # NOTE setting a secret is optional but allows for persistent storage per user

View File

@@ -1,3 +0,0 @@
#!/bin/bash
echo "Hello world"

View File

@@ -6,21 +6,372 @@ This application uses FastAPI to:
2. Host NiceGUI frontend via ui.run_with() 2. Host NiceGUI frontend via ui.run_with()
The NiceGUI frontend provides: The NiceGUI frontend provides:
1. Interactive dashboard at /show 1. Interactive dashboard at /
2. Real-time data visualization 2. Real-time data visualization
3. Audit trail display 3. Audit trail display
""" """
import frontend from __future__ import annotations
from fastapi import FastAPI
app = FastAPI() from contextlib import asynccontextmanager
import json
import re
from pathlib import Path
from typing import Annotated
from uuid import uuid4
from fastapi import Depends, FastAPI, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
try:
from . import __version__, frontend
from . import database as database_module
from .agents.database_manager import DatabaseManager
from .agents.orchestrator import AgentOrchestrator
from .agents.n8n_setup import N8NSetupAgent
from .agents.ui_manager import UIManager
from .models import ProjectHistory, ProjectLog, SystemLog
except ImportError:
import frontend
import database as database_module
from agents.database_manager import DatabaseManager
from agents.orchestrator import AgentOrchestrator
from agents.n8n_setup import N8NSetupAgent
from agents.ui_manager import UIManager
from models import ProjectHistory, ProjectLog, SystemLog
__version__ = "0.0.1"
@app.get('/') @asynccontextmanager
def read_root(): async def lifespan(_app: FastAPI):
"""Root endpoint that returns welcome message.""" """Log resolved runtime configuration when the app starts."""
return {'Hello': 'World'} runtime = database_module.get_database_runtime_summary()
print(
f"Runtime configuration: database_backend={runtime['backend']} target={runtime['target']}"
)
yield
app = FastAPI(lifespan=lifespan)
DbSession = Annotated[Session, Depends(database_module.get_db)]
PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+")
class SoftwareRequest(BaseModel):
"""Request body for software generation."""
name: str = Field(min_length=1, max_length=255)
description: str = Field(min_length=1, max_length=255)
features: list[str] = Field(default_factory=list)
tech_stack: list[str] = Field(default_factory=list)
class N8NSetupRequest(BaseModel):
"""Request body for n8n workflow provisioning."""
api_url: str | None = None
api_key: str | None = None
webhook_path: str = "telegram"
backend_url: str | None = None
force_update: bool = False
def _build_project_id(name: str) -> str:
"""Create a stable project id from the requested name."""
slug = PROJECT_ID_PATTERN.sub("-", name.strip().lower()).strip("-") or "project"
return f"{slug}-{uuid4().hex[:8]}"
def _serialize_project(history: ProjectHistory) -> dict:
"""Serialize a project history row for API responses."""
return {
"history_id": history.id,
"project_id": history.project_id,
"name": history.project_name,
"description": history.description,
"status": history.status,
"progress": history.progress,
"message": history.message,
"current_step": history.current_step,
"error_message": history.error_message,
"created_at": history.created_at.isoformat() if history.created_at else None,
"updated_at": history.updated_at.isoformat() if history.updated_at else None,
"completed_at": history.completed_at.isoformat() if history.completed_at else None,
}
def _serialize_project_log(log: ProjectLog) -> dict:
"""Serialize a project log row."""
return {
"id": log.id,
"history_id": log.history_id,
"level": log.log_level,
"message": log.log_message,
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
}
def _serialize_system_log(log: SystemLog) -> dict:
"""Serialize a system log row."""
return {
"id": log.id,
"component": log.component,
"level": log.log_level,
"message": log.log_message,
"user_agent": log.user_agent,
"ip_address": log.ip_address,
"timestamp": log.created_at.isoformat() if log.created_at else None,
}
def _serialize_audit_item(item: dict) -> dict:
"""Return audit-shaped dictionaries unchanged for API output."""
return item
def _compose_prompt_text(request: SoftwareRequest) -> str:
"""Render the originating software request into a stable prompt string."""
features = ", ".join(request.features) if request.features else "None"
tech_stack = ", ".join(request.tech_stack) if request.tech_stack else "None"
return (
f"Name: {request.name}\n"
f"Description: {request.description}\n"
f"Features: {features}\n"
f"Tech Stack: {tech_stack}"
)
def _project_root(project_id: str) -> Path:
"""Resolve the filesystem location for a generated project."""
return database_module.settings.projects_root / project_id
def _resolve_n8n_api_url(explicit_url: str | None = None) -> str:
"""Resolve the effective n8n API URL from explicit input or settings."""
if explicit_url and explicit_url.strip():
return explicit_url.strip()
if database_module.settings.n8n_api_url:
return database_module.settings.n8n_api_url
webhook_url = database_module.settings.n8n_webhook_url
if webhook_url:
return webhook_url.split("/webhook", 1)[0].rstrip("/")
return ""
@app.get('/api')
def read_api_info():
"""Return service metadata for API clients."""
return {
'service': 'AI Software Factory',
'version': __version__,
'endpoints': [
'/',
'/api',
'/health',
'/generate',
'/projects',
'/status/{project_id}',
'/audit/projects',
'/audit/logs',
'/audit/system/logs',
'/audit/prompts',
'/audit/changes',
'/audit/lineage',
'/audit/correlations',
'/n8n/health',
'/n8n/setup',
],
}
@app.get('/health')
def health_check():
"""Health check endpoint."""
runtime = database_module.get_database_runtime_summary()
return {
'status': 'healthy',
'database': runtime['backend'],
'database_target': runtime['target'],
'database_name': runtime['database'],
}
@app.post('/generate')
async def generate_software(request: SoftwareRequest, db: DbSession):
"""Create and record a software-generation request."""
database_module.init_db()
project_id = _build_project_id(request.name)
prompt_text = _compose_prompt_text(request)
orchestrator = AgentOrchestrator(
project_id=project_id,
project_name=request.name,
description=request.description,
features=request.features,
tech_stack=request.tech_stack,
db=db,
prompt_text=prompt_text,
)
result = await orchestrator.run()
manager = DatabaseManager(db)
manager.log_system_event(
component='api',
level='INFO' if result['status'] == 'completed' else 'ERROR',
message=f"Generated project {project_id} with {len(result.get('changed_files', []))} artifact(s)",
)
history = manager.get_project_by_id(project_id)
project_logs = manager.get_project_logs(history.id)
response_data = _serialize_project(history)
response_data['logs'] = [_serialize_project_log(log) for log in project_logs]
response_data['ui_data'] = result.get('ui_data')
response_data['features'] = request.features
response_data['tech_stack'] = request.tech_stack
response_data['project_root'] = result.get('project_root', str(_project_root(project_id)))
response_data['changed_files'] = result.get('changed_files', [])
response_data['repository'] = result.get('repository')
return {'status': result['status'], 'data': response_data}
@app.get('/projects')
def list_projects(db: DbSession):
"""List recorded projects."""
manager = DatabaseManager(db)
projects = manager.get_all_projects()
return {'projects': [_serialize_project(project) for project in projects]}
@app.get('/status/{project_id}')
def get_project_status(project_id: str, db: DbSession):
"""Get the current status for a single 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')
return _serialize_project(history)
@app.get('/audit/projects')
def get_audit_projects(db: DbSession):
"""Return projects together with their related logs and audit data."""
manager = DatabaseManager(db)
projects = []
for history in manager.get_all_projects():
project_data = _serialize_project(history)
audit_data = manager.get_project_audit_data(history.project_id)
project_data['logs'] = audit_data['logs']
project_data['actions'] = audit_data['actions']
project_data['audit_trail'] = audit_data['audit_trail']
projects.append(project_data)
return {'projects': projects}
@app.get('/audit/prompts')
def get_prompt_audit(db: DbSession, project_id: str | None = Query(default=None)):
"""Return stored prompt submissions."""
manager = DatabaseManager(db)
return {'prompts': [_serialize_audit_item(item) for item in manager.get_prompt_events(project_id=project_id)]}
@app.get('/audit/changes')
def get_code_change_audit(db: DbSession, project_id: str | None = Query(default=None)):
"""Return recorded code changes."""
manager = DatabaseManager(db)
return {'changes': [_serialize_audit_item(item) for item in manager.get_code_changes(project_id=project_id)]}
@app.get('/audit/lineage')
def get_prompt_change_lineage(db: DbSession, project_id: str | None = Query(default=None)):
"""Return explicit prompt-to-code lineage rows."""
manager = DatabaseManager(db)
return {'lineage': manager.get_prompt_change_links(project_id=project_id)}
@app.get('/audit/correlations')
def get_prompt_change_correlations(db: DbSession, project_id: str | None = Query(default=None)):
"""Return prompt-to-change correlations for generated projects."""
manager = DatabaseManager(db)
return {'correlations': manager.get_prompt_change_correlations(project_id=project_id)}
@app.get('/audit/logs')
def get_audit_logs(db: DbSession):
"""Return all project logs ordered newest first."""
logs = db.query(ProjectLog).order_by(ProjectLog.id.desc()).all()
return {'logs': [_serialize_project_log(log) for log in logs]}
@app.get('/audit/system/logs')
def get_system_audit_logs(
db: DbSession,
component: str | None = Query(default=None),
):
"""Return system logs with optional component filtering."""
query = db.query(SystemLog).order_by(SystemLog.id.desc())
if component:
query = query.filter(SystemLog.component == component)
return {'logs': [_serialize_system_log(log) for log in query.all()]}
@app.get('/n8n/health')
async def get_n8n_health():
"""Check whether the configured n8n instance is reachable."""
api_url = _resolve_n8n_api_url()
if not api_url:
return {
'status': 'error',
'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured.',
'api_url': '',
'auth_configured': bool(database_module.settings.n8n_api_key),
'checks': [],
'suggestion': 'Set N8N_API_URL to the base n8n address before provisioning workflows.',
}
agent = N8NSetupAgent(api_url=api_url, webhook_token=database_module.settings.n8n_api_key)
return await agent.health_check()
@app.post('/n8n/setup')
async def setup_n8n_workflow(request: N8NSetupRequest, db: DbSession):
"""Create or update the n8n Telegram workflow."""
api_url = _resolve_n8n_api_url(request.api_url)
if not api_url:
raise HTTPException(status_code=400, detail='n8n API URL is not configured')
agent = N8NSetupAgent(
api_url=api_url,
webhook_token=(request.api_key or database_module.settings.n8n_api_key),
)
result = await agent.setup(
webhook_path=request.webhook_path,
backend_url=request.backend_url or f"{database_module.settings.backend_public_url}/generate",
force_update=request.force_update,
telegram_bot_token=database_module.settings.telegram_bot_token,
telegram_credential_name=database_module.settings.n8n_telegram_credential_name,
)
manager = DatabaseManager(db)
log_level = 'INFO' if result.get('status') != 'error' else 'ERROR'
manager.log_system_event(
component='n8n',
level=log_level,
message=result.get('message', json.dumps(result)),
)
return result
@app.post('/init-db')
def initialize_database():
"""Initialize database tables (POST endpoint for NiceGUI to call before dashboard)."""
try:
database_module.init_db()
return {'message': 'Database tables created successfully', 'status': 'success'}
except Exception as e:
return {'message': f'Error initializing database: {str(e)}', 'status': 'error'}
frontend.init(app) frontend.init(app)

View File

@@ -10,7 +10,10 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import relationship, declarative_base from sqlalchemy.orm import relationship, declarative_base
from config import settings try:
from .config import settings
except ImportError:
from config import settings
Base = declarative_base() Base = declarative_base()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,6 +55,7 @@ class ProjectHistory(Base):
ui_snapshots = relationship("UISnapshot", back_populates="project_history", cascade="all, delete-orphan") ui_snapshots = relationship("UISnapshot", back_populates="project_history", cascade="all, delete-orphan")
pull_requests = relationship("PullRequest", back_populates="project_history", cascade="all, delete-orphan") pull_requests = relationship("PullRequest", back_populates="project_history", cascade="all, delete-orphan")
pull_request_data = relationship("PullRequestData", back_populates="project_history", cascade="all, delete-orphan") pull_request_data = relationship("PullRequestData", back_populates="project_history", cascade="all, delete-orphan")
prompt_code_links = relationship("PromptCodeLink", back_populates="project_history", cascade="all, delete-orphan")
class ProjectLog(Base): class ProjectLog(Base):
@@ -145,6 +149,22 @@ class AuditTrail(Base):
metadata_json = Column(JSON, nullable=True) metadata_json = Column(JSON, nullable=True)
class PromptCodeLink(Base):
"""Explicit lineage between a prompt event and a resulting code change."""
__tablename__ = "prompt_code_links"
id = Column(Integer, primary_key=True)
history_id = Column(Integer, ForeignKey("project_history.id"), nullable=False)
project_id = Column(String(255), nullable=False)
prompt_audit_id = Column(Integer, nullable=False)
code_change_audit_id = Column(Integer, nullable=False)
file_path = Column(String(500), nullable=True)
change_type = Column(String(50), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
project_history = relationship("ProjectHistory", back_populates="prompt_code_links")
class UserAction(Base): class UserAction(Base):
"""User action audit entries.""" """User action audit entries."""
__tablename__ = "user_actions" __tablename__ = "user_actions"

View File

@@ -16,3 +16,6 @@ flake8==6.1.0
mypy==1.7.1 mypy==1.7.1
httpx==0.25.2 httpx==0.25.2
nicegui==3.9.0 nicegui==3.9.0
aiohttp>=3.9.0
pytest-asyncio>=0.23.0
alembic>=1.14.0

View File

@@ -1,385 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Software Factory Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
padding: 20px;
}
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
padding: 30px;
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
background: linear-gradient(90deg, #00d4ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header p {
color: #888;
font-size: 1.1em;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
padding: 25px;
border: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
}
.stat-card h3 {
font-size: 0.9em;
color: #888;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-card .value {
font-size: 2.5em;
font-weight: bold;
color: #00d4ff;
}
.stat-card.project .value { color: #00ff88; }
.stat-card.active .value { color: #ff6b6b; }
.stat-card.code .value { color: #ffd93d; }
.status-panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-panel h2 {
font-size: 1.3em;
margin-bottom: 15px;
color: #00d4ff;
}
.status-bar {
height: 20px;
background: #2a2a4a;
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
}
.status-fill {
height: 100%;
background: linear-gradient(90deg, #00d4ff, #00ff88);
border-radius: 10px;
transition: width 0.5s ease;
}
.message {
padding: 10px;
background: rgba(0, 212, 255, 0.1);
border-radius: 8px;
border-left: 4px solid #00d4ff;
}
.projects-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.projects-section h2 {
font-size: 1.3em;
margin-bottom: 15px;
color: #00ff88;
}
.projects-list {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.project-item {
background: rgba(0, 255, 136, 0.1);
padding: 15px 20px;
border-radius: 10px;
border: 1px solid rgba(0, 255, 136, 0.3);
font-size: 0.9em;
}
.project-item.active {
background: rgba(255, 107, 107, 0.1);
border-color: rgba(255, 107, 107, 0.3);
}
.audit-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.audit-section h2 {
font-size: 1.3em;
margin-bottom: 15px;
color: #ffd93d;
}
.audit-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.audit-table th, .audit-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.audit-table th {
color: #888;
font-weight: 600;
font-size: 0.85em;
}
.audit-table td {
font-size: 0.9em;
}
.audit-table .timestamp {
color: #666;
font-size: 0.8em;
}
.actions-panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
padding: 25px;
border: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
}
.actions-panel h2 {
font-size: 1.3em;
margin-bottom: 15px;
color: #ff6b6b;
}
.actions-panel p {
color: #888;
margin-bottom: 20px;
}
.loading {
text-align: center;
padding: 50px;
color: #888;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.projects-list {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="dashboard">
<div class="header">
<h1>🚀 AI Software Factory</h1>
<p>Real-time Dashboard & Audit Trail Display</p>
</div>
<div class="stats-grid">
<div class="stat-card project">
<h3>Current Project</h3>
<div class="value" id="project-name">Loading...</div>
</div>
<div class="stat-card active">
<h3>Active Projects</h3>
<div class="value" id="active-projects">0</div>
</div>
<div class="stat-card code">
<h3>Total Projects</h3>
<div class="value" id="total-projects">0</div>
</div>
<div class="stat-card">
<h3>Status</h3>
<div class="value" id="status-value">Loading...</div>
</div>
</div>
<div class="status-panel">
<h2>📊 Current Status</h2>
<div class="status-bar">
<div class="status-fill" id="status-fill" style="width: 0%"></div>
</div>
<div class="message" id="status-message">Loading...</div>
</div>
<div class="projects-section">
<h2>📁 Active Projects</h2>
<div class="projects-list" id="projects-list">
<div class="loading">Loading projects...</div>
</div>
</div>
<div class="audit-section">
<h2>📜 Audit Trail</h2>
<table class="audit-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Agent</th>
<th>Action</th>
<th>Status</th>
</tr>
</thead>
<tbody id="audit-trail-body">
<tr>
<td class="timestamp">Loading...</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
</tbody>
</table>
</div>
<div class="actions-panel">
<h2>⚙️ System Actions</h2>
<p id="actions-message">Dashboard is rendering successfully.</p>
<p style="color: #888; font-size: 0.9em;">This dashboard is powered by the AI Software Factory and displays real-time status updates, audit trails, and project information.</p>
</div>
</div>
<script>
// Fetch data from API
async function loadDashboardData() {
try {
// Load projects
const projectsResponse = await fetch('/projects');
const projectsData = await projectsResponse.json();
updateProjects(projectsData.projects);
// Get latest active project
const activeProject = projectsData.projects.find(p => p.status === 'RUNNING' || p.status === 'IN_PROGRESS');
if (activeProject) {
document.getElementById('project-name').textContent = activeProject.project_name || activeProject.project_id;
updateStatusPanel(activeProject);
// Load audit trail for this project
const auditResponse = await fetch(`/audit/trail?limit=10`);
const auditData = await auditResponse.json();
updateAuditTrail(auditData.audit_trail);
} else {
// No active project, show all projects
document.getElementById('projects-list').innerHTML = projectsData.projects.map(p =>
`<div class="project-item ${p.status === 'RUNNING' || p.status === 'IN_PROGRESS' ? 'active' : ''}">
<strong>${p.project_name || p.project_id}</strong> • ${p.status}${p.progress || 0}%
</div>`
).join('');
}
} catch (error) {
console.error('Error loading dashboard data:', error);
document.getElementById('status-message').innerHTML =
`<strong>Error:</strong> Failed to load dashboard data. Please check the console for details.`;
}
}
function updateProjects(projects) {
const activeProjects = projects.filter(p => p.status === 'RUNNING' || p.status === 'IN_PROGRESS' || p.status === 'COMPLETED').length;
document.getElementById('active-projects').textContent = activeProjects;
document.getElementById('total-projects').textContent = projects.length;
}
function updateStatusPanel(project) {
const progress = project.progress || 0;
document.getElementById('status-fill').style.width = progress + '%';
document.getElementById('status-message').innerHTML =
`<strong>${project.message || 'Project running...'}</strong><br>` +
`<span style="color: #888;">Progress: ${progress}%</span>`;
document.getElementById('status-value').textContent = project.status;
}
function updateAuditTrail(auditEntries) {
if (auditEntries.length === 0) {
document.getElementById('audit-trail-body').innerHTML =
`<tr><td colspan="4" style="text-align: center; color: #888;">No audit entries yet</td></tr>`;
return;
}
const formattedEntries = auditEntries.map(entry => ({
...entry,
timestamp: entry.timestamp ? new Date(entry.timestamp).toLocaleString() : '-'
}));
document.getElementById('audit-trail-body').innerHTML = formattedEntries.map(entry => `
<tr>
<td class="timestamp">${entry.timestamp}</td>
<td>${entry.actor || '-'}</td>
<td>${entry.action || entry.details || '-'}</td>
<td style="color: ${getStatusColor(entry.action_type || entry.status)};">${entry.action_type || entry.status || '-'}</td>
</tr>
`).join('');
}
function getStatusColor(status) {
if (!status) return '#888';
const upper = status.toUpperCase();
if (['SUCCESS', 'COMPLETED', 'FINISHED'].includes(upper)) return '#00ff88';
if (['IN_PROGRESS', 'RUNNING', 'PENDING'].includes(upper)) return '#00d4ff';
if (['ERROR', 'FAILED', 'FAILED'].includes(upper)) return '#ff6b6b';
return '#888';
}
// Load data when dashboard is ready
loadDashboardData();
</script>
</body>
</html>