返回 DeepSeek-Reasonix
session_lifecycle_api.go
根目录 / desktop / session_lifecycle_api.go
1 package main
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "sort"
10 "strings"
11 "sync"
12
13 "reasonix/desktop/internal/workspacestate"
14 "reasonix/internal/session"
15 )
16
17 type SessionLifecycleTarget struct {
18 WorkspaceID string `json:"workspaceId,omitempty"`
19 Ref *session.SessionRef `json:"ref,omitempty"`
20 RecoveryEntryID string `json:"recoveryEntryId,omitempty"`
21 }
22 type SessionLifecycleRequest struct {
23 OperationID string `json:"operationId"`
24 Action string `json:"action"`
25 Targets []SessionLifecycleTarget `json:"targets"`
26 ExpectedGeneration uint64 `json:"expectedGeneration"`
27 }
28 type SessionLifecycleItem struct {
29 Target SessionLifecycleTarget `json:"target"`
30 Ref *session.SessionRef `json:"ref,omitempty"`
31 WorkspaceID string `json:"workspaceId"`
32 Committed bool `json:"committed"`
33 ErrorCode string `json:"errorCode,omitempty"`
34 Retryable bool `json:"retryable"`
35 }
36 type SessionLifecycleResult struct {
37 OperationID string `json:"operationId"`
38 Generation uint64 `json:"generation"`
39 Committed bool `json:"committed"`
40 Items []SessionLifecycleItem `json:"items"`
41 }
42
43 // Serializes duplicate RPC deliveries; persisted receipts handle restart.
44 var desktopLifecycleCommands sync.Mutex
45
46 func (a *App) ApplySessionLifecycle(req SessionLifecycleRequest) (SessionLifecycleResult, error) {
47 desktopLifecycleCommands.Lock()
48 defer desktopLifecycleCommands.Unlock()
49 out := SessionLifecycleResult{OperationID: req.OperationID, Items: []SessionLifecycleItem{}}
50 if err := validateLifecycleRequest(req); err != nil {
51 return out, err
52 }
53 body, err := json.Marshal(req)
54 if err != nil {
55 return out, err
56 }
57 sum := sha256.Sum256(body)
58 fingerprint := hex.EncodeToString(sum[:])
59 ctx := a.bootContext()
60 store := a.workspaceRegistry()
61 state, err := store.Load(ctx)
62 if err != nil {
63 return out, err
64 }
65 key := "command-" + req.OperationID
66 if old, ok := state.PendingOperations[key]; ok {
67 if old.Kind != "command" || old.RequestFingerprint != fingerprint {
68 return out, workspacestate.ErrMutationConflict
69 }
70 if len(old.Result) > 0 {
71 if err := json.Unmarshal(old.Result, &out); err != nil {
72 return out, err
73 }
74 out.Generation = old.ResultGeneration
75 }
76 if old.Phase == "committed" {
77 return out, nil
78 }
79 } else {
80 begin := store.BeginCommand
81 if req.Action == "purge" {
82 begin = store.BeginPurgeCommand
83 }
84 if err := begin(ctx, key, fingerprint, body, req.ExpectedGeneration); err != nil {
85 return out, err
86 }
87 }
88 previous := out.Items
89 out.Items = []SessionLifecycleItem{}
90 archiveErr := a.archiveLifecycleCommand(req, key)
91 for i, target := range req.Targets {
92 if i < len(previous) && (previous[i].Committed || !previous[i].Retryable) {
93 out.Items = append(out.Items, previous[i])
94 continue
95 }
96 item, err := a.applyLifecycleTarget(req, key, i, target, state, archiveErr)
97 if err != nil {
98 return out, err
99 }
100 out.Items = append(out.Items, item)
101 }
102 state, err = store.Load(ctx)
103 if err != nil {
104 return out, err
105 }
106 out.Generation = state.Generation + 1
107 out.Committed = true
108 final := true
109 for _, item := range out.Items {
110 out.Committed = out.Committed && item.Committed
111 if !item.Committed && item.Retryable {
112 final = false
113 }
114 }
115 body, err = json.Marshal(out)
116 if err != nil {
117 return out, err
118 }
119 a.lifecycleCheckpoint("before-command-result")
120 if err := store.SaveCommandResult(ctx, key, body, final); err != nil {
121 return out, err
122 }
123 a.lifecycleCheckpoint("after-command-result")
124 state, err = store.Load(ctx)
125 if err != nil {
126 return out, err
127 }
128 out.Generation = state.PendingOperations[key].ResultGeneration
129 a.emitProjectTreeChanged()
130 return out, nil
131 }
132
133 type TrashEntry struct {
134 ID string `json:"id"`
135 Ref *session.SessionRef `json:"ref,omitempty"`
136 RecoveryEntryID string `json:"recoveryEntryId,omitempty"`
137 Title string `json:"title"`
138 WorkspaceID string `json:"workspaceId"`
139 WorkspaceTitle string `json:"workspaceTitle"`
140 ArchivedAt int64 `json:"archivedAt"`
141 Health string `json:"health"`
142 OperationPhase string `json:"operationPhase,omitempty"`
143 CanPreview bool `json:"canPreview"`
144 CanRestore bool `json:"canRestore"`
145 CanPurge bool `json:"canPurge"`
146 CleanupBatchID string `json:"cleanupBatchId,omitempty"`
147 CleanupKind string `json:"cleanupKind,omitempty"`
148 }
149 type TrashEntryPage struct {
150 Items []TrashEntry `json:"items"`
151 Generation uint64 `json:"generation"`
152 NextCursor string `json:"nextCursor,omitempty"`
153 }
154
155 func (a *App) ListTrashEntries(query, cursor string, limit int) (TrashEntryPage, error) {
156 out := TrashEntryPage{Items: []TrashEntry{}}
157 state, err := a.workspaceRegistry().Load(a.bootContext())
158 if err != nil {
159 return out, err
160 }
161 out.Generation = state.Generation
162 start, err := decodeWorkspaceSessionCursor(cursor, state.Generation)
163 if err != nil {
164 return out, err
165 }
166 rows := []TrashEntry{}
167 service := a.desktopSessionService("")
168 cleanupState, _ := a.legacyCleanup.Load(a.bootContext())
169 cleanupBySession := legacyCleanupArchivedSessions(cleanupState)
170 for id, status := range state.SessionStates {
171 op := state.PendingOperations["purge-"+id]
172 purgeState := workspacestate.ClassifyPurge(state, id)
173 pending := purgeState == workspacestate.PurgeTombstoned || purgeState == workspacestate.PurgeContentRemoved || purgeState == workspacestate.PurgeInvalid
174 if status.Lifecycle != workspacestate.Archived && !pending {
175 continue
176 }
177 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id}
178 row := TrashEntry{ID: id, Ref: &ref, Title: state.Presentation[id].Title, ArchivedAt: status.ArchivedAt, CanPurge: purgeState != workspacestate.PurgeInvalid, Health: "ready"}
179 decorateLegacyCleanupTrashEntry(&row, cleanupState.BatchID, cleanupBySession[id])
180 for wid, w := range state.Workspaces {
181 if containsDesktopString(w.SessionIDs, id) {
182 row.WorkspaceID = wid
183 row.WorkspaceTitle = w.Title
184 break
185 }
186 }
187 if info, e := service.Query().Stat(a.bootContext(), ref); e == nil {
188 if info.Title != "" {
189 row.Title = info.Title
190 }
191 row.CanPreview = !pending
192 row.CanRestore = !pending
193 } else {
194 row.Health = "unavailable"
195 }
196 if pending {
197 row.OperationPhase = op.Phase
198 row.Health = "purge_pending"
199 }
200 if row.Title == "" {
201 row.Title = id
202 }
203 if strings.Contains(strings.ToLower(row.Title+"\n"+row.WorkspaceTitle), strings.ToLower(query)) {
204 rows = append(rows, row)
205 }
206 }
207 rows = append(rows, legacyCleanupTopicTrashEntries(cleanupState, state, query)...)
208 sort.Slice(rows, func(i, j int) bool {
209 if rows[i].ArchivedAt != rows[j].ArchivedAt {
210 return rows[i].ArchivedAt > rows[j].ArchivedAt
211 }
212 return rows[i].ID < rows[j].ID
213 })
214 if limit <= 0 {
215 limit = 50
216 }
217 if limit > 200 {
218 limit = 200
219 }
220 if start > len(rows) {
221 start = len(rows)
222 }
223 end := min(start+limit, len(rows))
224 out.Items = append(out.Items, rows[start:end]...)
225 if end < len(rows) {
226 out.NextCursor = fmt.Sprintf("%d:%d", state.Generation, end)
227 }
228 return out, nil
229 }
230
231 func validateLifecycleRequest(req SessionLifecycleRequest) error {
232 if strings.TrimSpace(req.OperationID) == "" || len(req.OperationID) > 200 || len(req.Targets) == 0 || len(req.Targets) > 1000 {
233 return errors.New("invalid lifecycle request")
234 }
235 if req.Action != "archive" && req.Action != "restore" && req.Action != "purge" {
236 return errors.New("invalid lifecycle action")
237 }
238 seen := map[string]bool{}
239 for _, target := range req.Targets {
240 if (target.Ref == nil) == (target.RecoveryEntryID == "") {
241 return errors.New("exactly one session identity is required")
242 }
243 if target.Ref != nil {
244 if err := validateLocalSessionRef(*target.Ref); err != nil {
245 return err
246 }
247 } else if req.Action != "restore" && !(req.Action == "purge" && strings.HasPrefix(target.RecoveryEntryID, "legacy-cleanup:")) {
248 return errors.New("historical recovery entries can only be restored")
249 }
250 body, _ := json.Marshal(target)
251 if seen[string(body)] {
252 return errors.New("duplicate lifecycle target")
253 }
254 seen[string(body)] = true
255 }
256 return nil
257 }
258
259 func (a *App) archiveLifecycleCommand(req SessionLifecycleRequest, key string) error {
260 store := a.workspaceRegistry()
261 if req.Action == "archive" {
262 child := key + "-archive"
263 state, err := store.Load(a.bootContext())
264 if err != nil {
265 return err
266 }
267 if state.PendingOperations[child].Phase != "committed" {
268 for _, target := range req.Targets {
269 if state.SessionStates[target.Ref.SessionID].Generation > req.ExpectedGeneration {
270 return workspacestate.ErrMutationConflict
271 }
272 }
273 refs := []session.SessionRef{}
274 for _, target := range req.Targets {
275 refs = append(refs, *target.Ref)
276 }
277 release := a.lockRuntimeMutation("archive lifecycle command")
278 e := a.archiveSessionRefsWithOperation(refs, child)
279 release()
280 if e != nil {
281 return e
282 }
283 }
284 }
285 return nil
286 }
287
288 func (a *App) applyLifecycleTarget(req SessionLifecycleRequest, key string, index int, target SessionLifecycleTarget, state workspacestate.State, archiveErr error) (SessionLifecycleItem, error) {
289 ctx, store := a.bootContext(), a.workspaceRegistry()
290 item := SessionLifecycleItem{Target: target, Ref: target.Ref}
291 var opErr error
292 child := fmt.Sprintf("%s-%d", key, index)
293 latest, loadErr := store.Load(ctx)
294 if loadErr != nil {
295 return item, loadErr
296 }
297 if target.Ref != nil && req.Action != "archive" && latest.PendingOperations[child].Phase != "committed" && latest.SessionStates[target.Ref.SessionID].Generation > req.ExpectedGeneration {
298 purgeState := workspacestate.ClassifyPurge(latest, target.Ref.SessionID)
299 resumingPurge := req.Action == "purge" && (purgeState == workspacestate.PurgeTombstoned || purgeState == workspacestate.PurgeContentRemoved || purgeState == workspacestate.PurgeCommitted)
300 if !resumingPurge {
301 item.ErrorCode = "state_conflict"
302 return item, nil
303 }
304 }
305 if target.Ref != nil {
306 for id, w := range state.Workspaces {
307 if containsDesktopString(w.SessionIDs, target.Ref.SessionID) {
308 item.WorkspaceID = id
309 break
310 }
311 }
312 }
313 switch req.Action {
314 case "archive":
315 opErr = archiveErr
316 case "purge":
317 if target.Ref == nil && strings.HasPrefix(target.RecoveryEntryID, "legacy-cleanup:") {
318 opErr = a.purgeLegacyCleanupTopic(strings.TrimPrefix(target.RecoveryEntryID, "legacy-cleanup:"), target.WorkspaceID)
319 } else {
320 release := a.lockRuntimeMutation("purge lifecycle command")
321 opErr = a.purgeCanonicalSession(ctx, *target.Ref, req.ExpectedGeneration)
322 release()
323 }
324 case "restore":
325 if target.Ref == nil && strings.HasPrefix(target.RecoveryEntryID, "legacy-cleanup:") {
326 opErr = a.restoreLegacyCleanupTopic(strings.TrimPrefix(target.RecoveryEntryID, "legacy-cleanup:"), target.WorkspaceID)
327 item.WorkspaceID = target.WorkspaceID
328 break
329 }
330 var restored SessionRestoreResult
331 if target.Ref == nil {
332 restored, opErr = a.restoreRecoveryEntryInWorkspace(target.RecoveryEntryID, child, target.WorkspaceID)
333 } else {
334 release := a.lockRuntimeMutation("restore lifecycle command")
335 saved, loadErr := store.Load(ctx)
336 if loadErr != nil {
337 opErr = loadErr
338 } else if done := saved.PendingOperations[child]; done.Phase == "committed" {
339 restored = SessionRestoreResult{Session: *target.Ref, WorkspaceID: done.WorkspaceID, Generation: done.ResultGeneration}
340 } else {
341 restored, opErr = a.restoreCanonicalSession(ctx, *target.Ref, child)
342 }
343 release()
344 }
345 if opErr == nil {
346 item.Ref = &restored.Session
347 item.WorkspaceID = restored.WorkspaceID
348 }
349 }
350 item.Committed = opErr == nil
351 if opErr != nil {
352 item.ErrorCode = "operation_failed"
353 item.Retryable = true
354 if errors.Is(opErr, workspacestate.ErrMutationConflict) {
355 item.ErrorCode = "state_conflict"
356 item.Retryable = false
357 }
358 }
359 return item, nil
360 }
361
361 lines GO