返回 DeepSeek-Reasonix
session_purge.go
根目录 / desktop / session_purge.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8
9 "reasonix/desktop/internal/workspacestate"
10 "reasonix/internal/session"
11 )
12
13 // PurgeCanonicalSession permanently removes only archived sessions. Legacy
14 // migration originals are retained as upgrade evidence, with mappings acting
15 // as tombstones so background discovery cannot import them again.
16 func (a *App) PurgeCanonicalSession(ref session.SessionRef) error {
17 if err := validateLocalSessionRef(ref); err != nil {
18 return err
19 }
20 state, err := a.workspaceRegistry().Load(a.bootContext())
21 if err != nil {
22 return err
23 }
24 release := a.lockRuntimeMutation("purge archived session")
25 defer release()
26 if err := a.purgeCanonicalSession(a.bootContext(), ref, state.Generation); err != nil {
27 return err
28 }
29 a.emitProjectTreeChanged()
30 a.emitSessionTargetChange("session_deleted", SessionTargetChangeEvent{
31 TargetKey: (SessionTarget{SessionRef: ref}).key(),
32 })
33 return nil
34 }
35
36 func (a *App) purgeCanonicalSessionWithOperation(ref session.SessionRef, operationID string) (SessionTarget, error) {
37 if err := validateLocalSessionRef(ref); err != nil {
38 return SessionTarget{}, err
39 }
40 target, err := a.resolveCanonicalPurgeTarget(ref)
41 if err != nil {
42 return SessionTarget{}, err
43 }
44 if target.Lifecycle != workspacestate.Archived && target.Lifecycle != workspacestate.Deleted {
45 return SessionTarget{}, newSessionOperationError("archived", "Archive this session before deleting it.")
46 }
47 operationID = strings.TrimSpace(operationID)
48 if operationID == "" {
49 operationID = "delete-" + strings.TrimPrefix(newTabID(), "tab_")
50 }
51 a.cancelAISessionTitle(target.key())
52 state, err := a.workspaceRegistry().Load(a.bootContext())
53 if err != nil {
54 return SessionTarget{}, err
55 }
56 release := a.lockRuntimeMutation("purge archived session")
57 defer release()
58 if err := a.purgeCanonicalSession(a.bootContext(), ref, state.Generation); err != nil {
59 return SessionTarget{}, err
60 }
61 if state, loadErr := a.workspaceRegistry().Load(a.bootContext()); loadErr == nil {
62 target.Lifecycle = state.SessionStates[ref.SessionID].Lifecycle
63 target.LifecycleGeneration = state.SessionStates[ref.SessionID].Generation
64 }
65 a.emitProjectTreeChanged()
66 a.emitSessionTargetChange("session_deleted", SessionTargetChangeEvent{
67 TargetKey: target.key(), OperationID: operationID,
68 LifecycleGeneration: target.LifecycleGeneration, WorkspaceID: target.WorkspaceID,
69 })
70 return target, nil
71 }
72
73 func (a *App) resolveCanonicalPurgeTarget(ref session.SessionRef) (SessionTarget, error) {
74 target, resolveErr := a.resolveCanonicalSessionTargetState(ref, "", true)
75 if resolveErr == nil {
76 return target, nil
77 }
78 state, err := a.workspaceRegistry().Load(a.bootContext())
79 if err != nil {
80 return SessionTarget{}, err
81 }
82 op, pending := state.PendingOperations["purge-"+ref.SessionID]
83 status, known := state.SessionStates[ref.SessionID]
84 if !pending || op.Kind != "purge" || !known || status.Lifecycle != workspacestate.Deleted {
85 return SessionTarget{}, resolveErr
86 }
87 target = a.runtimeSessionTarget("", ref, sessionRoute(ref.SessionID))
88 target.SessionRef = ref
89 target.SessionPath = sessionRoute(ref.SessionID)
90 target.TopicID = state.Presentation[ref.SessionID].TopicID
91 target.Lifecycle = status.Lifecycle
92 target.LifecycleGeneration = status.Generation
93 for id, workspace := range state.Workspaces {
94 if containsDesktopString(workspace.SessionIDs, ref.SessionID) {
95 target.WorkspaceID = id
96 target.WorkspaceRoot = workspace.Root
97 target.Scope = "project"
98 if id == workspacestate.GlobalWorkspaceID {
99 target.Scope, target.WorkspaceRoot = "global", ""
100 }
101 break
102 }
103 }
104 return target, nil
105 }
106
107 func (a *App) purgeCanonicalSession(ctx context.Context, ref session.SessionRef, expected uint64) error {
108 return a.executeCanonicalPurge(ctx, ref, expected, nil)
109 }
110
111 func (a *App) resumeCanonicalPurge(ctx context.Context, ref session.SessionRef, observed workspacestate.Operation) error {
112 return a.executeCanonicalPurge(ctx, ref, observed.ExpectedGeneration, &observed)
113 }
114
115 func (a *App) executeCanonicalPurge(ctx context.Context, ref session.SessionRef, expected uint64, observed *workspacestate.Operation) error {
116 store := a.workspaceRegistry()
117 state, err := store.Load(ctx)
118 if err != nil {
119 return err
120 }
121 existingOnly := observed != nil
122 if observed == nil {
123 if op, exists := state.PendingOperations["purge-"+ref.SessionID]; exists {
124 observed = &op
125 }
126 }
127 prepare := func() error {
128 if observed != nil {
129 return store.ResumePurgeForRequest(ctx, ref.SessionID, expected, *observed)
130 }
131 return store.BeginPurge(ctx, ref.SessionID, expected)
132 }
133 switch workspacestate.ClassifyPurge(state, ref.SessionID) {
134 case workspacestate.PurgeCommitted:
135 return prepare()
136 case workspacestate.PurgePreparedStale:
137 // A restore or later lifecycle mutation already superseded this legacy
138 // prepare. Clean only the observed registry record; do not touch the
139 // runtime or filesystem for an obsolete deletion intent.
140 return prepare()
141 case workspacestate.PurgeInvalid:
142 return workspacestate.ErrMutationConflict
143 case workspacestate.PurgeAbsent:
144 if existingOnly {
145 return prepare()
146 }
147 if state.SessionStates[ref.SessionID].Generation > expected {
148 return workspacestate.ErrMutationConflict
149 }
150 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Archived {
151 return errors.New("only archived sessions can be permanently deleted")
152 }
153 }
154 if err := a.retireArchivedSessionRuntime(ctx, ref); err != nil {
155 return fmt.Errorf("session runtime is still in use: %w", err)
156 }
157 filesystem := session.NewFilesystemPersistence(a.desktopSessions.root)
158 if err := filesystem.PurgeWithTombstone(ctx, ref.SessionID, func() error {
159 a.lifecycleCheckpoint("before-tombstone")
160 if err := prepare(); err != nil {
161 return err
162 }
163 a.lifecycleCheckpoint("after-tombstone")
164 return nil
165 }); err != nil {
166 return fmt.Errorf("purge cleanup pending: %w", err)
167 }
168 a.lifecycleCheckpoint("after-file-cleanup")
169 if err := store.AdvancePurge(ctx, ref.SessionID, "content_removed"); err != nil {
170 return err
171 }
172 a.lifecycleCheckpoint("after-content-removed")
173 if err := store.CompletePurge(ctx, ref.SessionID); err != nil {
174 return err
175 }
176 a.lifecycleCheckpoint("after-purge-committed")
177 return nil
178 }
179
180 func (a *App) lifecycleCheckpoint(phase string) {
181 if a.lifecycleCheckpointHook != nil {
182 a.lifecycleCheckpointHook(phase)
183 }
184 }
185
186 // Client release may retain an idle runtime for fast navigation. Archive and
187 // purge must retire that cache entry; Service.Close still refuses bound clients
188 // and executing turns, so this cannot close a live user's session underneath it.
189 func (a *App) retireArchivedSessionRuntime(ctx context.Context, ref session.SessionRef) error {
190 service := a.desktopSessionService("")
191 if _, live := service.Runtime(ref); !live {
192 return nil
193 }
194 err := service.Close(ctx, ref)
195 if errors.Is(err, session.ErrSessionNotRunning) {
196 return nil
197 }
198 return err
199 }
200
200 lines GO