Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2829caa02 | |||
| d4b280cf75 | |||
| 806db8537b | |||
| 360ed5c6f3 | |||
| 4b9eb2f359 | |||
| ebfcfb969a | |||
| 56b05eb686 | |||
| 59a7e9787e | |||
| a357a307a7 | |||
| af4247e657 | |||
| 227ad1ad6f | |||
| 82e53a6651 | |||
| e9dc1ede55 |
@@ -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
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
sqlite.db
|
sqlite.db
|
||||||
|
.nicegui/
|
||||||
@@ -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"]
|
||||||
|
|||||||
43
HISTORY.md
43
HISTORY.md
@@ -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
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -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'
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.4.0
|
0.6.2
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)}
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "Hello world"
|
|
||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user