13 Commits
0.4.0 ... 0.6.2

Author SHA1 Message Date
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
20 changed files with 755 additions and 206 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 '-' '_')

1
.gitignore vendored
View File

@@ -1 +1,2 @@
sqlite.db 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

@@ -4,6 +4,49 @@ Changelog
(unreleased) (unreleased)
------------ ------------
Fix
~~~
- Fix Quasar layout issues, refs NOISSUE. [Simon Diesenreiter]
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 - Chore(git): ignore local sqlite database refs NOISSUE. [Simon
Diesenreiter] Diesenreiter]
- Feat(factory): implement db-backed dashboard and workflow automation - Feat(factory): implement db-backed dashboard and workflow automation

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

@@ -1 +1 @@
0.4.0 0.6.2

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

@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
try: try:
from ..config import settings
from ..models import ( from ..models import (
AuditTrail, AuditTrail,
ProjectHistory, ProjectHistory,
@@ -17,6 +18,7 @@ try:
UserAction, UserAction,
) )
except ImportError: except ImportError:
from config import settings
from models import ( from models import (
AuditTrail, AuditTrail,
ProjectHistory, ProjectHistory,
@@ -307,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()
@@ -708,6 +735,7 @@ class DatabaseManager:
prompts = self.get_prompt_events(project_id=project_id) prompts = self.get_prompt_events(project_id=project_id)
code_changes = self.get_code_changes(project_id=project_id) code_changes = self.get_code_changes(project_id=project_id)
correlations = self.get_prompt_change_correlations(project_id=project_id) correlations = self.get_prompt_change_correlations(project_id=project_id)
repository = self._get_project_repository(history)
return { return {
"project": { "project": {
@@ -720,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
}, },
@@ -759,6 +788,7 @@ class DatabaseManager:
"prompts": prompts, "prompts": prompts,
"code_changes": code_changes, "code_changes": code_changes,
"prompt_change_correlations": correlations, "prompt_change_correlations": correlations,
"repository": repository,
} }
def get_prompt_events(self, project_id: str | None = None, limit: int = 100) -> list[dict]: def get_prompt_events(self, project_id: str | None = None, limit: int = 100) -> list[dict]:
@@ -840,6 +870,13 @@ class DatabaseManager:
} }
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

