| 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 |