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