@@ -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,6 +1,8 @@
"""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
try: try:
@@ -46,6 +48,113 @@ class N8NSetupAgent:
headers["X-N8N-API-KEY"] = self.webhook_token headers["X-N8N-API-KEY"] = self.webhook_token
return headers 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: async def _request(self, method: str, path: str, **kwargs) -> dict:
"""Send a request to n8n and normalize the response.""" """Send a request to n8n and normalize the response."""
import aiohttp import aiohttp
@@ -62,15 +171,42 @@ class N8NSetupAgent:
payload = {"text": await resp.text()} payload = {"text": await resp.text()}
if 200 <= resp.status < 300: if 200 <= resp.status < 300:
if isinstance(payload, dict): return self._normalize_success(method, url, resp.status, payload)
payload.setdefault("status_code", resp.status)
return payload
return {"data": payload, "status_code": resp.status}
message = payload.get("message") if isinstance(payload, dict) else str(payload) return self._normalize_error(method, url, resp.status, payload)
return {"error": f"Status {resp.status}: {message}", "status_code": resp.status, "payload": payload}
except Exception as e: except Exception as e:
return {"error": str(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."""
@@ -304,12 +440,14 @@ class N8NSetupAgent:
async def health_check(self) -> dict: async def health_check(self) -> dict:
"""Check n8n API health.""" """Check n8n API health."""
result = await self._request("GET", f"{self.api_url}/healthz") result = await self._request("GET", f"{self.api_url}/healthz")
if result.get("error"): fallback = await self._request("GET", "workflows")
fallback = await self._request("GET", "workflows") return self._build_health_result(result, fallback)
if fallback.get("error"):
return fallback def health_check_sync(self) -> dict:
return {"status": "ok", "checked_via": "workflows"} """Synchronously check n8n API health for UI rendering."""
return {"status": "ok", "checked_via": "healthz"} result = self._request_sync("GET", f"{self.api_url}/healthz")
fallback = self._request_sync("GET", "workflows")
return self._build_health_result(result, fallback)
async def setup( async def setup(
self, self,
@@ -324,7 +462,13 @@ class N8NSetupAgent:
# 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"),
}
effective_backend_url = backend_url or f"{settings.backend_public_url}/generate" effective_backend_url = backend_url or f"{settings.backend_public_url}/generate"
effective_bot_token = telegram_bot_token or settings.telegram_bot_token effective_bot_token = telegram_bot_token or settings.telegram_bot_token
@@ -334,7 +478,7 @@ class N8NSetupAgent:
if trigger_mode: if trigger_mode:
credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name) credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name)
if credential.get("error"): if credential.get("error"):
return {"status": "error", "message": credential["error"]} return {"status": "error", "message": credential["error"], "details": credential}
workflow = self.build_telegram_trigger_workflow( workflow = self.build_telegram_trigger_workflow(
backend_url=effective_backend_url, backend_url=effective_backend_url,
credential_name=effective_credential_name, credential_name=effective_credential_name,
@@ -347,7 +491,7 @@ class N8NSetupAgent:
existing = await self.get_workflow(workflow["name"]) existing = await self.get_workflow(workflow["name"])
if isinstance(existing, dict) and existing.get("error"): if isinstance(existing, dict) and existing.get("error"):
return {"status": "error", "message": existing["error"]} return {"status": "error", "message": existing["error"], "details": existing}
workflow_id = None workflow_id = None
if existing and existing.get("id"): if existing and existing.get("id"):
@@ -361,12 +505,12 @@ class N8NSetupAgent:
workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None
if isinstance(result, dict) and result.get("error"): if isinstance(result, dict) and result.get("error"):
return {"status": "error", "message": result["error"]} return {"status": "error", "message": result["error"], "details": result}
workflow_id = workflow_id or str(result.get("id", "")) workflow_id = workflow_id or str(result.get("id", ""))
enable_result = await self.enable_workflow(workflow_id) enable_result = await self.enable_workflow(workflow_id)
if enable_result.get("error"): if enable_result.get("error"):
return {"status": "error", "message": enable_result["error"], "workflow": result} return {"status": "error", "message": enable_result["error"], "workflow": result, "details": enable_result}
return { return {
"status": "success", "status": "success",

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import py_compile import py_compile
from pathlib import Path
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
@@ -51,18 +50,21 @@ class AgentOrchestrator:
self.prompt_text = prompt_text self.prompt_text = prompt_text
self.prompt_actor = prompt_actor self.prompt_actor = prompt_actor
self.changed_files: list[str] = [] self.changed_files: list[str] = []
self.project_root = settings.projects_root / project_id
self.prompt_audit = None
# Initialize agents
self.git_manager = GitManager(project_id)
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
@@ -90,6 +92,53 @@ class AgentOrchestrator:
self.ui_manager.ui_data["project_root"] = str(self.project_root) 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["features"] = list(self.features)
self.ui_manager.ui_data["tech_stack"] = list(self.tech_stack) 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: def _append_log(self, message: str) -> None:
timestamped = f"[{datetime.utcnow().isoformat()}] {message}" timestamped = f"[{datetime.utcnow().isoformat()}] {message}"
@@ -165,6 +214,8 @@ class AgentOrchestrator:
self._update_progress(5, "initializing", "Setting up project structure...") self._update_progress(5, "initializing", "Setting up project structure...")
self._append_log("Initializing project.") self._append_log("Initializing project.")
await self._ensure_remote_repository()
# Step 2: Create project structure (skip git operations) # Step 2: Create project structure (skip git operations)
self._update_progress(20, "project-structure", "Creating project files...") self._update_progress(20, "project-structure", "Creating project files...")
await self._create_project_structure() await self._create_project_structure()
@@ -201,6 +252,7 @@ class AgentOrchestrator:
"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), "project_root": str(self.project_root),
"changed_files": list(dict.fromkeys(self.changed_files)), "changed_files": list(dict.fromkeys(self.changed_files)),
"repository": self.ui_manager.ui_data.get("repository"),
} }
except Exception as e: except Exception as e:
@@ -226,6 +278,7 @@ class AgentOrchestrator:
"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), "project_root": str(self.project_root),
"changed_files": list(dict.fromkeys(self.changed_files)), "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:

View File

@@ -1,6 +1,7 @@
[alembic] [alembic]
script_location = alembic script_location = alembic
prepend_sys_path = . prepend_sys_path = .
path_separator = os
sqlalchemy.url = sqlite:////tmp/ai_software_factory_test.db sqlalchemy.url = sqlite:////tmp/ai_software_factory_test.db
[loggers] [loggers]

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,7 +29,7 @@ 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 = ""
@@ -60,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."""
@@ -73,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}"
@@ -83,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}"
@@ -105,6 +141,21 @@ 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."""
@@ -177,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

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import closing from contextlib import closing
from html import escape
from nicegui import ui from nicegui import ui
@@ -10,12 +11,12 @@ try:
from .agents.database_manager import DatabaseManager from .agents.database_manager import DatabaseManager
from .agents.n8n_setup import N8NSetupAgent from .agents.n8n_setup import N8NSetupAgent
from .config import settings from .config import settings
from .database import get_db_sync, init_db from .database import get_database_runtime_summary, get_db_sync, init_db
except ImportError: except ImportError:
from agents.database_manager import DatabaseManager from agents.database_manager import DatabaseManager
from agents.n8n_setup import N8NSetupAgent from agents.n8n_setup import N8NSetupAgent
from config import settings from config import settings
from database import get_db_sync, init_db from database import get_database_runtime_summary, get_db_sync, init_db
def _resolve_n8n_api_url() -> str: def _resolve_n8n_api_url() -> str:
@@ -27,6 +28,30 @@ def _resolve_n8n_api_url() -> str:
return '' 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: def _load_dashboard_snapshot() -> dict:
"""Load dashboard data from the database.""" """Load dashboard data from the database."""
db = get_db_sync() db = get_db_sync()
@@ -41,8 +66,32 @@ def _load_dashboard_snapshot() -> dict:
return {'error': f'Database error: {exc}'} return {'error': f'Database error: {exc}'}
def create_dashboard(): def _load_n8n_health_snapshot() -> dict:
"""Create the main NiceGUI dashboard.""" """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( ui.add_head_html(
""" """
<style> <style>
@@ -57,6 +106,105 @@ def create_dashboard():
""" """
) )
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'),
]
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():
"""Create the main NiceGUI dashboard."""
_add_dashboard_styles()
async def setup_n8n_workflow_action() -> None: async def setup_n8n_workflow_action() -> None:
api_url = _resolve_n8n_api_url() api_url = _resolve_n8n_api_url()
if not api_url: if not api_url:
@@ -79,6 +227,8 @@ def create_dashboard():
message=result.get('message', str(result)), message=result.get('message', str(result)),
) )
if result.get('status') == 'error':
_render_n8n_error_dialog(result)
ui.notify(result.get('message', 'n8n setup finished'), color='positive' if result.get('status') == 'success' else 'negative') ui.notify(result.get('message', 'n8n setup finished'), color='positive' if result.get('status') == 'success' else 'negative')
dashboard_body.refresh() dashboard_body.refresh()
@@ -101,6 +251,14 @@ def create_dashboard():
projects = snapshot['projects'] projects = snapshot['projects']
correlations = snapshot['correlations'] correlations = snapshot['correlations']
system_logs = snapshot['system_logs'] system_logs = snapshot['system_logs']
project_repository_map = {
project_bundle['project']['project_id']: {
'project_name': project_bundle['project']['project_name'],
'repository': project_bundle.get('repository') or project_bundle['project'].get('repository'),
}
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.column().classes('factory-shell w-full gap-4 q-pa-lg'):
with ui.card().classes('factory-panel w-full q-pa-lg'): with ui.card().classes('factory-panel w-full q-pa-lg'):
@@ -126,11 +284,12 @@ def create_dashboard():
ui.label(str(value)).style('font-size: 2.1rem; font-weight: 800; margin-top: 6px;') 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;') ui.label(subtitle).style('font-size: 0.9rem; opacity: 0.78; margin-top: 8px;')
tabs = ui.tabs().classes('w-full') with ui.tabs().classes('w-full') as tabs:
overview_tab = ui.tab('Overview') overview_tab = ui.tab('Overview')
projects_tab = ui.tab('Projects') projects_tab = ui.tab('Projects')
trace_tab = ui.tab('Prompt Trace') trace_tab = ui.tab('Prompt Trace')
system_tab = ui.tab('System') system_tab = ui.tab('System')
health_tab = ui.tab('Health')
with ui.tab_panels(tabs, value=overview_tab).classes('w-full'): with ui.tab_panels(tabs, value=overview_tab).classes('w-full'):
with ui.tab_panel(overview_tab): with ui.tab_panel(overview_tab):
@@ -171,6 +330,10 @@ def create_dashboard():
project = project_bundle['project'] project = project_bundle['project']
with ui.expansion(f"{project['project_name']} · {project['status']}", icon='folder').classes('factory-panel w-full q-mb-md'): with ui.expansion(f"{project['project_name']} · {project['status']}", icon='folder').classes('factory-panel w-full q-mb-md'):
with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'): with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'):
with ui.card().classes('q-pa-md'):
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'): with ui.card().classes('q-pa-md'):
ui.label('Prompt').style('font-weight: 700; color: #3a281a;') ui.label('Prompt').style('font-weight: 700; color: #3a281a;')
prompts = project_bundle.get('prompts', []) prompts = project_bundle.get('prompts', [])
@@ -178,7 +341,7 @@ def create_dashboard():
prompt = prompts[0] prompt = prompts[0]
ui.markdown(f"**Requested features:** {', '.join(prompt['features']) or 'None'}") ui.markdown(f"**Requested features:** {', '.join(prompt['features']) or 'None'}")
ui.markdown(f"**Tech stack:** {', '.join(prompt['tech_stack']) or 'None'}") ui.markdown(f"**Tech stack:** {', '.join(prompt['tech_stack']) or 'None'}")
ui.element('div').classes('factory-code').set_text(prompt['prompt_text']) ui.label(prompt['prompt_text']).classes('factory-code')
else: else:
ui.label('No prompt recorded.').classes('factory-muted') ui.label('No prompt recorded.').classes('factory-muted')
@@ -219,9 +382,11 @@ def create_dashboard():
ui.label('Each prompt entry is linked to the generated files recorded after that prompt for the same project.').classes('factory-muted') ui.label('Each prompt entry is linked to the generated files recorded after that prompt for the same project.').classes('factory-muted')
if correlations: if correlations:
for correlation in correlations: for correlation in correlations:
correlation_project = project_repository_map.get(correlation['project_id'], {})
with ui.card().classes('q-pa-md q-mt-md'): with ui.card().classes('q-pa-md q-mt-md'):
ui.label(correlation['project_id']).style('font-size: 1rem; font-weight: 700; color: #2f241d;') ui.label(correlation_project.get('project_name') or correlation['project_id']).style('font-size: 1rem; font-weight: 700; color: #2f241d;')
ui.element('div').classes('factory-code q-mt-sm').set_text(correlation['prompt_text']) _render_repository_block(correlation_project.get('repository'))
ui.label(correlation['prompt_text']).classes('factory-code q-mt-sm')
if correlation['changes']: if correlation['changes']:
for change in correlation['changes']: for change in correlation['changes']:
ui.markdown( ui.markdown(
@@ -258,6 +423,13 @@ def create_dashboard():
for endpoint in endpoints: for endpoint in endpoints:
ui.label(endpoint).classes('factory-code q-mt-sm') ui.label(endpoint).classes('factory-code q-mt-sm')
with ui.tab_panel(health_tab):
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')
_render_health_panels()
dashboard_body() dashboard_body()
ui.timer(10.0, dashboard_body.refresh) ui.timer(10.0, dashboard_body.refresh)

View File

@@ -2,6 +2,7 @@
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
@@ -17,10 +18,31 @@ except ImportError:
from models import Base from models import Base
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: 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) Path(db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
db_url = f"sqlite:///{db_path}" db_url = f"sqlite:///{db_path}"
@@ -31,7 +53,7 @@ def get_engine() -> 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,
@@ -43,7 +65,7 @@ def get_engine() -> 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."""
@@ -100,7 +122,7 @@ def get_alembic_config(database_url: str | None = None) -> Config:
alembic_ini = package_root / "alembic.ini" alembic_ini = package_root / "alembic.ini"
config = Config(str(alembic_ini)) config = Config(str(alembic_ini))
config.set_main_option("script_location", str(package_root / "alembic")) config.set_main_option("script_location", str(package_root / "alembic"))
config.set_main_option("sqlalchemy.url", database_url or (settings.database_url if not settings.USE_SQLITE else f"sqlite:///{settings.SQLITE_DB_PATH or '/tmp/ai_software_factory_test.db'}")) config.set_main_option("sqlalchemy.url", database_url or settings.database_url)
return config return config
@@ -116,7 +138,7 @@ def run_migrations(database_url: str | None = None) -> dict:
def init_db() -> dict: def init_db() -> dict:
"""Initialize database tables and database if needed.""" """Initialize database tables and database if needed."""
if settings.USE_SQLITE: if settings.use_sqlite:
result = run_migrations() result = run_migrations()
if result["status"] == "success": if result["status"] == "success":
print("SQLite database migrations applied successfully.") print("SQLite database migrations applied successfully.")
@@ -131,7 +153,7 @@ def init_db() -> dict:
return {'status': 'error', 'message': f'Error: {str(e)}'} return {'status': 'error', 'message': f'Error: {str(e)}'}
else: else:
# PostgreSQL # PostgreSQL
db_url = settings.POSTGRES_URL or settings.database_url db_url = settings.database_url
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory' db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
try: try:
@@ -180,7 +202,7 @@ def init_db() -> dict:
def drop_db() -> dict: def drop_db() -> dict:
"""Drop all database tables (use with caution!).""" """Drop all database tables (use with caution!)."""
if settings.USE_SQLITE: if settings.use_sqlite:
engine = get_engine() engine = get_engine()
try: try:
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
@@ -190,7 +212,7 @@ def drop_db() -> dict:
print(f"Error dropping SQLite tables: {str(e)}") print(f"Error dropping SQLite tables: {str(e)}")
return {'status': 'error', 'message': str(e)} return {'status': 'error', 'message': str(e)}
else: else:
db_url = settings.POSTGRES_URL or settings.database_url db_url = settings.database_url
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory' db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
try: try:

View File

@@ -5,13 +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
try: try:
from .dashboard_ui import create_dashboard from .dashboard_ui import create_dashboard, create_health_page
except ImportError: except ImportError:
from dashboard_ui import create_dashboard 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:
@@ -22,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,13 +6,14 @@ 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
""" """
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager
import json import json
import re import re
from pathlib import Path from pathlib import Path
@@ -42,7 +43,18 @@ except ImportError:
__version__ = "0.0.1" __version__ = "0.0.1"
app = FastAPI()
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""Log resolved runtime configuration when the app starts."""
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)] DbSession = Annotated[Session, Depends(database_module.get_db)]
PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+") PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+")
@@ -149,14 +161,15 @@ def _resolve_n8n_api_url(explicit_url: str | None = None) -> str:
return "" return ""
@app.get('/') @app.get('/api')
def read_root(): def read_api_info():
"""Root endpoint that returns service metadata.""" """Return service metadata for API clients."""
return { return {
'service': 'AI Software Factory', 'service': 'AI Software Factory',
'version': __version__, 'version': __version__,
'endpoints': [ 'endpoints': [
'/', '/',
'/api',
'/health', '/health',
'/generate', '/generate',
'/projects', '/projects',
@@ -177,9 +190,12 @@ def read_root():
@app.get('/health') @app.get('/health')
def health_check(): def health_check():
"""Health check endpoint.""" """Health check endpoint."""
runtime = database_module.get_database_runtime_summary()
return { return {
'status': 'healthy', 'status': 'healthy',
'database': 'sqlite' if database_module.settings.USE_SQLITE else 'postgresql', 'database': runtime['backend'],
'database_target': runtime['target'],
'database_name': runtime['database'],
} }
@@ -217,6 +233,7 @@ async def generate_software(request: SoftwareRequest, db: DbSession):
response_data['tech_stack'] = request.tech_stack response_data['tech_stack'] = request.tech_stack
response_data['project_root'] = result.get('project_root', str(_project_root(project_id))) response_data['project_root'] = result.get('project_root', str(_project_root(project_id)))
response_data['changed_files'] = result.get('changed_files', []) response_data['changed_files'] = result.get('changed_files', [])
response_data['repository'] = result.get('repository')
return {'status': result['status'], 'data': response_data} return {'status': result['status'], 'data': response_data}
@@ -306,10 +323,16 @@ async def get_n8n_health():
"""Check whether the configured n8n instance is reachable.""" """Check whether the configured n8n instance is reachable."""
api_url = _resolve_n8n_api_url() api_url = _resolve_n8n_api_url()
if not api_url: if not api_url:
return {'status': 'error', 'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured'} 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) agent = N8NSetupAgent(api_url=api_url, webhook_token=database_module.settings.n8n_api_key)
result = await agent.health_check() return await agent.health_check()
return {'status': 'ok' if not result.get('error') else 'error', 'data': result}
@app.post('/n8n/setup') @app.post('/n8n/setup')