This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+239
View File
@@ -0,0 +1,239 @@
// 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)
}