feat(api): expose project settings endpoints and block legacy workflow switching.

Add settings/plan/preview/activate routes and reject active-workflow toggles when governance instances or planning projects require configured activation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 02:50:23 +00:00
committed by weichen
co-authored by Cursor
parent dc629fc4b8
commit be5cdb1ef6
4 changed files with 205 additions and 1 deletions
+4
View File
@@ -137,6 +137,10 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request)
return
}
if s.blockLegacyWorkflowSwitch(w, r, id) {
return
}
var req projectworkflow.ActivateWorkflowInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的请求体"}`, http.StatusBadRequest)
@@ -0,0 +1,186 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"chat-agent-system/internal/projectinstance"
)
func (s *Server) handleGetDefaultWorkflowPlan(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
return
}
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable)
return
}
workflowKey := r.URL.Query().Get("workflow_key")
version, _ := strconv.Atoi(r.URL.Query().Get("version"))
plan, err := s.instSvc.BuildDefaultPlan(r.Context(), workflowKey, version)
if err != nil {
writeProjectInstanceError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, plan)
}
func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用,请先执行数据库迁移"}`, http.StatusServiceUnavailable)
return
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
settings, err := s.instSvc.GetSettings(r.Context(), projectID, user.ID, isCreator)
if err != nil {
writeProjectInstanceError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, settings)
}
func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable)
return
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
var req projectinstance.SavePlanInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest)
return
}
settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req)
if err != nil {
writeProjectInstanceError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, settings)
}
func (s *Server) handleActivationPreview(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable)
return
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
preview, err := s.instSvc.ActivationPreview(r.Context(), projectID, user.ID, isCreator)
if err != nil {
writeProjectInstanceError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, preview)
}
func (s *Server) handleActivateWorkflowInstance(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable)
return
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
var req projectinstance.ActivateInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest)
return
}
result, err := s.instSvc.Activate(r.Context(), projectID, user.ID, isCreator, req)
if err != nil {
writeProjectInstanceError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, result)
}
func writeProjectInstanceError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, projectinstance.ErrForbiddenNotCreator):
http.Error(w, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`, http.StatusForbidden)
case errors.Is(err, projectinstance.ErrPlanFrozen):
http.Error(w, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`, http.StatusConflict)
case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive):
http.Error(w, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`, http.StatusConflict)
case errors.Is(err, projectinstance.ErrPlanChangedReconfirm):
http.Error(w, `{"error":"PLAN_CHANGED_RECONFIRM","message":"规划已变更,请重新预览并确认"}`, http.StatusConflict)
case errors.Is(err, projectinstance.ErrValidationFailed):
http.Error(w, `{"error":"VALIDATION_FAILED","message":"`+err.Error()+`"}`, http.StatusBadRequest)
case errors.Is(err, projectinstance.ErrTokenInvalid):
http.Error(w, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`, http.StatusBadRequest)
case errors.Is(err, projectinstance.ErrConcurrentConflict):
http.Error(w, `{"error":"ERR_CONCURRENT_SWITCH","message":"项目版本冲突,请刷新后重试"}`, http.StatusConflict)
case errors.Is(err, projectinstance.ErrPlanRevisionConflict):
http.Error(w, `{"error":"PLAN_REVISION_CONFLICT","message":"规划版本冲突,请刷新后重试"}`, http.StatusConflict)
case errors.Is(err, projectinstance.ErrProjectNotFound):
http.Error(w, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`, http.StatusNotFound)
case errors.Is(err, projectinstance.ErrInvalidPlan):
http.Error(w, `{"error":"ERR_INVALID_PLAN","message":"`+err.Error()+`"}`, http.StatusBadRequest)
default:
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
}
}
func (s *Server) blockLegacyWorkflowSwitch(w http.ResponseWriter, r *http.Request, projectID int64) bool {
if s.instSvc == nil || !s.instSvc.Available() {
return false
}
live, err := s.instSvc.HasLiveInstance(r.Context(), projectID)
if err != nil {
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
return true
}
if live {
http.Error(w, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例,请使用项目设置查看流程"}`, http.StatusConflict)
return true
}
proj, err := s.pwSvc.GetProject(r.Context(), projectID, 0)
if err != nil {
return false
}
if proj != nil && proj.LifecycleStatus == "planning" && proj.ActiveWorkflowKey == "" {
http.Error(w, `{"error":"LEGACY_SWITCH_BLOCKED","message":"新项目请通过项目设置完成工作流激活"}`, http.StatusConflict)
return true
}
return false
}
+2 -1
View File
@@ -58,7 +58,8 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) {
err := json.Unmarshal(recCreate.Body.Bytes(), &createdProj)
require.NoError(t, err)
assert.Equal(t, int64(2), createdProj.CreatorUserID)
assert.Equal(t, "main", createdProj.ActiveWorkflowKey)
assert.Equal(t, "planning", createdProj.LifecycleStatus)
assert.Equal(t, "", createdProj.ActiveWorkflowKey)
assert.Equal(t, 1, createdProj.WorkflowSelectionVersion)
// 3. 列出项目列表 -> 包含刚创建的项目
+13
View File
@@ -20,6 +20,7 @@ import (
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/observability"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/taskrun"
"chat-agent-system/internal/workflowdef"
@@ -40,6 +41,7 @@ type Server struct {
authorizer *authz.Authorizer
authzSvc *authz.Service
wfDefSvc *workflowdef.Service
instSvc *projectinstance.Service
resolver *workspace.Resolver
httpServer *http.Server
}
@@ -76,6 +78,11 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
authzSvc := authz.NewService(pool)
wfDefSvc := workflowdef.NewService(pool, svc.CAS())
instStore := projectinstance.NewStore(pool)
var instSvc *projectinstance.Service
if wfDefSvc.Available() {
instSvc = projectinstance.NewService(instStore, wfDefSvc.Store(), pwSvc.Store())
}
s := &Server{
cfg: cfg,
svc: svc,
@@ -88,6 +95,7 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
authorizer: authz.NewAuthorizer(pool),
authzSvc: authzSvc,
wfDefSvc: wfDefSvc,
instSvc: instSvc,
resolver: workspace.NewResolver(cfg.DataDir, pool),
}
s.initTaskRunExecutors()
@@ -127,6 +135,11 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("DELETE /api/v1/projects/{id}/members", s.handleProjectMembers)
mux.HandleFunc("GET /api/v1/projects/{id}/workflows", s.handleListProjectWorkflows)
mux.HandleFunc("PUT /api/v1/projects/{id}/active-workflow", s.handleActivateWorkflow)
mux.HandleFunc("GET /api/v1/projects/{id}/settings", s.handleGetProjectSettings)
mux.HandleFunc("GET /api/v1/projects/{id}/workflow-plan/default", s.handleGetDefaultWorkflowPlan)
mux.HandleFunc("PUT /api/v1/projects/{id}/workflow-plan", s.handleSaveWorkflowPlan)
mux.HandleFunc("POST /api/v1/projects/{id}/activation-preview", s.handleActivationPreview)
mux.HandleFunc("POST /api/v1/projects/{id}/workflow-instances/activate", s.handleActivateWorkflowInstance)
mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions)
mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow)
mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns)