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
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()