feat: Implement comprehensive project management with new API client, backend routes, and frontend pages for projects and settings.

This commit is contained in:
2026-03-13 08:54:45 -04:00
parent 18c8815166
commit a927534d16
9 changed files with 815 additions and 78 deletions
+100 -19
View File
@@ -1,13 +1,10 @@
"""
SQLAlchemy ORM models for Foxy Dev Team.
"""
from __future__ import annotations
import enum
from datetime import datetime, timezone
from typing import Optional, List
from typing import List
from sqlalchemy import (
String, Text, Integer, DateTime, ForeignKey, Enum, JSON
String, Text, Integer, DateTime, ForeignKey, Enum, JSON, Boolean
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -43,6 +40,54 @@ class WorkflowType(str, enum.Enum):
SYSADMIN_ADJUST = "SYSADMIN_ADJUST"
class ProjectType(str, enum.Enum):
SYSTEMD_SERVICE = "SYSTEMD_SERVICE"
DOCKER_WEBAPP = "DOCKER_WEBAPP"
DOCKER_API = "DOCKER_API"
DOCKER_COMPOSE = "DOCKER_COMPOSE"
CLI_TOOL = "CLI_TOOL"
CRON_JOB = "CRON_JOB"
SHELL_SCRIPT = "SHELL_SCRIPT"
WORKER_MQ = "WORKER_MQ"
SOCKET_IPC = "SOCKET_IPC"
WEBHOOK_EVENT = "WEBHOOK_EVENT"
LIBRARY_PLUGIN = "LIBRARY_PLUGIN"
AGENT_BOT = "AGENT_BOT"
TUI_APP = "TUI_APP"
MCP_SERVER = "MCP_SERVER"
WASM_EDGE = "WASM_EDGE"
GRPC_SERVICE = "GRPC_SERVICE"
KERNEL_EBPF = "KERNEL_EBPF"
SERVERLESS_FAAS = "SERVERLESS_FAAS"
DESKTOP_GUI = "DESKTOP_GUI"
STATIC_SITE = "STATIC_SITE"
# Project type metadata (emoji, label, description)
PROJECT_TYPE_INFO = {
ProjectType.SYSTEMD_SERVICE: ("⚙️", "Service système (systemd)", "Démon long-running, démarrage automatique au boot, redémarrage sur erreur."),
ProjectType.DOCKER_WEBAPP: ("🌐", "Docker — Web app", "Application frontend ou backend HTTP/HTTPS containerisée."),
ProjectType.DOCKER_API: ("🔌", "Docker — API (REST / gRPC)", "Service HTTP headless containerisé, sans interface utilisateur."),
ProjectType.DOCKER_COMPOSE: ("🐳", "Docker Compose", "Orchestration de plusieurs conteneurs avec réseaux et volumes partagés."),
ProjectType.CLI_TOOL: ("💻", "Outil CLI", "Programme en ligne de commande avec sous-commandes, flags et stdin/stdout."),
ProjectType.CRON_JOB: ("⏰", "Jobs planifiés (cron / timer)", "Exécution périodique ou batch, anacron pour machines non 24/7."),
ProjectType.SHELL_SCRIPT: ("📜", "Script shell (Bash / Zsh)", "Automatisation légère, glue entre outils, pipelines de commandes."),
ProjectType.WORKER_MQ: ("📨", "Worker + Message queue", "Traitement asynchrone via Redis, RabbitMQ ou Kafka."),
ProjectType.SOCKET_IPC: ("🔗", "Socket / IPC local", "Communication inter-processus via Unix socket, FIFO ou D-Bus."),
ProjectType.WEBHOOK_EVENT: ("🪝", "Webhook / Event-driven", "Réception d'événements HTTP entrants, architecture pub/sub."),
ProjectType.LIBRARY_PLUGIN: ("📦", "Bibliothèque / Plugin", "Code réutilisable sous forme de .so, package Python ou module Go."),
ProjectType.AGENT_BOT: ("🤖", "Agent / Bot autonome", "Service IA ou bot (Telegram, Discord) tournant en continu."),
ProjectType.TUI_APP: ("🖥️", "TUI (interface texte)", "Interface terminal interactive via curses, Textual ou Bubble Tea."),
ProjectType.MCP_SERVER: ("🧠", "Serveur MCP", "Exposition d'outils IA via Model Context Protocol (stdio ou HTTP SSE)."),
ProjectType.WASM_EDGE: ("🧊", "WebAssembly / Edge", "Modules wasm sandboxés, portables, via wasi et wasmtime."),
ProjectType.GRPC_SERVICE: ("⚡", "Microservice gRPC / Thrift", "RPC binaire performant avec contrat IDL (protobuf)."),
ProjectType.KERNEL_EBPF: ("🔬", "Module kernel / eBPF", "Driver, hook réseau ou sonde d'observabilité au niveau noyau."),
ProjectType.SERVERLESS_FAAS: ("☁️", "Serverless / FaaS", "Fonctions déclenchées à la demande via OpenFaaS ou Knative."),
ProjectType.DESKTOP_GUI: ("🖼️", "Application desktop (GUI)", "Interface graphique native via GTK, Qt, ou Tauri/Electron."),
ProjectType.STATIC_SITE: ("📄", "Site statique / SSG", "Génération de HTML pur au build time via Hugo, Astro ou Jekyll."),
}
class TaskStatus(str, enum.Enum):
PENDING = "PENDING"
IN_PROGRESS = "IN_PROGRESS"
@@ -78,7 +123,35 @@ def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Models ────────────────────────────────────────────────────────────────────
# ─── Infrastructure Models ─────────────────────────────────────────────────────
class DeployServer(Base):
__tablename__ = "deploy_servers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
host: Mapped[str] = mapped_column(String(500), nullable=False)
user: Mapped[str] = mapped_column(String(100), default="deploy")
password: Mapped[str] = mapped_column(String(500), nullable=True)
ssh_port: Mapped[int] = mapped_column(Integer, default=22)
description: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
class GitServer(Base):
__tablename__ = "git_servers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
url: Mapped[str] = mapped_column(String(500), nullable=False)
token: Mapped[str] = mapped_column(String(500), nullable=True)
org: Mapped[str] = mapped_column(String(200), default="openclaw")
description: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
# ─── Core Models ───────────────────────────────────────────────────────────────
class Project(Base):
@@ -94,9 +167,15 @@ class Project(Base):
workflow_type: Mapped[WorkflowType] = mapped_column(
Enum(WorkflowType), default=WorkflowType.SOFTWARE_DESIGN, nullable=False
)
project_type: Mapped[ProjectType] = mapped_column(
Enum(ProjectType), default=ProjectType.DOCKER_WEBAPP, 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)
install_path: Mapped[str] = mapped_column(String(500), nullable=True)
gitea_repo: Mapped[str] = mapped_column(String(500), nullable=True)
deployment_target: Mapped[str] = mapped_column(String(200), nullable=True)
deploy_server_id: Mapped[int] = mapped_column(ForeignKey("deploy_servers.id"), nullable=True)
git_server_id: Mapped[int] = mapped_column(ForeignKey("git_servers.id"), 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
@@ -112,6 +191,8 @@ class Project(Base):
agent_executions: Mapped[List["AgentExecution"]] = relationship(
back_populates="project", cascade="all, delete-orphan", lazy="selectin"
)
deploy_server: Mapped["DeployServer"] = relationship(lazy="selectin")
git_server: Mapped["GitServer"] = relationship(lazy="selectin")
class Task(Base):
@@ -123,13 +204,13 @@ class Task(Base):
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)
assigned_to: Mapped[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)
dependencies: Mapped[dict] = mapped_column(JSON, default=list)
acceptance_criteria: Mapped[str] = mapped_column(Text, nullable=True)
agent_payloads: Mapped[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
@@ -148,11 +229,11 @@ class AgentExecution(Base):
status: Mapped[AgentExecutionStatus] = mapped_column(
Enum(AgentExecutionStatus), default=AgentExecutionStatus.RUNNING
)
pid: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
pid: Mapped[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)
finished_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
exit_code: Mapped[int] = mapped_column(Integer, nullable=True)
error_output: Mapped[str] = mapped_column(Text, nullable=True)
# Relationships
project: Mapped["Project"] = relationship(back_populates="agent_executions")
@@ -166,8 +247,8 @@ class AuditLog(Base):
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)
target: Mapped[str] = mapped_column(String(100), nullable=True)
message: Mapped[str] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(100), default="api")
# Relationships
+97 -3
View File
@@ -1,17 +1,28 @@
"""
Configuration management API endpoint.
Configuration management and server CRUD API endpoints.
"""
import logging
from fastapi import APIRouter
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.schemas import ConfigResponse, ConfigUpdate
from app.database import get_db
from app.models import DeployServer, GitServer
from app.schemas import (
ConfigResponse, ConfigUpdate,
DeployServerCreate, DeployServerResponse,
GitServerCreate, GitServerResponse,
)
log = logging.getLogger("foxy.api.config")
router = APIRouter(prefix="/api/config", tags=["config"])
# ─── App Configuration ────────────────────────────────────────────────────────
@router.get("", response_model=ConfigResponse)
async def get_config():
"""Get current configuration (secrets masked)."""
@@ -43,3 +54,86 @@ async def update_config(body: ConfigUpdate):
log.info(f"Config updated: {list(updated.keys())}")
return {"message": "Configuration updated", "updated_fields": list(updated.keys())}
# ─── Deploy Servers ────────────────────────────────────────────────────────────
@router.get("/deploy-servers", response_model=list[DeployServerResponse])
async def list_deploy_servers(db: AsyncSession = Depends(get_db)):
"""List all deployment servers."""
result = await db.execute(select(DeployServer).order_by(DeployServer.name))
return result.scalars().all()
@router.post("/deploy-servers", response_model=DeployServerResponse, status_code=201)
async def create_deploy_server(body: DeployServerCreate, db: AsyncSession = Depends(get_db)):
"""Add a new deployment server."""
server = DeployServer(
name=body.name,
host=body.host,
user=body.user,
password=body.password,
ssh_port=body.ssh_port,
description=body.description,
)
db.add(server)
await db.flush()
await db.refresh(server)
log.info(f"Deploy server created: {body.name} ({body.host})")
return server
@router.delete("/deploy-servers/{server_id}")
async def delete_deploy_server(server_id: int, db: AsyncSession = Depends(get_db)):
"""Remove a deployment server."""
result = await db.execute(select(DeployServer).where(DeployServer.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(status_code=404, detail="Deploy server not found")
name = server.name
await db.delete(server)
await db.flush()
log.info(f"Deploy server deleted: {name}")
return {"message": f"Deploy server '{name}' deleted"}
# ─── Git Servers ───────────────────────────────────────────────────────────────
@router.get("/git-servers", response_model=list[GitServerResponse])
async def list_git_servers(db: AsyncSession = Depends(get_db)):
"""List all Git servers."""
result = await db.execute(select(GitServer).order_by(GitServer.name))
return result.scalars().all()
@router.post("/git-servers", response_model=GitServerResponse, status_code=201)
async def create_git_server(body: GitServerCreate, db: AsyncSession = Depends(get_db)):
"""Add a new Git server."""
server = GitServer(
name=body.name,
url=body.url,
token=body.token,
org=body.org,
description=body.description,
)
db.add(server)
await db.flush()
await db.refresh(server)
log.info(f"Git server created: {body.name} ({body.url})")
return server
@router.delete("/git-servers/{server_id}")
async def delete_git_server(server_id: int, db: AsyncSession = Depends(get_db)):
"""Remove a Git server."""
result = await db.execute(select(GitServer).where(GitServer.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(status_code=404, detail="Git server not found")
name = server.name
await db.delete(server)
await db.flush()
log.info(f"Git server deleted: {name}")
return {"message": f"Git server '{name}' deleted"}
+62 -9
View File
@@ -14,11 +14,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import (
Project, Task, AuditLog, AgentExecution,
ProjectStatus, WorkflowType, AgentExecutionStatus,
ProjectStatus, WorkflowType, ProjectType, AgentExecutionStatus,
PROJECT_TYPE_INFO,
)
from app.schemas import (
ProjectCreate, ProjectUpdate, ProjectSummary, ProjectDetail,
TaskCreate, TaskResponse,
TaskCreate, TaskResponse, ProjectTypeInfo,
)
from app.workflows import (
get_workflow_steps, get_current_step, get_workflow_progress,
@@ -40,6 +41,23 @@ def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Project Types ─────────────────────────────────────────────────────────────
@router.get("/types", response_model=list[ProjectTypeInfo])
async def list_project_types():
"""Return all available project types with emoji, label and description."""
return [
ProjectTypeInfo(
value=pt.value,
emoji=info[0],
label=info[1],
description=info[2],
)
for pt, info in PROJECT_TYPE_INFO.items()
]
# ─── CRUD ──────────────────────────────────────────────────────────────────────
@@ -54,7 +72,11 @@ async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)
description=body.description,
status=ProjectStatus.AWAITING_CONDUCTOR,
workflow_type=body.workflow_type,
project_type=body.project_type,
test_mode=body.test_mode,
install_path=body.install_path,
deploy_server_id=body.deploy_server_id,
git_server_id=body.git_server_id,
)
db.add(project)
await db.flush()
@@ -64,21 +86,48 @@ async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)
agent="system",
action="PROJECT_CREATED",
target=slug,
message=f"Projet créé: {body.name} (workflow: {body.workflow_type.value})",
message=f"Projet créé: {body.name} (workflow: {body.workflow_type.value}, type: {body.project_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"])
# Refresh to load eager relationships (tasks, audit_logs, agent_executions, servers)
await db.refresh(project, ["tasks", "audit_logs", "agent_executions", "deploy_server", "git_server"])
log.info(f"Project created: {slug} (workflow: {body.workflow_type.value})")
log.info(f"Project created: {slug} (workflow: {body.workflow_type.value}, type: {body.project_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}")
await notify_project_event(project.name, "Projet créé", f"Workflow: {body.workflow_type.value}, Type: {body.project_type.value}")
return project
# Build response with server names
return _project_detail(project)
def _project_detail(p: Project) -> dict:
"""Build a ProjectDetail-compatible dict from a Project ORM instance."""
return {
"id": p.id,
"name": p.name,
"slug": p.slug,
"description": p.description,
"status": p.status,
"workflow_type": p.workflow_type,
"project_type": p.project_type,
"test_mode": p.test_mode,
"install_path": p.install_path,
"gitea_repo": p.gitea_repo,
"deployment_target": p.deployment_target,
"deploy_server_id": p.deploy_server_id,
"git_server_id": p.git_server_id,
"deploy_server_name": p.deploy_server.name if p.deploy_server else None,
"git_server_name": p.git_server.name if p.git_server else None,
"created_at": p.created_at,
"updated_at": p.updated_at,
"tasks": p.tasks,
"audit_logs": p.audit_logs,
"agent_executions": p.agent_executions,
}
@router.get("", response_model=list[ProjectSummary])
@@ -115,9 +164,13 @@ async def list_projects(
slug=p.slug,
status=p.status,
workflow_type=p.workflow_type,
project_type=p.project_type,
test_mode=p.test_mode,
install_path=p.install_path,
task_count=total,
tasks_done=done,
deploy_server_name=p.deploy_server.name if p.deploy_server else None,
git_server_name=p.git_server.name if p.git_server else None,
created_at=p.created_at,
updated_at=p.updated_at,
))
@@ -131,7 +184,7 @@ async def get_project(project_id: int, db: AsyncSession = Depends(get_db)):
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
return _project_detail(project)
# ─── Workflow Control ──────────────────────────────────────────────────────────
+15 -10
View File
@@ -20,18 +20,23 @@ async def list_workflows():
"""List all available workflow types with their step sequences."""
workflows = []
for wf_type, steps in WORKFLOW_REGISTRY.items():
step_list = [
{
"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
]
# Build visual path string: "Conductor → Architect → Dev → QA → Admin"
path = " → ".join(s.agent_name for s in steps)
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
],
"path": path,
"steps": step_list,
})
return workflows
+75 -1
View File
@@ -7,7 +7,7 @@ from typing import Optional, List, Any
from pydantic import BaseModel, Field
from app.models import (
ProjectStatus, WorkflowType, TaskStatus, TaskType,
ProjectStatus, WorkflowType, ProjectType, TaskStatus, TaskType,
TaskPriority, AgentExecutionStatus,
)
@@ -76,6 +76,62 @@ class AgentExecutionResponse(BaseModel):
model_config = {"from_attributes": True}
# ─── Deploy Server Schemas ─────────────────────────────────────────────────────
class DeployServerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
host: str = Field(..., min_length=1)
user: str = "deploy"
password: Optional[str] = None
ssh_port: int = 22
description: str = ""
class DeployServerResponse(BaseModel):
id: int
name: str
host: str
user: str
ssh_port: int
description: str
created_at: datetime
model_config = {"from_attributes": True}
# ─── Git Server Schemas ────────────────────────────────────────────────────────
class GitServerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
url: str = Field(..., min_length=1)
token: Optional[str] = None
org: str = "openclaw"
description: str = ""
class GitServerResponse(BaseModel):
id: int
name: str
url: str
org: str
description: str
created_at: datetime
model_config = {"from_attributes": True}
# ─── Project Type Info ─────────────────────────────────────────────────────────
class ProjectTypeInfo(BaseModel):
value: str
emoji: str
label: str
description: str
# ─── Project Schemas ───────────────────────────────────────────────────────────
@@ -83,13 +139,21 @@ class ProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: str = ""
workflow_type: WorkflowType = WorkflowType.SOFTWARE_DESIGN
project_type: ProjectType = ProjectType.DOCKER_WEBAPP
test_mode: bool = False
install_path: Optional[str] = None
deploy_server_id: Optional[int] = None
git_server_id: Optional[int] = None
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
workflow_type: Optional[WorkflowType] = None
project_type: Optional[ProjectType] = None
install_path: Optional[str] = None
deploy_server_id: Optional[int] = None
git_server_id: Optional[int] = None
class ProjectSummary(BaseModel):
@@ -98,9 +162,13 @@ class ProjectSummary(BaseModel):
slug: str
status: ProjectStatus
workflow_type: WorkflowType
project_type: ProjectType
test_mode: bool
install_path: Optional[str]
task_count: int = 0
tasks_done: int = 0
deploy_server_name: Optional[str] = None
git_server_name: Optional[str] = None
created_at: datetime
updated_at: datetime
@@ -114,9 +182,15 @@ class ProjectDetail(BaseModel):
description: str
status: ProjectStatus
workflow_type: WorkflowType
project_type: ProjectType
test_mode: bool
install_path: Optional[str]
gitea_repo: Optional[str]
deployment_target: Optional[str]
deploy_server_id: Optional[int]
git_server_id: Optional[int]
deploy_server_name: Optional[str] = None
git_server_name: Optional[str] = None
created_at: datetime
updated_at: datetime
tasks: List[TaskResponse] = []