- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
275 lines
9.2 KiB
Python
275 lines
9.2 KiB
Python
"""
|
|
B-5: 멀티 에이전트 협업 오케스트레이션 API 라우터
|
|
|
|
엔드포인트:
|
|
POST /api/orchestrator/workflows — 워크플로우 생성 및 실행
|
|
GET /api/orchestrator/workflows — 워크플로우 목록
|
|
GET /api/orchestrator/workflows/{id} — 워크플로우 상세 (단계 포함)
|
|
POST /api/orchestrator/workflows/{id}/retry — 실패한 워크플로우 재시도
|
|
DELETE /api/orchestrator/workflows/{id} — 워크플로우 취소
|
|
GET /api/orchestrator/templates — 사용 가능한 워크플로우 템플릿
|
|
GET /api/orchestrator/stats — 오케스트레이터 통계
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Optional
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
|
from sqlalchemy import desc, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from database import get_db, SessionLocal
|
|
from models import (
|
|
WorkflowInstance, WorkflowInstanceOut,
|
|
WorkflowStep, WorkflowStepOut,
|
|
WorkflowCreateRequest,
|
|
)
|
|
from core.orchestrator import (
|
|
create_workflow_instance,
|
|
execute_workflow,
|
|
WORKFLOW_TEMPLATES,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/orchestrator", tags=["orchestrator"])
|
|
|
|
|
|
# ── 워크플로우 생성 및 실행 ──────────────────────────────────────────────────
|
|
|
|
@router.post("/workflows", response_model=WorkflowInstanceOut, status_code=202)
|
|
async def create_workflow(
|
|
body: WorkflowCreateRequest,
|
|
background_tasks: BackgroundTasks,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
워크플로우 생성 및 비동기 실행.
|
|
- 인스턴스 + 단계 레코드 즉시 생성 → 202 반환
|
|
- 백그라운드에서 단계 순차 실행
|
|
"""
|
|
# 커스텀 워크플로우 외에는 템플릿 필수
|
|
if body.workflow_type != "CUSTOM" and body.workflow_type not in WORKFLOW_TEMPLATES:
|
|
raise HTTPException(
|
|
400,
|
|
f"알 수 없는 워크플로우 유형: {body.workflow_type}. "
|
|
f"사용 가능: {list(WORKFLOW_TEMPLATES.keys())}"
|
|
)
|
|
|
|
instance = await create_workflow_instance(
|
|
db = db,
|
|
workflow_type= body.workflow_type,
|
|
title = body.title,
|
|
sr_id = body.sr_id,
|
|
project_id = body.project_id,
|
|
context = body.context,
|
|
triggered_by = "api",
|
|
)
|
|
|
|
steps = WORKFLOW_TEMPLATES.get(body.workflow_type, [])
|
|
ctx = json.loads(instance.context_json) if instance.context_json else {}
|
|
|
|
# 백그라운드 실행
|
|
background_tasks.add_task(
|
|
execute_workflow,
|
|
instance.id,
|
|
steps,
|
|
ctx,
|
|
SessionLocal,
|
|
)
|
|
|
|
return instance
|
|
|
|
|
|
@router.get("/workflows", response_model=List[WorkflowInstanceOut])
|
|
async def list_workflows(
|
|
status: Optional[str] = Query(None),
|
|
workflow_type: Optional[str] = Query(None),
|
|
hours: int = Query(72, ge=1, le=720),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""워크플로우 인스턴스 목록."""
|
|
since = datetime.utcnow() - timedelta(hours=hours)
|
|
conditions = [WorkflowInstance.created_at >= since]
|
|
|
|
if status:
|
|
conditions.append(WorkflowInstance.status == status.upper())
|
|
if workflow_type:
|
|
conditions.append(WorkflowInstance.workflow_type == workflow_type.upper())
|
|
|
|
from sqlalchemy import and_
|
|
q = (
|
|
select(WorkflowInstance)
|
|
.where(and_(*conditions))
|
|
.order_by(desc(WorkflowInstance.created_at))
|
|
.limit(limit)
|
|
.offset(offset)
|
|
)
|
|
rows = (await db.execute(q)).scalars().all()
|
|
return rows
|
|
|
|
|
|
@router.get("/workflows/{instance_id}", response_model=WorkflowInstanceOut)
|
|
async def get_workflow(
|
|
instance_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""워크플로우 상세 (단계 목록 포함)."""
|
|
from sqlalchemy.orm import selectinload
|
|
q = (
|
|
select(WorkflowInstance)
|
|
.options(selectinload(WorkflowInstance.steps))
|
|
.where(WorkflowInstance.id == instance_id)
|
|
)
|
|
inst = (await db.execute(q)).scalars().first()
|
|
if not inst:
|
|
raise HTTPException(404, f"워크플로우 {instance_id}를 찾을 수 없습니다.")
|
|
return inst
|
|
|
|
|
|
@router.post("/workflows/{instance_id}/retry", status_code=202)
|
|
async def retry_workflow(
|
|
instance_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""실패한 워크플로우 재시도."""
|
|
inst = (await db.execute(
|
|
select(WorkflowInstance).where(WorkflowInstance.id == instance_id)
|
|
)).scalars().first()
|
|
if not inst:
|
|
raise HTTPException(404, f"워크플로우 {instance_id}를 찾을 수 없습니다.")
|
|
if inst.status not in ("FAILED", "CANCELLED"):
|
|
raise HTTPException(400, f"FAILED/CANCELLED 상태가 아닙니다: {inst.status}")
|
|
|
|
# 상태 초기화
|
|
inst.status = "PENDING"
|
|
inst.error_msg = None
|
|
inst.progress_pct = 0
|
|
inst.current_step = 0
|
|
inst.started_at = None
|
|
inst.completed_at = None
|
|
|
|
# 모든 단계 초기화
|
|
steps_rows = (await db.execute(
|
|
select(WorkflowStep).where(WorkflowStep.instance_id == instance_id)
|
|
)).scalars().all()
|
|
for s in steps_rows:
|
|
s.status = "PENDING"
|
|
s.output_json = None
|
|
s.error_msg = None
|
|
s.started_at = None
|
|
s.completed_at = None
|
|
|
|
await db.commit()
|
|
|
|
# 재실행
|
|
steps = WORKFLOW_TEMPLATES.get(inst.workflow_type, [])
|
|
ctx = json.loads(inst.context_json) if inst.context_json else {}
|
|
|
|
background_tasks.add_task(execute_workflow, instance_id, steps, ctx, SessionLocal)
|
|
|
|
return {"instance_id": instance_id, "status": "retry_queued"}
|
|
|
|
|
|
@router.delete("/workflows/{instance_id}", status_code=204)
|
|
async def cancel_workflow(
|
|
instance_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""워크플로우 취소 (실행 중인 것은 취소 표시만)."""
|
|
inst = (await db.execute(
|
|
select(WorkflowInstance).where(WorkflowInstance.id == instance_id)
|
|
)).scalars().first()
|
|
if not inst:
|
|
raise HTTPException(404, f"워크플로우 {instance_id}를 찾을 수 없습니다.")
|
|
if inst.status == "COMPLETED":
|
|
raise HTTPException(400, "이미 완료된 워크플로우입니다.")
|
|
|
|
inst.status = "CANCELLED"
|
|
inst.completed_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
|
|
# ── 워크플로우 템플릿 ─────────────────────────────────────────────────────────
|
|
|
|
@router.get("/templates")
|
|
async def list_templates():
|
|
"""사용 가능한 워크플로우 템플릿 목록."""
|
|
templates = []
|
|
for wf_type, steps in WORKFLOW_TEMPLATES.items():
|
|
templates.append({
|
|
"workflow_type": wf_type,
|
|
"step_count": len(steps),
|
|
"agents": list({s["agent_name"] for s in steps}),
|
|
"steps": [
|
|
{
|
|
"order": s["order"],
|
|
"agent": s["agent_name"],
|
|
"action": s["action"],
|
|
"description": s.get("description", ""),
|
|
"requires_llm": s.get("requires_llm", False),
|
|
}
|
|
for s in steps
|
|
],
|
|
})
|
|
return {"templates": templates}
|
|
|
|
|
|
# ── 통계 ─────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/stats")
|
|
async def get_orchestrator_stats(
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""오케스트레이터 통계."""
|
|
total = (await db.execute(
|
|
select(func.count()).select_from(WorkflowInstance)
|
|
)).scalar() or 0
|
|
|
|
by_status = {}
|
|
rows = (await db.execute(
|
|
select(WorkflowInstance.status, func.count())
|
|
.group_by(WorkflowInstance.status)
|
|
)).all()
|
|
for status, cnt in rows:
|
|
by_status[status] = cnt
|
|
|
|
by_type = {}
|
|
rows2 = (await db.execute(
|
|
select(WorkflowInstance.workflow_type, func.count())
|
|
.group_by(WorkflowInstance.workflow_type)
|
|
)).all()
|
|
for wf_type, cnt in rows2:
|
|
by_type[wf_type] = cnt
|
|
|
|
# 평균 소요 시간 (완료된 것)
|
|
from sqlalchemy import extract
|
|
avg_rows = (await db.execute(
|
|
select(WorkflowInstance)
|
|
.where(WorkflowInstance.status == "COMPLETED")
|
|
.order_by(desc(WorkflowInstance.completed_at))
|
|
.limit(100)
|
|
)).scalars().all()
|
|
|
|
avg_duration = 0.0
|
|
if avg_rows:
|
|
durations = [
|
|
(r.completed_at - r.started_at).total_seconds()
|
|
for r in avg_rows
|
|
if r.started_at and r.completed_at
|
|
]
|
|
avg_duration = round(sum(durations) / len(durations), 1) if durations else 0.0
|
|
|
|
return {
|
|
"total_workflows": total,
|
|
"by_status": by_status,
|
|
"by_type": by_type,
|
|
"avg_duration_sec": avg_duration,
|
|
"available_templates": list(WORKFLOW_TEMPLATES.keys()),
|
|
}
|