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