package intentflow import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "slices" "time" "chat-agent-system/internal/app/uow" ) const ( defaultLeaseTTL = 15 * time.Second defaultPlanTTL = 30 * time.Minute ) // Plans is the owner implementation of Service plus worker/recovery methods. type Plans struct { uow uow.UnitOfWork store Store owner ActionOwner authz StepAuthorizer events EventSink bind ResourceVersionBinder now func() time.Time newID func(prefix string) string lease time.Duration ttl time.Duration } // ResourceVersionBindInput carries enough context to resolve optimistic versions // for mutate/docgov steps at plan-create time (U10/U11). type ResourceVersionBindInput struct { Principal Principal ActionKey string SafeSummary string Slots []byte } // ResourceVersionBinder returns the expected resource version for a draft step. // Empty string means the action key does not require a version binding. type ResourceVersionBinder func(ctx context.Context, in ResourceVersionBindInput) (string, error) type Options struct { Owner ActionOwner Authorizer StepAuthorizer Events EventSink ResourceVersionBinder ResourceVersionBinder Now func() time.Time NewID func(prefix string) string Lease time.Duration PlanTTL time.Duration } func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans { now := opt.Now if now == nil { now = func() time.Time { return time.Now().UTC() } } newID := opt.NewID if newID == nil { newID = randomID } lease := opt.Lease if lease <= 0 { lease = defaultLeaseTTL } ttl := opt.PlanTTL if ttl <= 0 { ttl = defaultPlanTTL } authz := opt.Authorizer if authz == nil { if a, ok := opt.Owner.(StepAuthorizer); ok { authz = a } } return &Plans{ uow: unit, store: store, owner: opt.Owner, authz: authz, events: opt.Events, bind: opt.ResourceVersionBinder, now: now, newID: newID, lease: lease, ttl: ttl, } } func (p *Plans) stepAuthorizer() StepAuthorizer { if p == nil { return nil } if p.authz != nil { return p.authz } if a, ok := p.owner.(StepAuthorizer); ok { return a } return nil } func randomID(prefix string) string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return prefix + fmt.Sprintf("%d", time.Now().UnixNano()) } return prefix + hex.EncodeToString(b[:]) } func requirePrincipal(p Principal) error { if p.UserID <= 0 || p.ProjectID <= 0 || p.SessionID == "" || p.Environment == "" { return ErrUnauthenticated } return nil } func visibleTo(plan *Plan, p Principal) bool { return plan != nil && sameScope(plan.Principal, p) } func (p *Plans) GetPlan(ctx context.Context, principal Principal, planID string) (*PlanView, error) { if err := requirePrincipal(principal); err != nil { return nil, err } var view *PlanView err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error { plan, err := p.store.GetPlan(ctx, scope, planID) if err != nil { return err } if !visibleTo(plan, principal) { return ErrPlanNotFoundOrHidden } view = toView(plan) return nil }) return view, err } func (p *Plans) GetActivePlan(ctx context.Context, principal Principal) (*PlanView, error) { if err := requirePrincipal(principal); err != nil { return nil, err } var view *PlanView err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error { plan, err := p.store.GetActive(ctx, scope, principal) if err != nil { return err } if plan == nil { return nil } view = toView(plan) return nil }) return view, err } func (p *Plans) CreatePlan(ctx context.Context, in CreateCommand) (*PlanView, error) { if err := requirePrincipal(in.Principal); err != nil { return nil, err } if in.ClientTurnID == "" || in.RequestDigest == "" { return nil, ErrPlanDecisionConflict } if len(in.Steps) == 0 || len(in.Steps) > 4 { return nil, ErrPlanDependencyInvalid } now := p.now() status := in.Status if status == "" { status = PlanAwaitingConfirmation } mode := in.ExecutionMode if mode == "" { mode = ModeValidatedOnly } expires := in.ExpiresAt if expires.IsZero() { expires = now.Add(p.ttl) } seg := in.SegmenterVersion if seg == "" { seg = SegmenterVersionUnknown } pol := in.PolicyVersion if pol == "" { pol = PolicyVersion } var view *PlanView err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error { existing, err := p.store.GetByTurn(ctx, scope, in.Principal.Environment, in.Principal.SessionID, in.ClientTurnID) if err != nil { return err } if existing != nil { if existing.RequestDigest != in.RequestDigest { return ErrPlanDecisionConflict } view = toView(existing) return nil } if in.WritePlan { active, err := p.store.GetActiveWrite(ctx, scope, in.Principal) if err != nil { return err } if active != nil { view = toView(active) return ErrPlanDecisionConflict } } plan := &Plan{ PlanID: p.newID("pln_"), Principal: in.Principal, ClientTurnID: in.ClientTurnID, RequestDigest: in.RequestDigest, SegmenterVersion: seg, PolicyVersion: pol, Status: status, Revision: 1, ExpiresAt: expires, WritePlan: in.WritePlan, ExecutionMode: mode, SafeSummary: in.SafeSummary, CreatedAt: now, UpdatedAt: now, } for i, d := range in.Steps { mode := d.ExecutionMode if mode == "" { mode = plan.ExecutionMode } slots := d.Slots if len(slots) == 0 { slots = []byte("{}") } plan.Steps = append(plan.Steps, Step{ PlanID: plan.PlanID, StepID: p.newID("stp_"), Ordinal: i + 1, ActionKey: d.ActionKey, Slots: slots, DependsOn: slices.Clone(d.DependsOn), Status: StepPending, ExpectedResourceVersion: d.ExpectedResourceVersion, ActionDigest: d.ActionDigest, SafeSummary: d.SafeSummary, Version: 1, ExecutionMode: mode, CreatedAt: now, UpdatedAt: now, }) } if err := p.store.InsertPlan(ctx, scope, plan); err != nil { if errors.Is(err, errTurnExists) { again, loadErr := p.store.GetByTurn(ctx, scope, in.Principal.Environment, in.Principal.SessionID, in.ClientTurnID) if loadErr != nil { return loadErr } if again != nil && again.RequestDigest == in.RequestDigest { view = toView(again) return nil } return ErrPlanDecisionConflict } if errors.Is(err, uow.ErrBusy) { active, loadErr := p.store.GetActiveWrite(ctx, scope, in.Principal) if loadErr != nil { return loadErr } if active != nil { view = toView(active) } return ErrPlanDecisionConflict } return err } view = toView(plan) return p.emitFact(ctx, plan, "", now) }) return view, err } func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView, error) { if err := requirePrincipal(in.Principal); err != nil { return nil, err } if in.PlanID == "" || in.DecisionNonce == "" || in.DecisionDigest == "" { return nil, ErrPlanDecisionConflict } now := p.now() var view *PlanView err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error { plan, err := p.store.GetPlanForUpdate(ctx, scope, in.PlanID) if err != nil { return err } if !visibleTo(plan, in.Principal) { return ErrPlanNotFoundOrHidden } existing, err := p.store.GetDecisionByNonce(ctx, scope, in.PlanID, in.DecisionNonce) if err != nil { return err } if existing != nil { if existing.DecisionDigest != in.DecisionDigest { return ErrPlanDecisionConflict } fresh, err := p.store.GetPlan(ctx, scope, in.PlanID) if err != nil { return err } view = toView(fresh) return nil } if isWaitingStatus(plan.Status) && !now.Before(plan.ExpiresAt) { if _, err := p.store.CASPlan(ctx, scope, plan.PlanID, plan.Revision, waitingStatuses(), PlanExpired, now); err != nil { return err } cancelUnstarted(plan, now) if err := p.store.ReplaceSteps(ctx, scope, plan.PlanID, plan.Steps); err != nil { return err } return ErrPlanExpired } if in.ExpectedRevision != plan.Revision { return ErrPlanStaleRevision } if err := p.authorizeDecision(ctx, plan, in); err != nil { return err } dec := &Decision{ DecisionID: p.newID("dec_"), PlanID: plan.PlanID, Nonce: in.DecisionNonce, PlanRevision: plan.Revision, ActorUserID: in.Principal.UserID, Kind: in.Kind, StepIDs: slices.Clone(in.StepIDs), ExcludeUnselected: in.ExcludeUnselected, DecisionDigest: in.DecisionDigest, CreatedAt: now, } if err := p.store.InsertDecision(ctx, scope, dec); err != nil { if errors.Is(err, uow.ErrDigestConflict) || errors.Is(err, errTurnExists) { again, loadErr := p.store.GetDecisionByNonce(ctx, scope, in.PlanID, in.DecisionNonce) if loadErr != nil { return loadErr } if again != nil && again.DecisionDigest == in.DecisionDigest { fresh, err := p.store.GetPlan(ctx, scope, in.PlanID) if err != nil { return err } view = toView(fresh) return nil } return ErrPlanDecisionConflict } return err } from := []PlanStatus{plan.Status} var to PlanStatus switch in.Kind { case DecisionConfirm, DecisionConfirmRetry, DecisionContinue: if in.Kind == DecisionConfirm { // FIND-FLOW-01 回归:validated_only(shadow/dry-run)计划不允许 // 经 Confirm 进入 executing;shadow 安全不依赖「没人点 Confirm」。 if plan.ExecutionMode == ModeValidatedOnly { return ErrPlanModeNotConfirmable } if plan.Status != PlanAwaitingConfirmation && plan.Status != PlanAwaitingClarification && plan.Status != PlanProposed { return ErrPlanStaleRevision } } else if plan.Status != PlanPartiallyCompleted && plan.Status != PlanExecuting { return ErrPlanStaleRevision } if err := applyConfirm(plan, in.StepIDs, in.ExcludeUnselected, now); err != nil { return err } to = PlanExecuting case DecisionCancel: if !isWaitingStatus(plan.Status) && plan.Status != PlanExecuting && plan.Status != PlanPartiallyCompleted { return ErrPlanStaleRevision } cancelUnstarted(plan, now) to = PlanCancelled case DecisionExclude: if !isWaitingStatus(plan.Status) && plan.Status != PlanPartiallyCompleted { return ErrPlanStaleRevision } if err := applyExclude(plan, in.StepIDs, now); err != nil { return err } to = plan.Status if allStepsTerminal(plan) { to = PlanCancelled } case DecisionEdit: if !isWaitingStatus(plan.Status) { return ErrPlanStaleRevision } to = plan.Status default: return ErrPlanDependencyInvalid } newRev, err := p.store.CASPlan(ctx, scope, plan.PlanID, plan.Revision, from, to, now) if err != nil { return err } if newRev == 0 { return ErrPlanStaleRevision } if err := p.store.ReplaceSteps(ctx, scope, plan.PlanID, plan.Steps); err != nil { return err } plan.Status = to plan.Revision = newRev view = toView(plan) return p.emitFact(ctx, plan, "", now) }) return view, err } func (p *Plans) authorizeDecision(ctx context.Context, plan *Plan, in DecisionCommand) error { switch in.Kind { case DecisionConfirm, DecisionConfirmRetry, DecisionContinue: default: return nil } auth := p.stepAuthorizer() if auth == nil { return nil } ids, err := selectedStepIDs(plan, in.StepIDs) if err != nil { return err } byID := map[string]Step{} for _, s := range plan.Steps { byID[s.StepID] = s } for _, id := range ids { s := byID[id] if err := auth.Authorize(ctx, StepExec{ Principal: in.Principal, PlanID: plan.PlanID, StepID: s.StepID, ActionKey: s.ActionKey, ActionDigest: s.ActionDigest, ExpectedResourceVersion: s.ExpectedResourceVersion, Slots: s.Slots, SafeSummary: s.SafeSummary, }); err != nil { return err } } return nil } func selectedStepIDs(plan *Plan, selected []string) ([]string, error) { byID := map[string]*Step{} for i := range plan.Steps { byID[plan.Steps[i].StepID] = &plan.Steps[i] } if len(selected) == 0 { ids := make([]string, 0, len(plan.Steps)) for i := range plan.Steps { ids = append(ids, plan.Steps[i].StepID) } return ids, nil } chosen := map[string]bool{} for _, id := range selected { if _, ok := byID[id]; !ok { return nil, ErrPlanDependencyInvalid } chosen[id] = true } for id := range chosen { for _, dep := range byID[id].DependsOn { depStep, ok := byID[dep] if !ok || (!chosen[dep] && depStep.Status != StepSucceeded && depStep.Status != StepSkipped) { return nil, ErrPlanDependencyInvalid } } } ids := make([]string, 0, len(chosen)) for id := range chosen { ids = append(ids, id) } return ids, nil } func applyExclude(plan *Plan, stepIDs []string, now time.Time) error { if len(stepIDs) == 0 { return ErrPlanDependencyInvalid } byID := map[string]*Step{} for i := range plan.Steps { byID[plan.Steps[i].StepID] = &plan.Steps[i] } for _, id := range stepIDs { s, ok := byID[id] if !ok { return ErrPlanDependencyInvalid } if s.Status == StepPending || s.Status == StepEligible { s.Status = StepCancelled s.UpdatedAt = now } } return nil } func allStepsTerminal(plan *Plan) bool { for _, s := range plan.Steps { switch s.Status { case StepSucceeded, StepFailed, StepDenied, StepSkipped, StepCancelled: default: return false } } return true } func applyConfirm(plan *Plan, selected []string, excludeUnselected bool, now time.Time) error { byID := map[string]*Step{} for i := range plan.Steps { byID[plan.Steps[i].StepID] = &plan.Steps[i] } chosen := map[string]bool{} if len(selected) == 0 { for i := range plan.Steps { chosen[plan.Steps[i].StepID] = true } } else { for _, id := range selected { if _, ok := byID[id]; !ok { return ErrPlanDependencyInvalid } chosen[id] = true } for id := range chosen { for _, dep := range byID[id].DependsOn { depStep, ok := byID[dep] if !ok || (!chosen[dep] && depStep.Status != StepSucceeded && depStep.Status != StepSkipped) { return ErrPlanDependencyInvalid } } } } for i := range plan.Steps { s := &plan.Steps[i] if chosen[s.StepID] { if s.Status == StepPending || s.Status == StepEligible { s.Status = StepEligible s.UpdatedAt = now } continue } if excludeUnselected && (s.Status == StepPending || s.Status == StepEligible) { s.Status = StepCancelled s.UpdatedAt = now } } return nil } func cancelUnstarted(plan *Plan, now time.Time) { for i := range plan.Steps { s := &plan.Steps[i] if s.Status == StepPending || s.Status == StepEligible { s.Status = StepCancelled s.UpdatedAt = now } } } func (p *Plans) ClaimStep(ctx context.Context, workerID, planID, stepID string) (*ClaimedStep, error) { if workerID == "" || planID == "" || stepID == "" { return nil, ErrPlanDependencyInvalid } now := p.now() until := now.Add(p.lease) var out *ClaimedStep err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error { plan, err := p.store.GetPlanForUpdate(ctx, scope, planID) if err != nil { return err } if plan == nil { return ErrPlanNotFoundOrHidden } var digest string for _, s := range plan.Steps { if s.StepID == stepID { digest = s.ActionDigest break } } key := fmt.Sprintf("%s:%d:%s:%s", planID, plan.Revision, stepID, digest) step, token, err := p.store.ClaimStep(ctx, scope, planID, stepID, workerID, until, now, key) if err != nil { return err } if step == nil { return nil } if obs, ok := p.owner.(FenceObserver); ok { obs.ObserveFence(step.StepID, token) } out = &ClaimedStep{Plan: *plan, Step: *step, WorkerID: workerID, FencingToken: token, IdempotencyKey: step.IdempotencyKey} return nil }) return out, err } func (p *Plans) ExecuteClaimed(ctx context.Context, claimed *ClaimedStep) (*PlanView, error) { if claimed == nil { return nil, ErrPlanNotFoundOrHidden } if p.owner == nil { return nil, ErrPlanActionForbidden } // FIND-FLOW-01 回归:执行器硬读 ExecutionMode——validated_only 步骤 // 一律拒绝真实执行,无论上游状态机如何推进。步骤级 mode 优先, // 缺省回退计划级。 effectiveMode := claimed.Plan.ExecutionMode if claimed.Step.ExecutionMode != "" { effectiveMode = claimed.Step.ExecutionMode } if effectiveMode == ModeValidatedOnly { return nil, ErrPlanModeNotExecutable } exec := StepExec{ Principal: claimed.Plan.Principal, PlanID: claimed.Plan.PlanID, StepID: claimed.Step.StepID, ActionKey: claimed.Step.ActionKey, IdempotencyKey: claimed.IdempotencyKey, ActionDigest: claimed.Step.ActionDigest, ExpectedResourceVersion: claimed.Step.ExpectedResourceVersion, FencingToken: claimed.FencingToken, Slots: claimed.Step.Slots, SafeSummary: claimed.Step.SafeSummary, } if auth := p.stepAuthorizer(); auth != nil { if err := auth.Authorize(ctx, exec); err != nil { return nil, err } } rec, err := p.owner.Execute(ctx, exec) if err != nil { return nil, err } return p.writeReceipt(ctx, claimed, rec) } func (p *Plans) RecoverReceipt(ctx context.Context, claimed *ClaimedStep) (*PlanView, error) { if claimed == nil || p.owner == nil { return nil, ErrPlanNotFoundOrHidden } rec, ok, err := p.owner.LookupReceipt(ctx, claimed.IdempotencyKey) if err != nil { return nil, err } if !ok { return nil, ErrPlanActionVersionConflict } return p.writeReceipt(ctx, claimed, rec) } func (p *Plans) writeReceipt(ctx context.Context, claimed *ClaimedStep, rec Receipt) (*PlanView, error) { now := p.now() var view *PlanView err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error { ok, err := p.store.WriteStepReceipt(ctx, scope, claimed.WorkerID, claimed.FencingToken, Step{ PlanID: claimed.Plan.PlanID, StepID: claimed.Step.StepID, ResultRef: rec.ResultRef, ResultDigest: rec.ResultDigest, SafeSummary: rec.SafeSummary, }, now) if err != nil { return err } if !ok { return ErrPlanActionVersionConflict } plan, err := p.store.GetPlan(ctx, scope, claimed.Plan.PlanID) if err != nil { return err } view = toView(plan) return p.emitFact(ctx, plan, claimed.Step.StepID, now) }) return view, err } func (p *Plans) ExpireDue(ctx context.Context) (int, error) { var n int err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error { var err error n, err = p.store.ExpireWaiting(ctx, scope, p.now()) return err }) return n, err } func (p *Plans) PurgeClosed(ctx context.Context, olderThan time.Time) (int, error) { var n int err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error { var err error n, err = p.store.PurgeClosed(ctx, scope, olderThan) return err }) return n, err } func toView(plan *Plan) *PlanView { if plan == nil { return nil } v := &PlanView{ PlanID: plan.PlanID, Revision: plan.Revision, Status: plan.Status, ExecutionMode: plan.ExecutionMode, ExpiresAt: plan.ExpiresAt, SafeSummary: plan.SafeSummary, } for _, s := range plan.Steps { v.Steps = append(v.Steps, StepView{ StepID: s.StepID, ActionKey: s.ActionKey, Status: s.Status, ExecutionMode: s.ExecutionMode, DependsOn: slices.Clone(s.DependsOn), SafeSummary: s.SafeSummary, }) } return v }