// 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) }