| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "sort" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/desktop/internal/sessionui" |
| 12 | "reasonix/desktop/internal/workspacestate" |
| 13 | "reasonix/internal/session" |
| 14 | ) |
| 15 | |
| 16 | type SessionComposerState struct { |
| 17 | Ref session.SessionRef `json:"ref"` |
| 18 | Revision string `json:"revision"` |
| 19 | ContentJSON string `json:"contentJson"` |
| 20 | ContentVersion int `json:"contentVersion"` |
| 21 | Baseline string `json:"baseline"` |
| 22 | SubmissionID string `json:"submissionId,omitempty"` |
| 23 | SubmissionPhase string `json:"submissionPhase,omitempty"` |
| 24 | SubmissionRequest string `json:"submissionRequest,omitempty"` |
| 25 | SubmissionFingerprint string `json:"submissionFingerprint,omitempty"` |
| 26 | SubmissionRevision string `json:"submissionRevision,omitempty"` |
| 27 | HistoryChanged bool `json:"historyChanged"` |
| 28 | Conflict bool `json:"conflict"` |
| 29 | } |
| 30 | |
| 31 | type SessionComposerSaveRequest struct { |
| 32 | Ref session.SessionRef `json:"ref"` |
| 33 | ExpectedRevision string `json:"expectedRevision"` |
| 34 | ContentJSON string `json:"contentJson"` |
| 35 | ContentVersion int `json:"contentVersion"` |
| 36 | AcknowledgeHistory bool `json:"acknowledgeHistory,omitempty"` |
| 37 | } |
| 38 | |
| 39 | func composerRecordKey(ref session.SessionRef) string { return ref.HostID + ":" + ref.SessionID } |
| 40 | |
| 41 | func (a *App) readSessionComposer(ref session.SessionRef) (SessionComposerState, error) { |
| 42 | if err := validateLocalSessionRef(ref); err != nil { |
| 43 | return SessionComposerState{}, err |
| 44 | } |
| 45 | r, err := a.sessionUIStore().Get(a.bootContext(), "composer", composerRecordKey(ref)) |
| 46 | view := SessionComposerState{Ref: ref, Revision: "0", ContentJSON: "{}", ContentVersion: 1} |
| 47 | if err != nil { |
| 48 | return view, err |
| 49 | } |
| 50 | if r.Revision != "0" { |
| 51 | if err := json.Unmarshal(r.Payload, &view); err != nil { |
| 52 | return view, err |
| 53 | } |
| 54 | if view.ContentVersion != 1 { |
| 55 | return view, errors.New("unsupported composer content version") |
| 56 | } |
| 57 | } |
| 58 | view.Revision = r.Revision |
| 59 | return view, nil |
| 60 | } |
| 61 | |
| 62 | // Follow user history as well as receipts: imported and older submit paths can |
| 63 | // advance history without a SubmissionID. Model settings and streaming assistant |
| 64 | // output must not invalidate input while the user composes the next turn. |
| 65 | func (a *App) composerBaseline(ref session.SessionRef) (string, error) { |
| 66 | snapshot, err := a.desktopSessionService("").Query().Snapshot(a.bootContext(), ref) |
| 67 | if err != nil { |
| 68 | return "", err |
| 69 | } |
| 70 | payload, err := json.Marshal(snapshot.Projection.Submissions) |
| 71 | if err != nil { |
| 72 | return "", err |
| 73 | } |
| 74 | var receipts map[string]json.RawMessage |
| 75 | if err := json.Unmarshal(payload, &receipts); err != nil { |
| 76 | return "", err |
| 77 | } |
| 78 | keys := make([]string, 0, len(receipts)) |
| 79 | for key := range receipts { |
| 80 | keys = append(keys, key) |
| 81 | } |
| 82 | sort.Strings(keys) |
| 83 | users := []json.RawMessage{} |
| 84 | for _, message := range snapshot.Projection.Messages { |
| 85 | if message.Role == "user" { |
| 86 | encoded, err := json.Marshal(message) |
| 87 | if err != nil { |
| 88 | return "", err |
| 89 | } |
| 90 | users = append(users, encoded) |
| 91 | } |
| 92 | } |
| 93 | encoded, _ := json.Marshal(struct { |
| 94 | Receipts []string |
| 95 | Users []json.RawMessage |
| 96 | }{keys, users}) |
| 97 | return fmt.Sprintf("%x", sha256.Sum256(encoded)), nil |
| 98 | } |
| 99 | |
| 100 | func (a *App) GetSessionComposerState(ref session.SessionRef) (result SessionComposerState, err error) { |
| 101 | defer func() { err = sessionUIError(err, composerRecordKey(ref), "") }() |
| 102 | defer a.lockRuntimeMutation("read composer")() |
| 103 | return a.getSessionComposerState(ref) |
| 104 | } |
| 105 | |
| 106 | func (a *App) getSessionComposerState(ref session.SessionRef) (SessionComposerState, error) { |
| 107 | view, err := a.readSessionComposer(ref) |
| 108 | if err != nil { |
| 109 | return view, err |
| 110 | } |
| 111 | baseline, err := a.composerBaseline(ref) |
| 112 | if err != nil { |
| 113 | return view, err |
| 114 | } |
| 115 | if view.SubmissionID != "" { |
| 116 | snapshot, snapshotErr := a.desktopSessionService("").Query().Snapshot(a.bootContext(), ref) |
| 117 | if snapshotErr != nil { |
| 118 | return view, snapshotErr |
| 119 | } |
| 120 | _, accepted := snapshot.Projection.Submissions.Lookup(ref.SessionID, view.SubmissionID) |
| 121 | if !accepted { |
| 122 | accepted, err = a.composerGuidanceAccepted(view) |
| 123 | if err != nil { |
| 124 | return view, err |
| 125 | } |
| 126 | } |
| 127 | if accepted && snapshot.DurableSequence >= snapshot.EventSequence { |
| 128 | submission := view |
| 129 | submission.SubmissionPhase = "accepted" |
| 130 | view.ContentJSON, view.SubmissionID, view.SubmissionPhase = "{}", "", "" |
| 131 | view.SubmissionRequest, view.SubmissionFingerprint = "", "" |
| 132 | view.Baseline = baseline |
| 133 | return a.saveComposerSettlement(view, submission) |
| 134 | } |
| 135 | view.SubmissionPhase = "unknown" |
| 136 | } |
| 137 | view.HistoryChanged = view.Revision != "0" && view.Baseline != "" && view.Baseline != baseline && draftHasContent(view.ContentJSON) |
| 138 | if view.Revision == "0" { |
| 139 | view.Baseline = baseline |
| 140 | } |
| 141 | return view, nil |
| 142 | } |
| 143 | |
| 144 | func (a *App) saveComposerView(view SessionComposerState) (SessionComposerState, error) { |
| 145 | return a.saveComposerSettlement(view, view) |
| 146 | } |
| 147 | |
| 148 | func (a *App) saveComposerSettlement(view, submission SessionComposerState) (SessionComposerState, error) { |
| 149 | payload, err := json.Marshal(view) |
| 150 | if err != nil { |
| 151 | return view, err |
| 152 | } |
| 153 | companions := []sessionui.Record{} |
| 154 | if submission.SubmissionID != "" { |
| 155 | receipt, marshalErr := json.Marshal(submission) |
| 156 | if marshalErr != nil { |
| 157 | return view, marshalErr |
| 158 | } |
| 159 | companions = append(companions, sessionui.Record{Key: composerRecordKey(view.Ref) + "/" + submission.SubmissionID, Payload: receipt}) |
| 160 | } |
| 161 | r, err := a.sessionUIStore().Save(a.bootContext(), "composer", composerRecordKey(view.Ref), view.Revision, payload, companions...) |
| 162 | if errors.Is(err, sessionui.ErrConflict) { |
| 163 | if decodeErr := json.Unmarshal(r.Payload, &view); decodeErr != nil { |
| 164 | return view, decodeErr |
| 165 | } |
| 166 | view.Revision, view.Conflict = r.Revision, true |
| 167 | return view, nil |
| 168 | } |
| 169 | if err == nil { |
| 170 | view.Revision = r.Revision |
| 171 | } |
| 172 | return view, err |
| 173 | } |
| 174 | |
| 175 | func (a *App) SaveSessionComposerState(req SessionComposerSaveRequest) (result SessionComposerState, err error) { |
| 176 | defer func() { err = sessionUIError(err, composerRecordKey(req.Ref), "") }() |
| 177 | defer a.lockRuntimeMutation("save composer")() |
| 178 | if req.ContentVersion != 1 || !json.Valid([]byte(req.ContentJSON)) { |
| 179 | return SessionComposerState{}, errors.New("invalid composer content") |
| 180 | } |
| 181 | view, err := a.readSessionComposer(req.Ref) |
| 182 | if err != nil { |
| 183 | return view, err |
| 184 | } |
| 185 | state, err := a.workspaceRegistry().Load(a.bootContext()) |
| 186 | if err != nil { |
| 187 | return view, err |
| 188 | } |
| 189 | if state.SessionStates[req.Ref.SessionID].Lifecycle == workspacestate.Deleted { |
| 190 | return view, session.ErrSessionNotFound |
| 191 | } |
| 192 | if view.SubmissionID != "" { |
| 193 | return view, errors.New("check the pending submission before editing") |
| 194 | } |
| 195 | baseline, err := a.composerBaseline(req.Ref) |
| 196 | if err != nil { |
| 197 | return view, err |
| 198 | } |
| 199 | if view.Revision != "0" && view.Baseline != baseline && draftHasContent(view.ContentJSON) && !req.AcknowledgeHistory { |
| 200 | view.HistoryChanged = true |
| 201 | return view, nil |
| 202 | } |
| 203 | view.Revision, view.ContentJSON, view.ContentVersion = req.ExpectedRevision, req.ContentJSON, req.ContentVersion |
| 204 | view.Baseline, view.HistoryChanged, view.Conflict = baseline, false, false |
| 205 | return a.saveComposerView(view) |
| 206 | } |
| 207 | |
| 208 | // BeginSessionComposerSubmission associates an already-saved source with the |
| 209 | // ordinary identified submission. It does not admit or execute work. |
| 210 | func (a *App) BeginSessionComposerSubmission(ref session.SessionRef, revision, submissionID, requestJSON string) (result SessionComposerState, err error) { |
| 211 | defer func() { err = sessionUIError(err, composerRecordKey(ref), submissionID) }() |
| 212 | defer a.lockRuntimeMutation("begin composer submission")() |
| 213 | view, err := a.getSessionComposerState(ref) |
| 214 | if err != nil { |
| 215 | return view, err |
| 216 | } |
| 217 | if view.SubmissionID != "" { |
| 218 | if view.SubmissionID == submissionID && view.SubmissionRevision == revision && view.SubmissionRequest == requestJSON { |
| 219 | return view, nil |
| 220 | } |
| 221 | return view, errors.New("another submission owns this input") |
| 222 | } |
| 223 | if view.Revision != revision || view.HistoryChanged || view.Conflict { |
| 224 | return view, sessionui.ErrConflict |
| 225 | } |
| 226 | if strings.TrimSpace(submissionID) == "" { |
| 227 | return view, errors.New("submission identity is required") |
| 228 | } |
| 229 | if !json.Valid([]byte(requestJSON)) { |
| 230 | return view, errors.New("invalid frozen submission request") |
| 231 | } |
| 232 | previous, err := a.sessionUIStore().Get(a.bootContext(), "submission", composerRecordKey(ref)+"/"+submissionID) |
| 233 | if err != nil { |
| 234 | return view, err |
| 235 | } |
| 236 | if previous.Revision != "0" { |
| 237 | return view, errors.New("submission identity already belongs to another input version") |
| 238 | } |
| 239 | view.SubmissionRequest = requestJSON |
| 240 | view.SubmissionRevision = revision |
| 241 | view.SubmissionFingerprint = fmt.Sprintf("%x", sha256.Sum256([]byte(requestJSON))) |
| 242 | view.SubmissionID, view.SubmissionPhase = submissionID, "pending" |
| 243 | return a.saveComposerView(view) |
| 244 | } |
| 245 | |
| 246 | // CompleteSessionComposerSubmission is called only after the existing send |
| 247 | // command returns its admission result. A lost response is reconciled by Get; |
| 248 | // callers never infer non-acceptance from an absent transcript row. |
| 249 | func (a *App) CompleteSessionComposerSubmission(ref session.SessionRef, submissionID, outcome string) (result SessionComposerState, err error) { |
| 250 | defer func() { err = sessionUIError(err, composerRecordKey(ref), submissionID) }() |
| 251 | defer a.lockRuntimeMutation("complete composer submission")() |
| 252 | view, err := a.readSessionComposer(ref) |
| 253 | if err != nil { |
| 254 | return view, err |
| 255 | } |
| 256 | if view.SubmissionID == "" { |
| 257 | return view, nil |
| 258 | } |
| 259 | if view.SubmissionID != submissionID { |
| 260 | return view, sessionui.ErrConflict |
| 261 | } |
| 262 | submission := view |
| 263 | submission.SubmissionPhase = outcome |
| 264 | switch outcome { |
| 265 | case "accepted": |
| 266 | view.ContentJSON, view.SubmissionID, view.SubmissionPhase = "{}", "", "" |
| 267 | view.SubmissionRequest, view.SubmissionFingerprint = "", "" |
| 268 | case "not_accepted": |
| 269 | view.SubmissionID, view.SubmissionPhase = "", "" |
| 270 | case "unknown": |
| 271 | view.SubmissionPhase = "unknown" |
| 272 | default: |
| 273 | return view, errors.New("invalid submission outcome") |
| 274 | } |
| 275 | view.Baseline, err = a.composerBaseline(ref) |
| 276 | if err != nil { |
| 277 | return view, err |
| 278 | } |
| 279 | return a.saveComposerSettlement(view, submission) |
| 280 | } |
| 281 |