| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | |
| 8 | "reasonix/internal/event" |
| 9 | goaldomain "reasonix/internal/goal" |
| 10 | "reasonix/internal/session" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | const defaultBlockedAfterRounds uint64 = 3 |
| 15 | |
| 16 | func (c *Controller) GetGoal(context.Context) (*goaldomain.View, error) { |
| 17 | return c.goalLifecycleView() |
| 18 | } |
| 19 | |
| 20 | func (c *Controller) CreateGoal(ctx context.Context, request goaldomain.CreateRequest, authority tool.GoalAuthority) (goaldomain.View, error) { |
| 21 | view, err := c.applyGoalMutation(ctx, authority, tool.GoalActionCreate, func(machine *goaldomain.Machine) (goaldomain.View, error) { |
| 22 | return machine.Create(request) |
| 23 | }) |
| 24 | if err == nil { |
| 25 | c.resetGoalResourceBudget() |
| 26 | } |
| 27 | return view, err |
| 28 | } |
| 29 | |
| 30 | func (c *Controller) UpdateGoal(ctx context.Context, request tool.GoalUpdateRequest, authority tool.GoalAuthority) (goaldomain.View, error) { |
| 31 | return c.applyGoalMutation(ctx, authority, request.Action, func(machine *goaldomain.Machine) (goaldomain.View, error) { |
| 32 | switch request.Action { |
| 33 | case tool.GoalActionEdit: |
| 34 | if request.BlockedReason != nil { |
| 35 | return goaldomain.View{}, &goaldomain.Error{Code: goaldomain.ErrInvalidEdit, Message: "blocked_reason is valid only with action blocked"} |
| 36 | } |
| 37 | return machine.Edit(request.Ref, goaldomain.EditRequest{Objective: request.Objective, MaxGoalRounds: request.MaxGoalRounds}) |
| 38 | case tool.GoalActionPause: |
| 39 | return machine.Pause(request.Ref) |
| 40 | case tool.GoalActionResume: |
| 41 | if current := machine.Get(); current != nil && current.Phase == goaldomain.PhasePaused { |
| 42 | return goaldomain.View{}, &goaldomain.Error{Code: goaldomain.ErrUserAuthorityRequired, Message: "a user-paused goal must be resumed through an explicit UI or command action"} |
| 43 | } |
| 44 | return machine.Resume(request.Ref, authority.Source == tool.GoalSourceDirectHuman) |
| 45 | case tool.GoalActionComplete: |
| 46 | return machine.Complete(request.Ref) |
| 47 | case tool.GoalActionBlocked: |
| 48 | if request.BlockedReason == nil { |
| 49 | return goaldomain.View{}, &goaldomain.Error{Code: goaldomain.ErrInvalidBlockReason, Message: "blocked action requires blocked_reason"} |
| 50 | } |
| 51 | return machine.Block(request.Ref, *request.BlockedReason, authority.Source == tool.GoalSourceDirectHuman, defaultBlockedAfterRounds) |
| 52 | default: |
| 53 | return goaldomain.View{}, &goaldomain.Error{Code: goaldomain.ErrInvalidTransition, Message: fmt.Sprintf("unsupported goal action %q", request.Action)} |
| 54 | } |
| 55 | }) |
| 56 | } |
| 57 | |
| 58 | func (c *Controller) applyGoalMutation( |
| 59 | ctx context.Context, |
| 60 | authority tool.GoalAuthority, |
| 61 | action tool.GoalAction, |
| 62 | mutate func(*goaldomain.Machine) (goaldomain.View, error), |
| 63 | ) (goaldomain.View, error) { |
| 64 | if c == nil || mutate == nil { |
| 65 | return goaldomain.View{}, session.ErrSessionNotRunning |
| 66 | } |
| 67 | c.goalLifecycleMutationMu.Lock() |
| 68 | defer c.goalLifecycleMutationMu.Unlock() |
| 69 | runtime, err := c.validateGoalAuthority(authority, action) |
| 70 | if err != nil { |
| 71 | return goaldomain.View{}, err |
| 72 | } |
| 73 | c.goalLifecycleMu.RLock() |
| 74 | machine, loadErr := c.goalLifecycle, c.goalLifecycleLoadErr |
| 75 | c.goalLifecycleMu.RUnlock() |
| 76 | if loadErr != nil { |
| 77 | return goaldomain.View{}, fmt.Errorf("goal lifecycle is unavailable: %w", loadErr) |
| 78 | } |
| 79 | if machine == nil { |
| 80 | return goaldomain.View{}, session.ErrSessionNotRunning |
| 81 | } |
| 82 | candidate := machine.Clone() |
| 83 | view, err := mutate(candidate) |
| 84 | if err != nil { |
| 85 | return goaldomain.View{}, err |
| 86 | } |
| 87 | payload, err := candidate.Encode() |
| 88 | if err != nil { |
| 89 | return goaldomain.View{}, err |
| 90 | } |
| 91 | operationID := fmt.Sprintf("goal:%s:%s:%d:%s", runtime.Ref().SessionID, view.ID, view.Revision, action) |
| 92 | appendCtx := ctx |
| 93 | if appendCtx == nil || appendCtx.Err() != nil { |
| 94 | appendCtx = context.Background() |
| 95 | } |
| 96 | if _, err := runtime.Session().Append(appendCtx, session.Batch{ |
| 97 | OperationID: operationID, |
| 98 | TurnID: runtime.Session().ExecutionSnapshot().Projection.TurnID, |
| 99 | Events: []session.Event{{Kind: "goal/state", Payload: json.RawMessage(payload)}}, |
| 100 | }); err != nil { |
| 101 | return goaldomain.View{}, err |
| 102 | } |
| 103 | // Session.Append is the final authority check. Once accepted, goal/state is |
| 104 | // a session fact and must be published locally; a stale error here could make |
| 105 | // the model repeat a mutation whose durability is already certain. |
| 106 | _, currentRuntime, stillExclusive := c.v3Binding() |
| 107 | if !stillExclusive || currentRuntime != runtime { |
| 108 | // The accepted fact belongs to the runtime that authorized this tool |
| 109 | // call. A concurrent session switch installs its own projection, so do |
| 110 | // not publish the old session's candidate into the new runtime. |
| 111 | return view, nil |
| 112 | } |
| 113 | c.goalLifecycleMu.Lock() |
| 114 | if c.goalLifecycle == machine && c.goalLifecycleLoadErr == nil { |
| 115 | c.goalLifecycle = candidate |
| 116 | } |
| 117 | c.goalLifecycleMu.Unlock() |
| 118 | c.refreshRuntimeState(event.Event{}) |
| 119 | return view, nil |
| 120 | } |
| 121 | |
| 122 | func (c *Controller) validateGoalAuthority(authority tool.GoalAuthority, action tool.GoalAction) (*session.Runtime, error) { |
| 123 | if !authority.Allows(action) { |
| 124 | return nil, &goaldomain.Error{Code: goaldomain.ErrUserAuthorityRequired, Message: "current execution is not allowed to perform this goal action"} |
| 125 | } |
| 126 | _, runtime, exclusive := c.v3Binding() |
| 127 | if !exclusive || runtime == nil { |
| 128 | return nil, session.ErrSessionNotRunning |
| 129 | } |
| 130 | snapshot := runtime.StateSnapshot() |
| 131 | if snapshot.Phase != session.RuntimeRunning || |
| 132 | authority.SessionID != snapshot.Ref.SessionID || |
| 133 | authority.RuntimeEpoch != snapshot.Epoch || |
| 134 | authority.ActivityID != snapshot.ActivityRevision { |
| 135 | return nil, &goaldomain.Error{Code: goaldomain.ErrUserAuthorityRequired, Message: "goal authority no longer matches the active runtime"} |
| 136 | } |
| 137 | if authority.Source == tool.GoalSourceGoalRound { |
| 138 | view, err := c.goalLifecycleView() |
| 139 | if err != nil { |
| 140 | return nil, err |
| 141 | } |
| 142 | if view == nil || view.ID != authority.GoalID || view.Revision != authority.Revision || view.RoundsStarted != authority.Round { |
| 143 | return nil, &goaldomain.Error{Code: goaldomain.ErrStaleRevision, Message: "goal round authority is stale"} |
| 144 | } |
| 145 | } |
| 146 | return runtime, nil |
| 147 | } |
| 148 |