chore: install frontend dependencies.
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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>"
|
||||
)
|
||||
@@ -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.")
|
||||
@@ -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()
|
||||
@@ -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())}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user