You've already forked agentic-coding-workflow
240 lines
7.1 KiB
Go
240 lines
7.1 KiB
Go
// handler.go 暴露编排器模块的 HTTP 端点:/api/v1/orchestrator/runs(POST 启动、
|
|
// GET 列表)、/runs/{id}(GET)、/runs/{id}/pause|resume|cancel(POST)。
|
|
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
|
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
|
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
|
)
|
|
|
|
// AdminLookup 解析某用户是否为 admin(与 acp/chat/workspace 一致)。
|
|
type AdminLookup interface {
|
|
IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error)
|
|
}
|
|
|
|
// Handler 暴露编排器 HTTP 端点。
|
|
type Handler struct {
|
|
svc Service
|
|
resolver middleware.SessionResolver
|
|
adminLookup AdminLookup
|
|
}
|
|
|
|
// NewHandler 构造 Handler。
|
|
func NewHandler(svc Service, resolver middleware.SessionResolver, al AdminLookup) *Handler {
|
|
return &Handler{svc: svc, resolver: resolver, adminLookup: al}
|
|
}
|
|
|
|
// Mount 注册 /api/v1/orchestrator/* 路由。
|
|
func (h *Handler) Mount(r chi.Router) {
|
|
r.Route("/api/v1/orchestrator/runs", func(r chi.Router) {
|
|
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
|
r.Post("/", h.startRun)
|
|
r.Get("/", h.listRuns)
|
|
r.Get("/{id}", h.getRun)
|
|
r.Post("/{id}/pause", h.pauseRun)
|
|
r.Post("/{id}/resume", h.resumeRun)
|
|
r.Post("/{id}/cancel", h.cancelRun)
|
|
})
|
|
}
|
|
|
|
func (h *Handler) caller(r *http.Request) (Caller, error) {
|
|
uid, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
return Caller{}, errs.New(errs.CodeUnauthorized, "not authenticated")
|
|
}
|
|
admin, err := h.adminLookup.IsAdmin(r.Context(), uid)
|
|
if err != nil {
|
|
return Caller{}, err
|
|
}
|
|
return Caller{UserID: uid, IsAdmin: admin}, nil
|
|
}
|
|
|
|
// ===== DTO =====
|
|
|
|
type startRunReq struct {
|
|
ProjectSlug string `json:"project_slug"`
|
|
RequirementNumber int `json:"requirement_number"`
|
|
StartPhase string `json:"start_phase,omitempty"`
|
|
PhaseAgentKinds map[string]string `json:"phase_agent_kinds,omitempty"`
|
|
PromptOverrides map[string]string `json:"prompt_overrides,omitempty"`
|
|
MaxAttemptsPerPhase int `json:"max_attempts_per_phase,omitempty"`
|
|
MaxDepth int `json:"max_depth,omitempty"`
|
|
FanOutLimit int `json:"fan_out_limit,omitempty"`
|
|
}
|
|
|
|
type runDTO struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
RequirementID string `json:"requirement_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
OwnerID string `json:"owner_id"`
|
|
Status string `json:"status"`
|
|
CurrentPhase string `json:"current_phase"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
EndedAt *time.Time `json:"ended_at,omitempty"`
|
|
}
|
|
|
|
func runToDTO(r *Run) runDTO {
|
|
d := runDTO{
|
|
ID: r.ID.String(),
|
|
ProjectID: r.ProjectID.String(),
|
|
RequirementID: r.RequirementID.String(),
|
|
WorkspaceID: r.WorkspaceID.String(),
|
|
OwnerID: r.OwnerID.String(),
|
|
Status: string(r.Status),
|
|
CurrentPhase: string(r.CurrentPhase),
|
|
CreatedAt: r.CreatedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
EndedAt: r.EndedAt,
|
|
}
|
|
if r.LastError != nil {
|
|
d.LastError = *r.LastError
|
|
}
|
|
return d
|
|
}
|
|
|
|
// ===== handlers =====
|
|
|
|
func (h *Handler) startRun(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.caller(r)
|
|
if err != nil {
|
|
h.writeErr(w, r, err)
|
|
return
|
|
}
|
|
var body startRunReq
|
|
if derr := json.NewDecoder(r.Body).Decode(&body); derr != nil {
|
|
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
|
|
return
|
|
}
|
|
if body.ProjectSlug == "" || body.RequirementNumber <= 0 {
|
|
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "project_slug and requirement_number are required"))
|
|
return
|
|
}
|
|
|
|
cfg := RunConfig{
|
|
MaxAttemptsPerPhase: body.MaxAttemptsPerPhase,
|
|
MaxDepth: body.MaxDepth,
|
|
FanOutLimit: body.FanOutLimit,
|
|
}
|
|
if len(body.PhaseAgentKinds) > 0 {
|
|
cfg.PhaseAgentKinds = map[project.Phase]uuid.UUID{}
|
|
for ph, idStr := range body.PhaseAgentKinds {
|
|
id, perr := uuid.Parse(idStr)
|
|
if perr != nil {
|
|
h.writeErr(w, r, errs.Wrap(perr, errs.CodeInvalidInput, "phase_agent_kinds id parse"))
|
|
return
|
|
}
|
|
cfg.PhaseAgentKinds[project.Phase(ph)] = id
|
|
}
|
|
}
|
|
if len(body.PromptOverrides) > 0 {
|
|
cfg.PromptOverrides = map[project.Phase]string{}
|
|
for ph, p := range body.PromptOverrides {
|
|
cfg.PromptOverrides[project.Phase(ph)] = p
|
|
}
|
|
}
|
|
|
|
run, err := h.svc.StartRun(r.Context(), c, StartRunInput{
|
|
ProjectSlug: body.ProjectSlug,
|
|
RequirementNumber: body.RequirementNumber,
|
|
StartPhase: project.Phase(body.StartPhase),
|
|
Config: cfg,
|
|
})
|
|
if err != nil {
|
|
h.writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, runToDTO(run))
|
|
}
|
|
|
|
func (h *Handler) getRun(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.caller(r)
|
|
if err != nil {
|
|
h.writeErr(w, r, err)
|
|
return
|
|
}
|
|
id, perr := uuid.Parse(chi.URLParam(r, "id"))
|
|
if perr != nil {
|
|
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
|
|
return
|
|
}
|
|
run, err := h.svc.GetRun(r.Context(), c, id)
|
|
if err != nil {
|
|
h.writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, runToDTO(run))
|
|
}
|
|
|
|
func (h *Handler) listRuns(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.caller(r)
|
|
if err != nil {
|
|
h.writeErr(w, r, err)
|
|
return
|
|
}
|
|
f := RunFilter{Status: RunStatus(r.URL.Query().Get("status"))}
|
|
if rid := r.URL.Query().Get("requirement_id"); rid != "" {
|
|
if id, perr := uuid.Parse(rid); perr == nil {
|
|
f.RequirementID = &id
|
|
}
|
|
}
|
|
if pid := r.URL.Query().Get("project_id"); pid != "" {
|
|
if id, perr := uuid.Parse(pid); perr == nil {
|
|
f.ProjectID = &id
|
|
}
|
|
}
|
|
runs, err := h.svc.ListRuns(r.Context(), c, f)
|
|
if err != nil {
|
|
h.writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]runDTO, 0, len(runs))
|
|
for _, run := range runs {
|
|
out = append(out, runToDTO(run))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) pauseRun(w http.ResponseWriter, r *http.Request) { h.runAction(w, r, h.svc.Pause) }
|
|
func (h *Handler) resumeRun(w http.ResponseWriter, r *http.Request) { h.runAction(w, r, h.svc.Resume) }
|
|
func (h *Handler) cancelRun(w http.ResponseWriter, r *http.Request) { h.runAction(w, r, h.svc.Cancel) }
|
|
|
|
func (h *Handler) runAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, Caller, uuid.UUID) error) {
|
|
c, err := h.caller(r)
|
|
if err != nil {
|
|
h.writeErr(w, r, err)
|
|
return
|
|
}
|
|
id, perr := uuid.Parse(chi.URLParam(r, "id"))
|
|
if perr != nil {
|
|
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
|
|
return
|
|
}
|
|
if aerr := fn(r.Context(), c, id); aerr != nil {
|
|
h.writeErr(w, r, aerr)
|
|
return
|
|
}
|
|
run, gerr := h.svc.GetRun(r.Context(), c, id)
|
|
if gerr != nil {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, runToDTO(run))
|
|
}
|
|
|
|
func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) {
|
|
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
|
}
|