Replace FakeActionOwner with QueryOwner+QueryGuard at the composition root; DispatchEligible reuses claim/execute/recover so confirm stays zero-write before dispatch and default tier remains legacy.
208 lines
6.9 KiB
Go
208 lines
6.9 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"chat-agent-system/internal/app/intentflow"
|
|
"chat-agent-system/internal/authz"
|
|
)
|
|
|
|
type intentPlanDecisionBody struct {
|
|
ExpectedRevision int64 `json:"expected_revision"`
|
|
DecisionNonce string `json:"decision_nonce"`
|
|
DecisionDigest string `json:"decision_digest"`
|
|
Kind string `json:"kind"`
|
|
StepIDs []string `json:"step_ids"`
|
|
ExcludeUnselected bool `json:"exclude_unselected"`
|
|
UserID int64 `json:"user_id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
func (s *Server) handleGetActiveIntentPlan(w http.ResponseWriter, r *http.Request) {
|
|
plans, prin, ok := s.planRequestScope(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
view, err := plans.GetActivePlan(r.Context(), prin)
|
|
if err != nil {
|
|
writeIntentPlanError(w, r, plans, prin, "", err)
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
|
|
}
|
|
|
|
func (s *Server) handleGetIntentPlan(w http.ResponseWriter, r *http.Request) {
|
|
plans, prin, ok := s.planRequestScope(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
planID := r.PathValue("plan_id")
|
|
view, err := plans.GetPlan(r.Context(), prin, planID)
|
|
if err != nil {
|
|
writeIntentPlanError(w, r, plans, prin, planID, err)
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
|
|
}
|
|
|
|
func (s *Server) handleIntentPlanDecision(w http.ResponseWriter, r *http.Request) {
|
|
plans, prin, ok := s.planRequestScope(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body intentPlanDecisionBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
|
|
"error_code": "PLAN_DEPENDENCY_INVALID",
|
|
"message": "无效的决策请求",
|
|
})
|
|
return
|
|
}
|
|
kind := intentflow.DecisionKind(strings.TrimSpace(body.Kind))
|
|
planID := r.PathValue("plan_id")
|
|
view, err := plans.DecidePlan(r.Context(), intentflow.DecisionCommand{
|
|
Principal: prin,
|
|
PlanID: planID,
|
|
ExpectedRevision: body.ExpectedRevision,
|
|
DecisionNonce: body.DecisionNonce,
|
|
DecisionDigest: body.DecisionDigest,
|
|
Kind: kind,
|
|
StepIDs: body.StepIDs,
|
|
ExcludeUnselected: body.ExcludeUnselected,
|
|
})
|
|
if err != nil {
|
|
writeIntentPlanError(w, r, plans, prin, planID, err)
|
|
return
|
|
}
|
|
// After confirm, advance eligible steps via the existing plan ledger dispatcher
|
|
// (ClaimStep / ExecuteClaimed / RecoverReceipt). No second decision queue.
|
|
if shouldDispatchAfterDecision(kind, view) {
|
|
if advanced, advErr := plans.DispatchEligible(r.Context(), "http-confirm", planID); advErr == nil && advanced != nil {
|
|
view = advanced
|
|
}
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
|
|
}
|
|
|
|
func shouldDispatchAfterDecision(kind intentflow.DecisionKind, view *intentflow.PlanView) bool {
|
|
if view == nil {
|
|
return false
|
|
}
|
|
switch kind {
|
|
case intentflow.DecisionConfirm, intentflow.DecisionConfirmRetry, intentflow.DecisionContinue:
|
|
return view.Status == intentflow.PlanExecuting ||
|
|
view.Status == intentflow.PlanPartiallyCompleted ||
|
|
view.Status == intentflow.PlanCompleted
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Server) planRequestScope(w http.ResponseWriter, r *http.Request) (intentflow.Service, intentflow.Principal, bool) {
|
|
if s == nil || s.intentPlans == nil {
|
|
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error_code": "PLAN_NOT_FOUND_OR_HIDDEN",
|
|
"message": "计划服务不可用",
|
|
})
|
|
return nil, intentflow.Principal{}, false
|
|
}
|
|
user, ok := s.requireAuthenticatedUser(w, r)
|
|
if !ok {
|
|
return nil, intentflow.Principal{}, false
|
|
}
|
|
projectID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("project_id")), 10, 64)
|
|
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
|
|
if err != nil || projectID <= 0 || sessionID == "" {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
|
|
"error_code": "PLAN_DEPENDENCY_INVALID",
|
|
"message": "缺少 project_id 或 session_id",
|
|
})
|
|
return nil, intentflow.Principal{}, false
|
|
}
|
|
if err := s.planProjectAccess(r.Context(), projectID, user.ID); err != nil {
|
|
if errors.Is(err, authz.ErrUnauthenticated) {
|
|
writeJSONResponse(w, http.StatusUnauthorized, map[string]string{
|
|
"error_code": "ERR_UNAUTHENTICATED",
|
|
"message": "请先登录",
|
|
})
|
|
return nil, intentflow.Principal{}, false
|
|
}
|
|
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
|
"error_code": "ERR_FORBIDDEN",
|
|
"message": "无权访问该项目",
|
|
})
|
|
return nil, intentflow.Principal{}, false
|
|
}
|
|
ownerID, ownerErr := s.planSessionOwner(r.Context(), sessionID)
|
|
if ownerErr != nil || ownerID <= 0 || ownerID != user.ID {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]string{
|
|
"error_code": "PLAN_NOT_FOUND_OR_HIDDEN",
|
|
"message": "计划不存在或不可见",
|
|
})
|
|
return nil, intentflow.Principal{}, false
|
|
}
|
|
env := "dev"
|
|
if s.cfg != nil && strings.TrimSpace(s.cfg.Env) != "" {
|
|
env = s.cfg.Env
|
|
}
|
|
return s.intentPlans, intentflow.Principal{
|
|
UserID: user.ID,
|
|
Environment: env,
|
|
ProjectID: projectID,
|
|
SessionID: sessionID,
|
|
}, true
|
|
}
|
|
|
|
func (s *Server) planProjectAccess(ctx context.Context, projectID, userID int64) error {
|
|
if s.planAccessFn != nil {
|
|
return s.planAccessFn(ctx, projectID, userID)
|
|
}
|
|
if s.authorizer == nil {
|
|
return authz.ErrForbidden
|
|
}
|
|
return s.authorizer.RequireProjectAccess(ctx, projectID, userID)
|
|
}
|
|
|
|
func (s *Server) planSessionOwner(ctx context.Context, sessionID string) (int64, error) {
|
|
if s.planSessionOwnerFn != nil {
|
|
return s.planSessionOwnerFn(ctx, sessionID)
|
|
}
|
|
if s.ctxMgr == nil {
|
|
return 0, intentflow.ErrPlanNotFoundOrHidden
|
|
}
|
|
return s.ctxMgr.GetSessionOwnerID(ctx, sessionID)
|
|
}
|
|
|
|
func writeIntentPlanError(w http.ResponseWriter, r *http.Request, plans intentflow.Service, prin intentflow.Principal, planID string, err error) {
|
|
code := intentflow.ErrorCode(err)
|
|
status := http.StatusInternalServerError
|
|
switch {
|
|
case errors.Is(err, intentflow.ErrUnauthenticated):
|
|
status = http.StatusUnauthorized
|
|
case errors.Is(err, intentflow.ErrPlanNotFoundOrHidden):
|
|
status = http.StatusNotFound
|
|
case errors.Is(err, intentflow.ErrPlanActionForbidden):
|
|
status = http.StatusForbidden
|
|
case errors.Is(err, intentflow.ErrPlanStaleRevision),
|
|
errors.Is(err, intentflow.ErrPlanExpired),
|
|
errors.Is(err, intentflow.ErrPlanDecisionConflict),
|
|
errors.Is(err, intentflow.ErrPlanActionVersionConflict):
|
|
status = http.StatusConflict
|
|
case errors.Is(err, intentflow.ErrPlanDependencyInvalid):
|
|
status = http.StatusBadRequest
|
|
}
|
|
body := map[string]any{"error_code": code, "message": code}
|
|
if plans != nil && (errors.Is(err, intentflow.ErrPlanStaleRevision) || errors.Is(err, intentflow.ErrPlanDecisionConflict)) && planID != "" {
|
|
if latest, getErr := plans.GetPlan(r.Context(), prin, planID); getErr == nil && latest != nil {
|
|
body["plan"] = latest
|
|
}
|
|
}
|
|
writeJSONResponse(w, status, body)
|
|
}
|