chore: install frontend dependencies.

This commit is contained in:
2026-03-12 17:00:42 -04:00
commit 18c8815166
63 changed files with 14100 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# ─── Database ──────────────────────────────────────────────
DATABASE_URL=sqlite+aiosqlite:///./foxy_dev_team.db
# ─── Telegram ──────────────────────────────────────────────
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
TELEGRAM_CHAT_ID=your-chat-id
# ─── OpenClaw ──────────────────────────────────────────────
OPENCLAW_WORKSPACE=/home/openclaw/.openclaw/workspace
# ─── Gitea ─────────────────────────────────────────────────
GITEA_SERVER=https://gitea.your.server
GITEA_OPENCLAW_TOKEN=your-gitea-token
# ─── Deployment ────────────────────────────────────────────
DEPLOYMENT_SERVER=your.server.com
DEPLOYMENT_USER=deploy
DEPLOYMENT_PWD=your-deployment-password
# ─── App ───────────────────────────────────────────────────
LOG_LEVEL=info
CORS_ORIGINS=["http://localhost:5173"]
View File
+44
View File
@@ -0,0 +1,44 @@
"""
Application settings loaded from .env file.
"""
from pydantic_settings import BaseSettings
from typing import List
import json
class Settings(BaseSettings):
# Database
DATABASE_URL: str = "sqlite+aiosqlite:///./foxy_dev_team.db"
# Telegram
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = ""
# OpenClaw
OPENCLAW_WORKSPACE: str = "/home/openclaw/.openclaw/workspace"
# Gitea
GITEA_SERVER: str = ""
GITEA_OPENCLAW_TOKEN: str = ""
# Deployment
DEPLOYMENT_SERVER: str = ""
DEPLOYMENT_USER: str = ""
DEPLOYMENT_PWD: str = ""
# App
LOG_LEVEL: str = "info"
CORS_ORIGINS: str = '["http://localhost:5173"]'
@property
def cors_origins_list(self) -> List[str]:
try:
return json.loads(self.CORS_ORIGINS)
except (json.JSONDecodeError, TypeError):
return ["http://localhost:5173"]
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
+37
View File
@@ -0,0 +1,37 @@
"""
Async SQLAlchemy engine and session factory.
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
"""FastAPI dependency that yields an async DB session."""
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def init_db():
"""Create all tables on startup."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+88
View File
@@ -0,0 +1,88 @@
"""
🦊 Foxy Dev Team — Backend API v2.0
FastAPI application with WebSocket support for real-time dashboard.
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import init_db
from app.routers import projects, agents, logs, workflows, config
from app.routers.ws import manager
# ─── Logging ───────────────────────────────────────────────────────────────────
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
format="[%(asctime)s] %(levelname)s %(name)s — %(message)s",
datefmt="%Y-%m-%dT%H:%M:%SZ",
)
log = logging.getLogger("foxy.main")
# ─── Lifespan ──────────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("🦊 Foxy Dev Team API v2.0 — Starting...")
await init_db()
log.info("✅ Database initialized")
yield
log.info("🛑 Foxy Dev Team API — Shutting down")
# ─── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(
title="🦊 Foxy Dev Team API",
description="Backend API for the Foxy Dev Team multi-agent orchestration system",
version="2.0.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Routers ───────────────────────────────────────────────────────────────────
app.include_router(projects.router)
app.include_router(agents.router)
app.include_router(logs.router)
app.include_router(workflows.router)
app.include_router(config.router)
# ─── WebSocket ─────────────────────────────────────────────────────────────────
@app.websocket("/ws/live")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
# Keep connection alive; client-initiated messages can be handled here
data = await websocket.receive_text()
# Echo ping/pong or handle subscriptions
if data == "ping":
await websocket.send_text('{"type":"pong"}')
except WebSocketDisconnect:
await manager.disconnect(websocket)
except Exception:
await manager.disconnect(websocket)
# ─── Health ────────────────────────────────────────────────────────────────────
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "2.0.0", "service": "foxy-dev-team"}
+174
View File
@@ -0,0 +1,174 @@
"""
SQLAlchemy ORM models for Foxy Dev Team.
"""
import enum
from datetime import datetime, timezone
from typing import Optional, List
from sqlalchemy import (
String, Text, Integer, DateTime, ForeignKey, Enum, JSON
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
# ─── Enums ─────────────────────────────────────────────────────────────────────
class ProjectStatus(str, enum.Enum):
PENDING = "PENDING"
AWAITING_CONDUCTOR = "AWAITING_CONDUCTOR"
CONDUCTOR_RUNNING = "CONDUCTOR_RUNNING"
AWAITING_ARCHITECT = "AWAITING_ARCHITECT"
ARCHITECT_RUNNING = "ARCHITECT_RUNNING"
AWAITING_DEV = "AWAITING_DEV"
DEV_RUNNING = "DEV_RUNNING"
AWAITING_UIUX = "AWAITING_UIUX"
UIUX_RUNNING = "UIUX_RUNNING"
AWAITING_QA = "AWAITING_QA"
QA_RUNNING = "QA_RUNNING"
AWAITING_DEPLOY = "AWAITING_DEPLOY"
DEPLOY_RUNNING = "DEPLOY_RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
PAUSED = "PAUSED"
class WorkflowType(str, enum.Enum):
SOFTWARE_DESIGN = "SOFTWARE_DESIGN"
SYSADMIN_DEBUG = "SYSADMIN_DEBUG"
DEVOPS_SETUP = "DEVOPS_SETUP"
SYSADMIN_ADJUST = "SYSADMIN_ADJUST"
class TaskStatus(str, enum.Enum):
PENDING = "PENDING"
IN_PROGRESS = "IN_PROGRESS"
IN_REVIEW = "IN_REVIEW"
REJECTED = "REJECTED"
READY_FOR_DEPLOY = "READY_FOR_DEPLOY"
DONE = "DONE"
BLOCKED = "BLOCKED"
class TaskType(str, enum.Enum):
BACKEND = "BACKEND"
FRONTEND = "FRONTEND"
INFRA = "INFRA"
TEST = "TEST"
DESIGN = "DESIGN"
class TaskPriority(str, enum.Enum):
P1 = "P1"
P2 = "P2"
P3 = "P3"
class AgentExecutionStatus(str, enum.Enum):
RUNNING = "RUNNING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
TIMEOUT = "TIMEOUT"
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Models ────────────────────────────────────────────────────────────────────
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False, index=True)
description: Mapped[str] = mapped_column(Text, default="")
status: Mapped[ProjectStatus] = mapped_column(
Enum(ProjectStatus), default=ProjectStatus.PENDING, nullable=False
)
workflow_type: Mapped[WorkflowType] = mapped_column(
Enum(WorkflowType), default=WorkflowType.SOFTWARE_DESIGN, nullable=False
)
test_mode: Mapped[bool] = mapped_column(default=False)
gitea_repo: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
deployment_target: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
)
# Relationships
tasks: Mapped[List["Task"]] = relationship(
back_populates="project", cascade="all, delete-orphan", lazy="selectin"
)
audit_logs: Mapped[List["AuditLog"]] = relationship(
back_populates="project", cascade="all, delete-orphan", lazy="selectin"
)
agent_executions: Mapped[List["AgentExecution"]] = relationship(
back_populates="project", cascade="all, delete-orphan", lazy="selectin"
)
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False)
task_id: Mapped[str] = mapped_column(String(50), nullable=False) # e.g. TASK-001
type: Mapped[TaskType] = mapped_column(Enum(TaskType), default=TaskType.BACKEND)
title: Mapped[str] = mapped_column(String(500), nullable=False)
priority: Mapped[TaskPriority] = mapped_column(Enum(TaskPriority), default=TaskPriority.P3)
assigned_to: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
status: Mapped[TaskStatus] = mapped_column(
Enum(TaskStatus), default=TaskStatus.PENDING, nullable=False
)
dependencies: Mapped[Optional[dict]] = mapped_column(JSON, default=list)
acceptance_criteria: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
agent_payloads: Mapped[Optional[dict]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
)
# Relationships
project: Mapped["Project"] = relationship(back_populates="tasks")
class AgentExecution(Base):
__tablename__ = "agent_executions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False)
agent_name: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[AgentExecutionStatus] = mapped_column(
Enum(AgentExecutionStatus), default=AgentExecutionStatus.RUNNING
)
pid: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
exit_code: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
error_output: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Relationships
project: Mapped["Project"] = relationship(back_populates="agent_executions")
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
agent: Mapped[str] = mapped_column(String(100), nullable=False)
action: Mapped[str] = mapped_column(String(100), nullable=False)
target: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(100), default="api")
# Relationships
project: Mapped["Project"] = relationship(back_populates="audit_logs")
+68
View File
@@ -0,0 +1,68 @@
"""
Async Telegram notification service.
Replaces the synchronous urllib-based notify() from foxy-autopilot.py.
"""
import logging
import httpx
from app.config import settings
log = logging.getLogger("foxy.notifications")
async def send_telegram(message: str) -> bool:
"""Send a message to the configured Telegram chat."""
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
log.warning("Telegram not configured — skipping notification")
return False
url = f"https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {
"chat_id": settings.TELEGRAM_CHAT_ID,
"text": message,
"parse_mode": "HTML",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, data=payload)
if resp.status_code == 200:
return True
log.warning(f"Telegram API returned {resp.status_code}: {resp.text[:200]}")
return False
except Exception as e:
log.warning(f"Telegram notification error (ignored): {e}")
return False
async def notify_project_event(
project_name: str,
event: str,
details: str = "",
agent: str = "",
) -> bool:
"""Send a formatted project event notification."""
msg_parts = [f"🦊 <b>Foxy Dev Team</b>", f"📋 <b>{project_name}</b>"]
if agent:
msg_parts.append(f"🤖 {agent}")
msg_parts.append(f"📊 {event}")
if details:
msg_parts.append(f"ℹ️ {details}")
return await send_telegram("\n".join(msg_parts))
async def notify_error(
project_name: str,
agent_name: str,
error: str,
) -> bool:
"""Send an error notification."""
return await send_telegram(
f"🦊 ⚠️ <b>Erreur</b>\n"
f"📋 {project_name}\n"
f"❌ {agent_name}\n"
f"<code>{error[:300]}</code>"
)
+201
View File
@@ -0,0 +1,201 @@
"""
OpenClaw CLI integration — async wrapper for spawning agents.
Replaces the synchronous subprocess-based spawn_agent() from foxy-autopilot.py.
"""
import asyncio
import logging
import platform
from typing import Optional
from app.config import settings
log = logging.getLogger("foxy.openclaw")
# ─── Agent Labels ──────────────────────────────────────────────────────────────
AGENT_LABELS = {
"Foxy-Conductor": "foxy-conductor",
"Foxy-Architect": "foxy-architect",
"Foxy-Dev": "foxy-dev",
"Foxy-UIUX": "foxy-uiux",
"Foxy-QA": "foxy-qa",
"Foxy-Admin": "foxy-admin",
}
# ─── Spawn Command Detection ──────────────────────────────────────────────────
_detected_command: Optional[list[str]] = None
async def _run_help(args: list[str], timeout: int = 8) -> str:
"""Run a help command and return its output."""
try:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return (stdout.decode() + stderr.decode()).lower()
except (asyncio.TimeoutError, FileNotFoundError):
return ""
except Exception:
return ""
async def detect_openclaw_syntax() -> Optional[list[str]]:
"""
Auto-detect the correct openclaw CLI syntax for spawning agents.
Returns a template list like ["openclaw", "agent", "--agent", "{agent}", "--task", "{task}"]
"""
global _detected_command
candidates = [
(
["openclaw", "agent", "--help"],
["openclaw", "agent", "--agent", "{agent}", "--task", "{task}"],
["agent", "task"],
),
(
["openclaw", "agent", "--help"],
["openclaw", "agent", "--agent", "{agent}", "--message", "{task}"],
["agent", "message"],
),
(
["openclaw", "agents", "run", "--help"],
["openclaw", "agents", "run", "--agent", "{agent}", "--task", "{task}"],
["agent", "task"],
),
(
["openclaw", "agents", "spawn", "--help"],
["openclaw", "agents", "spawn", "--agent", "{agent}", "--task", "{task}"],
["agent", "task"],
),
]
for help_cmd, spawn_template, keywords in candidates:
output = await _run_help(help_cmd)
if not output:
continue
if all(kw in output for kw in keywords):
log.info(f"OpenClaw syntax detected: {' '.join(spawn_template[:5])}")
_detected_command = spawn_template
return spawn_template
log.warning("No known openclaw syntax detected")
return None
def build_spawn_cmd(template: list[str], agent_label: str, task_msg: str) -> list[str]:
"""Build the actual command by replacing placeholders."""
return [
t.replace("{agent}", agent_label).replace("{task}", task_msg)
for t in template
]
# ─── Agent Spawning ───────────────────────────────────────────────────────────
async def spawn_agent(
agent_name: str,
task_message: str,
) -> Optional[asyncio.subprocess.Process]:
"""
Spawn an OpenClaw agent asynchronously.
Returns the Process object if successful, None on failure.
"""
global _detected_command
if _detected_command is None:
_detected_command = await detect_openclaw_syntax()
if _detected_command is None:
log.error("Cannot spawn agent — openclaw syntax not detected")
return None
agent_label = AGENT_LABELS.get(agent_name, agent_name.lower().replace(" ", "-"))
cmd = build_spawn_cmd(_detected_command, agent_label, task_message)
log.info(f"Spawning {agent_name} (label: {agent_label})")
cmd_display = " ".join(c if len(c) < 50 else c[:47] + "..." for c in cmd)
log.info(f"CMD: {cmd_display}")
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=settings.OPENCLAW_WORKSPACE,
)
# Wait briefly to check for immediate failure
await asyncio.sleep(3)
if proc.returncode is not None and proc.returncode != 0:
stderr = await proc.stderr.read()
err_msg = stderr.decode()[:400]
log.error(f"Agent spawn failed immediately (code {proc.returncode}): {err_msg}")
return None
log.info(f"Agent {agent_name} spawned (PID: {proc.pid})")
return proc
except FileNotFoundError:
log.error("'openclaw' not found in PATH")
return None
except Exception as e:
log.error(f"Error spawning {agent_name}: {e}")
return None
def build_task_for_agent(
agent_name: str,
project_name: str,
project_slug: str,
description: str,
test_mode: bool = False,
) -> str:
"""Build the task/message string sent to an agent via OpenClaw."""
base = (
f"Tu es {agent_name}. "
f"Projet actif : {project_name} (slug: {project_slug}). "
f"{'MODE TEST : simule ton travail sans produire de code réel. ' if test_mode else ''}"
f"Connecte-toi à l'API Foxy Dev Team pour lire l'état du projet et mettre à jour tes résultats. "
)
instructions = {
"Foxy-Conductor": (
base
+ "MISSION : Analyse la description du projet. "
+ "Crée les tâches initiales, puis change le statut à l'étape suivante du workflow. "
),
"Foxy-Architect": (
base
+ "MISSION : Produis l'architecture technique (ADR), "
+ "découpe en tickets avec assigned_to, acceptance_criteria, depends_on. "
),
"Foxy-Dev": (
base
+ "MISSION : Prends les tâches PENDING assignées à toi. "
+ "Écris le code, commit sur branche task/TASK-XXX via Gitea. "
),
"Foxy-UIUX": (
base
+ "MISSION : Prends les tâches UI/PENDING assignées à toi. "
+ "Crée les composants React/TypeScript, commit sur branche task/TASK-XXX-ui. "
),
"Foxy-QA": (
base
+ "MISSION : Audite toutes les tâches IN_REVIEW. "
+ "Approuve ou rejette avec feedback détaillé. "
),
"Foxy-Admin": (
base
+ "MISSION : Déploie toutes les tâches READY_FOR_DEPLOY. "
+ "Backup avant déploiement. Génère le rapport final si tout est DONE. "
),
}
return instructions.get(agent_name, base + "Exécute ta mission.")
View File
+98
View File
@@ -0,0 +1,98 @@
"""
Agent status and history API endpoints.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import AgentExecution, AgentExecutionStatus
from app.schemas import AgentStatus, AgentExecutionResponse
from app.workflows import AGENT_LABELS, AGENT_MODELS
log = logging.getLogger("foxy.api.agents")
router = APIRouter(prefix="/api/agents", tags=["agents"])
@router.get("", response_model=list[AgentStatus])
async def list_agents(db: AsyncSession = Depends(get_db)):
"""List all agents with their current status and stats."""
agents = []
for display_name, label in AGENT_LABELS.items():
# Count executions
total_q = await db.execute(
select(func.count(AgentExecution.id))
.where(AgentExecution.agent_name == display_name)
)
total = total_q.scalar() or 0
success_q = await db.execute(
select(func.count(AgentExecution.id))
.where(AgentExecution.agent_name == display_name)
.where(AgentExecution.status == AgentExecutionStatus.SUCCESS)
)
success = success_q.scalar() or 0
failure_q = await db.execute(
select(func.count(AgentExecution.id))
.where(AgentExecution.agent_name == display_name)
.where(AgentExecution.status == AgentExecutionStatus.FAILED)
)
failure = failure_q.scalar() or 0
# Check if currently running
running_q = await db.execute(
select(AgentExecution)
.where(AgentExecution.agent_name == display_name)
.where(AgentExecution.status == AgentExecutionStatus.RUNNING)
.limit(1)
)
running_exec = running_q.scalar_one_or_none()
current_status = "running" if running_exec else "idle"
current_project = None
if running_exec:
current_project = str(running_exec.project_id)
agents.append(AgentStatus(
name=label,
display_name=display_name,
model=AGENT_MODELS.get(display_name, "unknown"),
current_status=current_status,
current_project=current_project,
total_executions=total,
success_count=success,
failure_count=failure,
))
return agents
@router.get("/{agent_name}/history", response_model=list[AgentExecutionResponse])
async def get_agent_history(
agent_name: str,
limit: int = Query(50, le=200),
db: AsyncSession = Depends(get_db),
):
"""Get execution history for a specific agent."""
# Map label to display name
display_name = None
for dn, label in AGENT_LABELS.items():
if label == agent_name or dn == agent_name:
display_name = dn
break
if not display_name:
display_name = agent_name
result = await db.execute(
select(AgentExecution)
.where(AgentExecution.agent_name == display_name)
.order_by(AgentExecution.started_at.desc())
.limit(limit)
)
return result.scalars().all()
+45
View File
@@ -0,0 +1,45 @@
"""
Configuration management API endpoint.
"""
import logging
from fastapi import APIRouter
from app.config import settings
from app.schemas import ConfigResponse, ConfigUpdate
log = logging.getLogger("foxy.api.config")
router = APIRouter(prefix="/api/config", tags=["config"])
@router.get("", response_model=ConfigResponse)
async def get_config():
"""Get current configuration (secrets masked)."""
return ConfigResponse(
OPENCLAW_WORKSPACE=settings.OPENCLAW_WORKSPACE,
GITEA_SERVER=settings.GITEA_SERVER,
DEPLOYMENT_SERVER=settings.DEPLOYMENT_SERVER,
DEPLOYMENT_USER=settings.DEPLOYMENT_USER,
TELEGRAM_CHAT_ID=settings.TELEGRAM_CHAT_ID,
LOG_LEVEL=settings.LOG_LEVEL,
GITEA_OPENCLAW_TOKEN="***" if settings.GITEA_OPENCLAW_TOKEN else "",
DEPLOYMENT_PWD="***" if settings.DEPLOYMENT_PWD else "",
TELEGRAM_BOT_TOKEN="***" if settings.TELEGRAM_BOT_TOKEN else "",
)
@router.put("")
async def update_config(body: ConfigUpdate):
"""
Update non-sensitive configuration values.
Note: In production, this would persist to .env file or a config store.
For now, it updates the in-memory settings.
"""
updated = {}
for field, value in body.model_dump(exclude_unset=True).items():
if value is not None:
setattr(settings, field, value)
updated[field] = value
log.info(f"Config updated: {list(updated.keys())}")
return {"message": "Configuration updated", "updated_fields": list(updated.keys())}
+42
View File
@@ -0,0 +1,42 @@
"""
Audit log and streaming endpoints.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import AuditLog
from app.schemas import AuditLogResponse
log = logging.getLogger("foxy.api.logs")
router = APIRouter(prefix="/api/logs", tags=["logs"])
@router.get("", response_model=list[AuditLogResponse])
async def list_logs(
project_id: Optional[int] = Query(None),
agent: Optional[str] = Query(None),
action: Optional[str] = Query(None),
limit: int = Query(100, le=500),
offset: int = Query(0),
db: AsyncSession = Depends(get_db),
):
"""List audit logs with optional filters."""
query = select(AuditLog).order_by(AuditLog.timestamp.desc())
if project_id:
query = query.where(AuditLog.project_id == project_id)
if agent:
query = query.where(AuditLog.agent == agent)
if action:
query = query.where(AuditLog.action == action)
query = query.limit(limit).offset(offset)
result = await db.execute(query)
return result.scalars().all()
+290
View File
@@ -0,0 +1,290 @@
"""
Project management API endpoints.
"""
import re
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import (
Project, Task, AuditLog, AgentExecution,
ProjectStatus, WorkflowType, AgentExecutionStatus,
)
from app.schemas import (
ProjectCreate, ProjectUpdate, ProjectSummary, ProjectDetail,
TaskCreate, TaskResponse,
)
from app.workflows import (
get_workflow_steps, get_current_step, get_workflow_progress,
)
from app.notifications import notify_project_event
from app.routers.ws import manager
log = logging.getLogger("foxy.api.projects")
router = APIRouter(prefix="/api/projects", tags=["projects"])
def _slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
return f"{slug}-{ts}" if slug else f"proj-{ts}"
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── CRUD ──────────────────────────────────────────────────────────────────────
@router.post("", response_model=ProjectDetail, status_code=201)
async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)):
"""Create a new project and initialize its workflow."""
slug = _slugify(body.name)
project = Project(
name=body.name,
slug=slug,
description=body.description,
status=ProjectStatus.AWAITING_CONDUCTOR,
workflow_type=body.workflow_type,
test_mode=body.test_mode,
)
db.add(project)
await db.flush()
audit = AuditLog(
project_id=project.id,
agent="system",
action="PROJECT_CREATED",
target=slug,
message=f"Projet créé: {body.name} (workflow: {body.workflow_type.value})",
source="api",
)
db.add(audit)
await db.flush()
# Refresh to load eager relationships (tasks, audit_logs, agent_executions)
await db.refresh(project, ["tasks", "audit_logs", "agent_executions"])
log.info(f"Project created: {slug} (workflow: {body.workflow_type.value})")
await manager.broadcast_project_update(project.id, project.status.value, project.name)
await notify_project_event(project.name, "Projet créé", f"Workflow: {body.workflow_type.value}")
return project
@router.get("", response_model=list[ProjectSummary])
async def list_projects(
status: Optional[str] = Query(None),
workflow_type: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
"""List all projects with optional filters."""
query = select(Project).order_by(Project.updated_at.desc())
if status:
try:
query = query.where(Project.status == ProjectStatus(status))
except ValueError:
pass
if workflow_type:
try:
query = query.where(Project.workflow_type == WorkflowType(workflow_type))
except ValueError:
pass
result = await db.execute(query)
projects = result.scalars().all()
summaries = []
for p in projects:
total = len(p.tasks)
done = sum(1 for t in p.tasks if t.status.value in ("DONE", "READY_FOR_DEPLOY"))
summaries.append(ProjectSummary(
id=p.id,
name=p.name,
slug=p.slug,
status=p.status,
workflow_type=p.workflow_type,
test_mode=p.test_mode,
task_count=total,
tasks_done=done,
created_at=p.created_at,
updated_at=p.updated_at,
))
return summaries
@router.get("/{project_id}", response_model=ProjectDetail)
async def get_project(project_id: int, db: AsyncSession = Depends(get_db)):
"""Get project detail with tasks, audit logs, and agent executions."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
# ─── Workflow Control ──────────────────────────────────────────────────────────
@router.post("/{project_id}/start")
async def start_project(project_id: int, db: AsyncSession = Depends(get_db)):
"""Start or resume a project's workflow."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.status in (ProjectStatus.COMPLETED, ProjectStatus.FAILED):
raise HTTPException(status_code=400, detail=f"Cannot start a {project.status.value} project")
# If paused, resume to previous awaiting status
if project.status == ProjectStatus.PAUSED:
steps = get_workflow_steps(project.workflow_type)
project.status = ProjectStatus(steps[0].awaiting_status)
elif project.status == ProjectStatus.PENDING:
project.status = ProjectStatus.AWAITING_CONDUCTOR
project.updated_at = _utcnow()
audit = AuditLog(
project_id=project.id,
agent="system",
action="WORKFLOW_STARTED",
target=project.slug,
message=f"Workflow démarré → {project.status.value}",
source="api",
)
db.add(audit)
await db.flush()
await manager.broadcast_project_update(project.id, project.status.value, project.name)
await notify_project_event(project.name, f"Workflow démarré → {project.status.value}")
return {"status": project.status.value, "message": "Workflow started"}
@router.post("/{project_id}/pause")
async def pause_project(project_id: int, db: AsyncSession = Depends(get_db)):
"""Pause a project's workflow."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
old_status = project.status.value
project.status = ProjectStatus.PAUSED
project.updated_at = _utcnow()
audit = AuditLog(
project_id=project.id,
agent="system",
action="WORKFLOW_PAUSED",
target=project.slug,
message=f"Workflow mis en pause (était: {old_status})",
source="api",
)
db.add(audit)
await db.flush()
await manager.broadcast_project_update(project.id, project.status.value, project.name)
return {"status": "PAUSED", "message": f"Project paused (was {old_status})"}
@router.post("/{project_id}/stop")
async def stop_project(project_id: int, db: AsyncSession = Depends(get_db)):
"""Stop a project — marks it as FAILED."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
old_status = project.status.value
project.status = ProjectStatus.FAILED
project.updated_at = _utcnow()
audit = AuditLog(
project_id=project.id,
agent="system",
action="WORKFLOW_STOPPED",
target=project.slug,
message=f"Workflow arrêté (était: {old_status})",
source="api",
)
db.add(audit)
await db.flush()
await manager.broadcast_project_update(project.id, project.status.value, project.name)
await notify_project_event(project.name, "Projet arrêté", f"Ancien statut: {old_status}")
return {"status": "FAILED", "message": "Project stopped"}
@router.post("/{project_id}/reset")
async def reset_project(project_id: int, db: AsyncSession = Depends(get_db)):
"""Reset a project back to AWAITING_CONDUCTOR."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
old_status = project.status.value
project.status = ProjectStatus.AWAITING_CONDUCTOR
project.updated_at = _utcnow()
audit = AuditLog(
project_id=project.id,
agent="system",
action="WORKFLOW_RESET",
target=project.slug,
message=f"Workflow reset (était: {old_status}) → AWAITING_CONDUCTOR",
source="api",
)
db.add(audit)
await db.flush()
await manager.broadcast_project_update(project.id, project.status.value, project.name)
await notify_project_event(project.name, "Reset", f"{old_status} → AWAITING_CONDUCTOR")
return {"status": "AWAITING_CONDUCTOR", "message": "Project reset"}
@router.delete("/{project_id}")
async def delete_project(project_id: int, db: AsyncSession = Depends(get_db)):
"""Delete a project and all associated data."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
name = project.name
await db.delete(project)
await db.flush()
log.info(f"Project deleted: {name}")
return {"message": f"Project '{name}' deleted"}
# ─── Progress & Workflow Info ──────────────────────────────────────────────────
@router.get("/{project_id}/progress")
async def get_project_progress(project_id: int, db: AsyncSession = Depends(get_db)):
"""Get workflow progress for a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
progress = get_workflow_progress(project.workflow_type, project.status.value)
return progress
+37
View File
@@ -0,0 +1,37 @@
"""
Workflow definitions and execution endpoint.
"""
import logging
from fastapi import APIRouter
from app.workflows import (
WORKFLOW_REGISTRY, get_workflow_steps,
AGENT_LABELS, AGENT_MODELS,
)
from app.models import WorkflowType
log = logging.getLogger("foxy.api.workflows")
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
@router.get("")
async def list_workflows():
"""List all available workflow types with their step sequences."""
workflows = []
for wf_type, steps in WORKFLOW_REGISTRY.items():
workflows.append({
"type": wf_type.value,
"label": wf_type.value.replace("_", " ").title(),
"steps": [
{
"agent": s.agent_name,
"label": AGENT_LABELS.get(s.agent_name, s.agent_name),
"model": AGENT_MODELS.get(s.agent_name, "unknown"),
"awaiting_status": s.awaiting_status,
"running_status": s.running_status,
}
for s in steps
],
})
return workflows
+68
View File
@@ -0,0 +1,68 @@
"""
WebSocket hub for real-time updates: agent status, logs, project state changes.
"""
import asyncio
import json
import logging
from typing import Set
from fastapi import WebSocket, WebSocketDisconnect
log = logging.getLogger("foxy.ws")
class ConnectionManager:
"""Manages active WebSocket connections and broadcasts messages."""
def __init__(self):
self.active_connections: Set[WebSocket] = set()
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket):
await websocket.accept()
async with self._lock:
self.active_connections.add(websocket)
log.info(f"WebSocket connected ({len(self.active_connections)} active)")
async def disconnect(self, websocket: WebSocket):
async with self._lock:
self.active_connections.discard(websocket)
log.info(f"WebSocket disconnected ({len(self.active_connections)} active)")
async def broadcast(self, message_type: str, data: dict):
"""Broadcast a message to all connected clients."""
payload = json.dumps({"type": message_type, "data": data})
async with self._lock:
dead = set()
for ws in self.active_connections:
try:
await ws.send_text(payload)
except Exception:
dead.add(ws)
self.active_connections -= dead
async def broadcast_agent_status(self, agent_name: str, status: str, project: str = ""):
await self.broadcast("agent_status", {
"agent": agent_name,
"status": status,
"project": project,
})
async def broadcast_log(self, agent: str, message: str, level: str = "info"):
await self.broadcast("log", {
"agent": agent,
"message": message,
"level": level,
})
async def broadcast_project_update(self, project_id: int, status: str, name: str = ""):
await self.broadcast("project_update", {
"project_id": project_id,
"status": status,
"name": name,
})
# Singleton
manager = ConnectionManager()
+172
View File
@@ -0,0 +1,172 @@
"""
Pydantic schemas for API request/response serialization.
"""
from datetime import datetime
from typing import Optional, List, Any
from pydantic import BaseModel, Field
from app.models import (
ProjectStatus, WorkflowType, TaskStatus, TaskType,
TaskPriority, AgentExecutionStatus,
)
# ─── Task Schemas ──────────────────────────────────────────────────────────────
class TaskCreate(BaseModel):
task_id: str = Field(..., examples=["TASK-001"])
type: TaskType = TaskType.BACKEND
title: str
priority: TaskPriority = TaskPriority.P3
assigned_to: Optional[str] = None
dependencies: List[str] = []
acceptance_criteria: Optional[str] = None
class TaskResponse(BaseModel):
id: int
project_id: int
task_id: str
type: TaskType
title: str
priority: TaskPriority
assigned_to: Optional[str]
status: TaskStatus
dependencies: Any
acceptance_criteria: Optional[str]
agent_payloads: Any
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ─── AuditLog Schemas ─────────────────────────────────────────────────────────
class AuditLogResponse(BaseModel):
id: int
project_id: int
timestamp: datetime
agent: str
action: str
target: Optional[str]
message: Optional[str]
source: str
model_config = {"from_attributes": True}
# ─── AgentExecution Schemas ────────────────────────────────────────────────────
class AgentExecutionResponse(BaseModel):
id: int
project_id: int
agent_name: str
status: AgentExecutionStatus
pid: Optional[int]
started_at: datetime
finished_at: Optional[datetime]
exit_code: Optional[int]
error_output: Optional[str]
model_config = {"from_attributes": True}
# ─── Project Schemas ───────────────────────────────────────────────────────────
class ProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: str = ""
workflow_type: WorkflowType = WorkflowType.SOFTWARE_DESIGN
test_mode: bool = False
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
workflow_type: Optional[WorkflowType] = None
class ProjectSummary(BaseModel):
id: int
name: str
slug: str
status: ProjectStatus
workflow_type: WorkflowType
test_mode: bool
task_count: int = 0
tasks_done: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ProjectDetail(BaseModel):
id: int
name: str
slug: str
description: str
status: ProjectStatus
workflow_type: WorkflowType
test_mode: bool
gitea_repo: Optional[str]
deployment_target: Optional[str]
created_at: datetime
updated_at: datetime
tasks: List[TaskResponse] = []
audit_logs: List[AuditLogResponse] = []
agent_executions: List[AgentExecutionResponse] = []
model_config = {"from_attributes": True}
# ─── Agent Schemas ─────────────────────────────────────────────────────────────
class AgentStatus(BaseModel):
name: str
display_name: str
model: str
current_status: str = "idle" # idle | running | failed
current_project: Optional[str] = None
total_executions: int = 0
success_count: int = 0
failure_count: int = 0
# ─── Config Schemas ────────────────────────────────────────────────────────────
class ConfigResponse(BaseModel):
OPENCLAW_WORKSPACE: str
GITEA_SERVER: str
DEPLOYMENT_SERVER: str
DEPLOYMENT_USER: str
TELEGRAM_CHAT_ID: str
LOG_LEVEL: str
# Secrets are masked
GITEA_OPENCLAW_TOKEN: str = "***"
DEPLOYMENT_PWD: str = "***"
TELEGRAM_BOT_TOKEN: str = "***"
class ConfigUpdate(BaseModel):
OPENCLAW_WORKSPACE: Optional[str] = None
GITEA_SERVER: Optional[str] = None
DEPLOYMENT_SERVER: Optional[str] = None
DEPLOYMENT_USER: Optional[str] = None
LOG_LEVEL: Optional[str] = None
# ─── WebSocket Messages ───────────────────────────────────────────────────────
class WSMessage(BaseModel):
type: str # "agent_status" | "log" | "project_update"
data: Any
+181
View File
@@ -0,0 +1,181 @@
"""
Dynamic Workflow Engine for Foxy Dev Team.
Replaces the static STATUS_TRANSITIONS dict from foxy-autopilot.py with a flexible
engine that supports 4 workflow types and routes tasks between agents accordingly.
"""
from dataclasses import dataclass
from typing import Optional
from app.models import ProjectStatus, WorkflowType
@dataclass(frozen=True)
class WorkflowStep:
"""A single step in a workflow pipeline."""
agent_name: str # e.g. "Foxy-Conductor"
awaiting_status: str # e.g. "AWAITING_CONDUCTOR"
running_status: str # e.g. "CONDUCTOR_RUNNING"
# ─── Workflow Definitions ──────────────────────────────────────────────────────
# Workflow 1: Standard Software Design
# Conductor → Architect → Dev → UIUX → QA → Admin
SOFTWARE_DESIGN_STEPS = [
WorkflowStep("Foxy-Conductor", "AWAITING_CONDUCTOR", "CONDUCTOR_RUNNING"),
WorkflowStep("Foxy-Architect", "AWAITING_ARCHITECT", "ARCHITECT_RUNNING"),
WorkflowStep("Foxy-Dev", "AWAITING_DEV", "DEV_RUNNING"),
WorkflowStep("Foxy-UIUX", "AWAITING_UIUX", "UIUX_RUNNING"),
WorkflowStep("Foxy-QA", "AWAITING_QA", "QA_RUNNING"),
WorkflowStep("Foxy-Admin", "AWAITING_DEPLOY", "DEPLOY_RUNNING"),
]
# Workflow 2: Sysadmin Debug
# Conductor → Admin → QA
SYSADMIN_DEBUG_STEPS = [
WorkflowStep("Foxy-Conductor", "AWAITING_CONDUCTOR", "CONDUCTOR_RUNNING"),
WorkflowStep("Foxy-Admin", "AWAITING_DEPLOY", "DEPLOY_RUNNING"),
WorkflowStep("Foxy-QA", "AWAITING_QA", "QA_RUNNING"),
]
# Workflow 3: DevOps Setup
# Conductor → Admin → QA
DEVOPS_SETUP_STEPS = [
WorkflowStep("Foxy-Conductor", "AWAITING_CONDUCTOR", "CONDUCTOR_RUNNING"),
WorkflowStep("Foxy-Admin", "AWAITING_DEPLOY", "DEPLOY_RUNNING"),
WorkflowStep("Foxy-QA", "AWAITING_QA", "QA_RUNNING"),
]
# Workflow 4: Sysadmin Adjust
# Conductor → Admin → QA
SYSADMIN_ADJUST_STEPS = [
WorkflowStep("Foxy-Conductor", "AWAITING_CONDUCTOR", "CONDUCTOR_RUNNING"),
WorkflowStep("Foxy-Admin", "AWAITING_DEPLOY", "DEPLOY_RUNNING"),
WorkflowStep("Foxy-QA", "AWAITING_QA", "QA_RUNNING"),
]
WORKFLOW_REGISTRY: dict[WorkflowType, list[WorkflowStep]] = {
WorkflowType.SOFTWARE_DESIGN: SOFTWARE_DESIGN_STEPS,
WorkflowType.SYSADMIN_DEBUG: SYSADMIN_DEBUG_STEPS,
WorkflowType.DEVOPS_SETUP: DEVOPS_SETUP_STEPS,
WorkflowType.SYSADMIN_ADJUST: SYSADMIN_ADJUST_STEPS,
}
# ─── Agent Label Mapping ──────────────────────────────────────────────────────
AGENT_LABELS = {
"Foxy-Conductor": "foxy-conductor",
"Foxy-Architect": "foxy-architect",
"Foxy-Dev": "foxy-dev",
"Foxy-UIUX": "foxy-uiux",
"Foxy-QA": "foxy-qa",
"Foxy-Admin": "foxy-admin",
}
AGENT_DISPLAY_NAMES = {v: k for k, v in AGENT_LABELS.items()}
AGENT_MODELS = {
"Foxy-Conductor": "grok-4.1-fast",
"Foxy-Architect": "grok-4.1-fast",
"Foxy-Dev": "minimax-m2.5",
"Foxy-UIUX": "qwen3-30b-a3b",
"Foxy-QA": "qwen3.5-flash",
"Foxy-Admin": "grok-4.1-fast",
}
# ─── Engine Functions ──────────────────────────────────────────────────────────
def get_workflow_steps(workflow_type: WorkflowType) -> list[WorkflowStep]:
"""Get the ordered list of steps for a workflow type."""
return WORKFLOW_REGISTRY.get(workflow_type, SOFTWARE_DESIGN_STEPS)
def get_current_step(workflow_type: WorkflowType, status: str) -> Optional[WorkflowStep]:
"""Find the step matching the current project status (awaiting or running)."""
steps = get_workflow_steps(workflow_type)
for step in steps:
if status in (step.awaiting_status, step.running_status):
return step
return None
def get_next_step(workflow_type: WorkflowType, status: str) -> Optional[WorkflowStep]:
"""
Given a 'RUNNING' status, determine the next awaiting step in the workflow.
Returns None if the current step is the last one (project should be COMPLETED).
"""
steps = get_workflow_steps(workflow_type)
for i, step in enumerate(steps):
if status == step.running_status:
if i + 1 < len(steps):
return steps[i + 1]
else:
return None # Last step — project is complete
return None
def get_next_awaiting_status(workflow_type: WorkflowType, current_running_status: str) -> str:
"""
After a running agent finishes, what's the next status?
Returns COMPLETED if the workflow is done.
"""
next_step = get_next_step(workflow_type, current_running_status)
if next_step:
return next_step.awaiting_status
return "COMPLETED"
def is_awaiting_status(status: str) -> bool:
"""Check if a status represents an 'awaiting' state."""
return status.startswith("AWAITING_")
def is_running_status(status: str) -> bool:
"""Check if a status represents a 'running' state."""
return status.endswith("_RUNNING")
def is_terminal_status(status: str) -> bool:
"""Check if a status is terminal (COMPLETED / FAILED)."""
return status in ("COMPLETED", "FAILED")
def get_workflow_progress(workflow_type: WorkflowType, status: str) -> dict:
"""
Calculate the progress of a project through its workflow.
Returns {current_step, total_steps, percentage, completed_agents, remaining_agents}.
"""
steps = get_workflow_steps(workflow_type)
total = len(steps)
if is_terminal_status(status):
return {
"current_step": total if status == "COMPLETED" else 0,
"total_steps": total,
"percentage": 100 if status == "COMPLETED" else 0,
"completed_agents": [s.agent_name for s in steps] if status == "COMPLETED" else [],
"remaining_agents": [],
}
current_idx = 0
for i, step in enumerate(steps):
if status in (step.awaiting_status, step.running_status):
current_idx = i
break
completed = [s.agent_name for s in steps[:current_idx]]
remaining = [s.agent_name for s in steps[current_idx:]]
percentage = int((current_idx / total) * 100) if total > 0 else 0
return {
"current_step": current_idx,
"total_steps": total,
"percentage": percentage,
"completed_agents": completed,
"remaining_agents": remaining,
}
+8
View File
@@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
aiosqlite==0.20.0
pydantic-settings==2.7.1
python-dotenv==1.0.1
httpx==0.28.1
websockets==14.1