Files
Agent-System/internal/server/intent_plan_handlers.go
T
WeiCHEN d019388f37 feat(intent): wire production QueryOwner and confirm dispatcher
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.
2026-09-22 21:44:56 +08:00

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