| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "slices" |
| 11 | "sort" |
| 12 | "strings" |
| 13 | |
| 14 | "reasonix/desktop/internal/workspacestate" |
| 15 | "reasonix/internal/agent" |
| 16 | "reasonix/internal/config" |
| 17 | "reasonix/internal/session" |
| 18 | ) |
| 19 | |
| 20 | type RecoveryEntryView struct { |
| 21 | WorkspaceChoices []RecoveryWorkspaceChoice `json:"workspaceChoices"` |
| 22 | ID string `json:"id"` |
| 23 | Title string `json:"title"` |
| 24 | Format string `json:"format"` |
| 25 | Reason string `json:"reason"` |
| 26 | Status string `json:"status"` |
| 27 | CanPreview bool `json:"canPreview"` |
| 28 | CanRestore bool `json:"canRestore"` |
| 29 | } |
| 30 | |
| 31 | type RecoveryWorkspaceChoice struct { |
| 32 | ID string `json:"id"` |
| 33 | Title string `json:"title"` |
| 34 | } |
| 35 | |
| 36 | func (a *App) recoveryWorkspaceChoices(ctx context.Context, state workspacestate.State, entry workspacestate.RecoveryEntry) []RecoveryWorkspaceChoice { |
| 37 | allowed := map[string]bool{} |
| 38 | if entry.SessionID != "" { |
| 39 | if info, err := a.desktopSessionService("").Query().Stat(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: entry.SessionID}); err == nil && info.CWD != "" { |
| 40 | for id, w := range state.Workspaces { |
| 41 | if sameDesktopPath(w.Root, info.CWD) { |
| 42 | allowed[id] = true |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | } else { |
| 47 | allowed[desktopWorkspaceOwnerID(state, entry.Scope, entry.WorkspaceRoot)] = true |
| 48 | if entry.WorkspaceID != "" { |
| 49 | allowed[entry.WorkspaceID] = true |
| 50 | } |
| 51 | } |
| 52 | choices := []RecoveryWorkspaceChoice{} |
| 53 | for id := range allowed { |
| 54 | if w, ok := state.Workspaces[id]; ok { |
| 55 | choices = append(choices, RecoveryWorkspaceChoice{ID: id, Title: w.Title}) |
| 56 | } |
| 57 | } |
| 58 | sort.Slice(choices, func(i, j int) bool { return choices[i].ID < choices[j].ID }) |
| 59 | return choices |
| 60 | } |
| 61 | |
| 62 | type RecoveryEntryPage struct { |
| 63 | Items []RecoveryEntryView `json:"items"` |
| 64 | NextCursor string `json:"nextCursor,omitempty"` |
| 65 | Generation uint64 `json:"generation"` |
| 66 | } |
| 67 | type SessionUpgradeStatus struct { |
| 68 | Sources int `json:"sources"` |
| 69 | Sessions int `json:"sessions"` |
| 70 | Operations int `json:"operations"` |
| 71 | Discovered int `json:"discovered"` |
| 72 | Migrated int `json:"migrated"` |
| 73 | Pending int `json:"pending"` |
| 74 | Failed int `json:"failed"` |
| 75 | Conflicts int `json:"conflicts"` |
| 76 | PendingOperations int `json:"pendingOperations"` |
| 77 | } |
| 78 | |
| 79 | func (a *App) GetSessionUpgradeStatus() (SessionUpgradeStatus, error) { |
| 80 | state, err := a.workspaceRegistry().Load(a.bootContext()) |
| 81 | if err != nil { |
| 82 | return SessionUpgradeStatus{}, err |
| 83 | } |
| 84 | sessions := map[string]bool{} |
| 85 | for _, mapping := range state.SourceMappings { |
| 86 | sessions[mapping.SessionID] = true |
| 87 | } |
| 88 | result := SessionUpgradeStatus{Sources: len(state.SourceMappings), Sessions: len(sessions), Operations: len(state.PendingOperations), Migrated: len(sessions), Discovered: len(state.SourceMappings) + len(state.RecoveryEntries)} |
| 89 | for _, entry := range state.RecoveryEntries { |
| 90 | if entry.Status == "restored" { |
| 91 | continue |
| 92 | } |
| 93 | result.Pending++ |
| 94 | if entry.Status == "failed" { |
| 95 | result.Failed++ |
| 96 | } |
| 97 | if strings.Contains(entry.Reason, "conflict") { |
| 98 | result.Conflicts++ |
| 99 | } |
| 100 | } |
| 101 | for _, op := range state.PendingOperations { |
| 102 | if op.Phase != "committed" { |
| 103 | result.PendingOperations++ |
| 104 | } |
| 105 | } |
| 106 | return result, nil |
| 107 | } |
| 108 | |
| 109 | func (a *App) ListRecoveryEntries(query, cursor string, limit int) (RecoveryEntryPage, error) { |
| 110 | out := RecoveryEntryPage{Items: []RecoveryEntryView{}} |
| 111 | state, err := a.workspaceRegistry().Load(a.bootContext()) |
| 112 | if err != nil { |
| 113 | return out, err |
| 114 | } |
| 115 | out.Generation = state.Generation |
| 116 | start, err := decodeWorkspaceSessionCursor(cursor, state.Generation) |
| 117 | if err != nil { |
| 118 | return out, err |
| 119 | } |
| 120 | ids := []string{} |
| 121 | for id, entry := range state.RecoveryEntries { |
| 122 | if entry.Status == "restored" { |
| 123 | continue |
| 124 | } |
| 125 | if query != "" && !strings.Contains(strings.ToLower(filepath.Base(entry.Path)+" "+entry.Reason+" "+id), strings.ToLower(query)) { |
| 126 | continue |
| 127 | } |
| 128 | ids = append(ids, id) |
| 129 | } |
| 130 | sort.Strings(ids) |
| 131 | if limit <= 0 { |
| 132 | limit = 50 |
| 133 | } |
| 134 | if limit > 200 { |
| 135 | limit = 200 |
| 136 | } |
| 137 | if start > len(ids) { |
| 138 | start = len(ids) |
| 139 | } |
| 140 | end := min(start+limit, len(ids)) |
| 141 | for _, id := range ids[start:end] { |
| 142 | entry := state.RecoveryEntries[id] |
| 143 | title := filepath.Base(entry.Path) |
| 144 | if entry.SessionID != "" { |
| 145 | title = entry.SessionID |
| 146 | } |
| 147 | available := entry.SessionID != "" || (entry.Path != "" && (entry.Format == "legacy" || entry.Format == "legacy-trash" || entry.Format == "canonical")) |
| 148 | choices := a.recoveryWorkspaceChoices(a.bootContext(), state, entry) |
| 149 | canRestore := !strings.Contains(entry.Reason, "conflict") || (entry.Reason == "workspace_conflict" && len(choices) > 0) |
| 150 | out.Items = append(out.Items, RecoveryEntryView{WorkspaceChoices: choices, ID: id, Title: title, Format: entry.Format, Reason: entry.Reason, Status: entry.Status, CanPreview: available, CanRestore: available && canRestore}) |
| 151 | } |
| 152 | if end < len(ids) { |
| 153 | out.NextCursor = fmt.Sprintf("%d:%d", state.Generation, end) |
| 154 | } |
| 155 | return out, nil |
| 156 | } |
| 157 | |
| 158 | func (a *App) checkedRecoveryEntry(ctx context.Context, id string) (workspacestate.RecoveryEntry, error) { |
| 159 | state, err := a.workspaceRegistry().Load(ctx) |
| 160 | if err != nil { |
| 161 | return workspacestate.RecoveryEntry{}, err |
| 162 | } |
| 163 | entry, ok := state.RecoveryEntries[id] |
| 164 | if !ok { |
| 165 | return entry, errors.New("recovery entry is unavailable") |
| 166 | } |
| 167 | if entry.Path != "" { |
| 168 | switch entry.Format { |
| 169 | case "legacy-trash": |
| 170 | if _, err := a.trashedSessionDir(entry.Path); err != nil { |
| 171 | return entry, err |
| 172 | } |
| 173 | case "legacy": |
| 174 | if _, _, err := a.sessionDirForPath(entry.Path); err != nil { |
| 175 | return entry, err |
| 176 | } |
| 177 | case "canonical": |
| 178 | allowed := sameDesktopPath(filepath.Dir(entry.Path), config.SessionStoreDir()) || sameDesktopPath(filepath.Dir(entry.Path), config.ProjectSessionStoreDir(globalWorkspaceRoot())) |
| 179 | for _, workspace := range state.Workspaces { |
| 180 | allowed = allowed || sameDesktopPath(filepath.Dir(entry.Path), config.ProjectSessionStoreDir(workspace.Root)) |
| 181 | } |
| 182 | if !allowed { |
| 183 | return entry, errors.New("canonical recovery source is outside known storage roots") |
| 184 | } |
| 185 | default: |
| 186 | return entry, errors.New("this historical format requires migration repair") |
| 187 | } |
| 188 | if _, err := desktopSourceFingerprint(entry.Path); err != nil { |
| 189 | return entry, err |
| 190 | } |
| 191 | } |
| 192 | return entry, nil |
| 193 | } |
| 194 | |
| 195 | func (a *App) PreviewRecoveryEntry(id string) (HistoryPage, error) { |
| 196 | entry, err := a.checkedRecoveryEntry(a.bootContext(), id) |
| 197 | if err != nil { |
| 198 | return HistoryPage{Messages: []HistoryMessage{}}, err |
| 199 | } |
| 200 | if entry.SessionID != "" { |
| 201 | return a.ReadSessionHistory(session.SessionRef{HostID: localDesktopHostID, SessionID: entry.SessionID}, "", 32) |
| 202 | } |
| 203 | if entry.Format == "canonical" { |
| 204 | if preview, err := isDesktopStoredPreview(entry.Path); err != nil { |
| 205 | return HistoryPage{Messages: []HistoryMessage{}}, err |
| 206 | } else if preview { |
| 207 | return previewStoredRecovery(a.bootContext(), entry.Path) |
| 208 | } |
| 209 | old, err := session.NewService("recovery-preview", session.NewFilesystemPersistence(filepath.Dir(entry.Path))) |
| 210 | if err != nil { |
| 211 | return HistoryPage{Messages: []HistoryMessage{}}, err |
| 212 | } |
| 213 | defer func() { _ = old.Shutdown(context.Background()) }() |
| 214 | messages, err := old.Query().History(a.bootContext(), session.SessionRef{HostID: "recovery-preview", SessionID: filepath.Base(entry.Path)}) |
| 215 | if err != nil { |
| 216 | return HistoryPage{Messages: []HistoryMessage{}}, err |
| 217 | } |
| 218 | return historyPageFromProviderMessages(messages, func(value string) string { return value }, nil, nil, 0, 32), nil |
| 219 | } |
| 220 | if entry.HeadID != "" { |
| 221 | loaded, err := agent.LoadSessionHeadReadOnly(entry.Path, entry.HeadID) |
| 222 | if err != nil { |
| 223 | return HistoryPage{Messages: []HistoryMessage{}}, err |
| 224 | } |
| 225 | return historyPageFromProviderMessages(loaded.Messages, func(value string) string { return value }, nil, nil, 0, 32), nil |
| 226 | } |
| 227 | return previewSessionPage(filepath.Dir(entry.Path), entry.Path, 0, 32) |
| 228 | } |
| 229 | |
| 230 | func (a *App) RestoreRecoveryEntry(id, operationID string) (SessionRestoreResult, error) { |
| 231 | return a.restoreRecoveryEntryInWorkspace(id, operationID, "") |
| 232 | } |
| 233 | |
| 234 | func (a *App) restoreRecoveryEntryInWorkspace(id, operationID, workspaceID string) (SessionRestoreResult, error) { |
| 235 | ctx, finish, err := a.beginHistoricalRecovery() |
| 236 | if err != nil { |
| 237 | return SessionRestoreResult{}, err |
| 238 | } |
| 239 | defer finish() |
| 240 | if operationID == "" { |
| 241 | operationID = "restore-recovery-" + id |
| 242 | } |
| 243 | if operationID != "" { |
| 244 | state, err := a.workspaceRegistry().Load(ctx) |
| 245 | if err != nil { |
| 246 | return SessionRestoreResult{}, err |
| 247 | } |
| 248 | if previous, ok := state.PendingOperations[operationID]; ok && previous.Phase == "committed" { |
| 249 | if previous.RecoveryEntryID != id || len(previous.SessionIDs) != 1 { |
| 250 | return SessionRestoreResult{}, workspacestate.ErrMutationConflict |
| 251 | } |
| 252 | return SessionRestoreResult{Session: session.SessionRef{HostID: localDesktopHostID, SessionID: previous.SessionIDs[0]}, WorkspaceID: previous.WorkspaceID, Generation: previous.ResultGeneration}, nil |
| 253 | } |
| 254 | } |
| 255 | entry, err := a.checkedRecoveryEntry(ctx, id) |
| 256 | if err != nil { |
| 257 | return SessionRestoreResult{}, err |
| 258 | } |
| 259 | entry, err = a.selectRecoveryWorkspace(ctx, entry, workspaceID) |
| 260 | if err != nil { |
| 261 | return SessionRestoreResult{}, err |
| 262 | } |
| 263 | if err := validateRecoveryConflict(entry, workspaceID); err != nil { |
| 264 | return SessionRestoreResult{}, err |
| 265 | } |
| 266 | if entry.SessionID != "" { |
| 267 | return a.restoreCanonicalSession(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: entry.SessionID}, operationID, id) |
| 268 | } |
| 269 | release, err := acquireHistoricalSource(ctx, desktopSourceKey(entry.Path, entry.HeadID), historicalSource{path: entry.Path, format: entry.Format}) |
| 270 | if err != nil { |
| 271 | return SessionRestoreResult{}, err |
| 272 | } |
| 273 | defer release() |
| 274 | fingerprint, err := desktopSourceFingerprint(entry.Path) |
| 275 | if err != nil { |
| 276 | return SessionRestoreResult{}, err |
| 277 | } |
| 278 | if entry.Fingerprint != "" && entry.Fingerprint != fingerprint { |
| 279 | return SessionRestoreResult{}, errors.New("historical source changed; rescan before restoring") |
| 280 | } |
| 281 | workspaceID, err = a.ensureDesktopWorkspace(ctx, entry.Scope, entry.WorkspaceRoot) |
| 282 | if err != nil { |
| 283 | return SessionRestoreResult{}, err |
| 284 | } |
| 285 | state, err := a.workspaceRegistry().Load(ctx) |
| 286 | if err != nil { |
| 287 | return SessionRestoreResult{}, err |
| 288 | } |
| 289 | if previous, ok := state.PendingOperations[operationID]; ok && previous.Phase == "committed" { |
| 290 | if previous.RecoveryEntryID != id || len(previous.SessionIDs) != 1 { |
| 291 | return SessionRestoreResult{}, workspacestate.ErrMutationConflict |
| 292 | } |
| 293 | return SessionRestoreResult{Session: session.SessionRef{HostID: localDesktopHostID, SessionID: previous.SessionIDs[0]}, WorkspaceID: previous.WorkspaceID, Generation: previous.ResultGeneration}, nil |
| 294 | } |
| 295 | op := workspacestate.Operation{ID: operationID, Kind: "restore", RecoveryEntryID: id, WorkspaceID: workspaceID, Lifecycle: workspacestate.Active, ExpectedGeneration: state.Generation} |
| 296 | if err := a.workspaceRegistry().BeginOperation(ctx, op); err != nil { |
| 297 | return SessionRestoreResult{}, err |
| 298 | } |
| 299 | source := desktopMigrationSource{scope: entry.Scope, workspaceRoot: entry.WorkspaceRoot, operationID: operationID, headID: entry.HeadID} |
| 300 | if entry.Reason == "source_changed_after_adoption" { |
| 301 | source.versionFingerprint = fingerprint |
| 302 | } |
| 303 | err = a.convertHistoricalSource(ctx, historicalSource{path: entry.Path, format: entry.Format}, source, workspaceID) |
| 304 | if err != nil { |
| 305 | return SessionRestoreResult{}, err |
| 306 | } |
| 307 | state, err = a.workspaceRegistry().Load(ctx) |
| 308 | if err != nil { |
| 309 | return SessionRestoreResult{}, err |
| 310 | } |
| 311 | op = state.PendingOperations[operationID] |
| 312 | if op.Phase != "committed" || len(op.SessionIDs) != 1 { |
| 313 | return SessionRestoreResult{}, errors.New("historical restore is pending") |
| 314 | } |
| 315 | a.emitProjectTreeChanged() |
| 316 | return SessionRestoreResult{Session: session.SessionRef{HostID: localDesktopHostID, SessionID: op.SessionIDs[0]}, WorkspaceID: workspaceID, Generation: op.ResultGeneration}, nil |
| 317 | } |
| 318 | |
| 319 | func (a *App) discoverHistoricalTrash(ctx context.Context) error { |
| 320 | var joined error |
| 321 | for _, dir := range a.knownSessionDirs() { |
| 322 | if err := ctx.Err(); err != nil { |
| 323 | return err |
| 324 | } |
| 325 | paths, err := listTrashedSessionFiles(dir) |
| 326 | if err != nil { |
| 327 | joined = errors.Join(joined, err) |
| 328 | continue |
| 329 | } |
| 330 | for _, path := range paths { |
| 331 | scope, root := "global", "" |
| 332 | if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && meta.WorkspaceRoot != "" && !sameDesktopPath(meta.WorkspaceRoot, globalWorkspaceRoot()) { |
| 333 | scope, root = "project", meta.WorkspaceRoot |
| 334 | } |
| 335 | // Old "deleted" entries were recoverable trash, not permanent |
| 336 | // deletion. Preserve that affordance in the single archived list. |
| 337 | if explicitlyDeletedLegacyEntry(path) { |
| 338 | fingerprint, err := desktopSourceFingerprint(path) |
| 339 | if err != nil { |
| 340 | joined = errors.Join(joined, err) |
| 341 | continue |
| 342 | } |
| 343 | source := desktopMigrationSource{scope: scope, workspaceRoot: root, deferArchive: true} |
| 344 | if err := a.migrateLegacySession(ctx, path, source, ""); err != nil { |
| 345 | joined = errors.Join(joined, err) |
| 346 | continue |
| 347 | } |
| 348 | state, err := a.workspaceRegistry().Load(ctx) |
| 349 | if err != nil { |
| 350 | joined = errors.Join(joined, err) |
| 351 | continue |
| 352 | } |
| 353 | opID := "archive-import-" + desktopSourceKey(path, "") + "-" + fingerprint |
| 354 | if op, ok := state.PendingOperations[opID]; ok && op.Phase == "content_ready" { |
| 355 | joined = errors.Join(joined, a.workspaceRegistry().CommitHistoricalArchive(ctx, opID, trashedSessionDeletedAt(path))) |
| 356 | } |
| 357 | continue |
| 358 | } |
| 359 | if err := a.sourceRecovery(ctx, path, "legacy-trash", "historical_state_unknown", scope, root); err != nil { |
| 360 | joined = errors.Join(joined, err) |
| 361 | } |
| 362 | joined = errors.Join(joined, a.discoverLegacyHeads(ctx, path, "legacy-trash", scope, root)) |
| 363 | } |
| 364 | } |
| 365 | return joined |
| 366 | } |
| 367 | |
| 368 | func explicitlyDeletedLegacyEntry(path string) bool { |
| 369 | body, err := os.ReadFile(filepath.Join(filepath.Dir(path), sessionTrashMetaFile)) |
| 370 | if err != nil { |
| 371 | return false |
| 372 | } |
| 373 | var meta trashedSessionMeta |
| 374 | return json.Unmarshal(body, &meta) == nil && meta.Kind == "deleted" |
| 375 | } |
| 376 | |
| 377 | func (a *App) reconcileUnregisteredSessions(ctx context.Context) error { |
| 378 | state, err := a.workspaceRegistry().Load(ctx) |
| 379 | if err != nil { |
| 380 | return err |
| 381 | } |
| 382 | known := map[string]bool{} |
| 383 | for id, status := range state.SessionStates { |
| 384 | if status.Lifecycle == workspacestate.Deleted { |
| 385 | known[id] = true |
| 386 | } |
| 387 | } |
| 388 | for _, workspace := range state.Workspaces { |
| 389 | for _, id := range workspace.SessionIDs { |
| 390 | known[id] = true |
| 391 | } |
| 392 | } |
| 393 | for id := range state.PendingCreates { |
| 394 | known[id] = true |
| 395 | } |
| 396 | for _, op := range state.PendingOperations { |
| 397 | if op.Phase != "committed" { |
| 398 | for _, id := range op.SessionIDs { |
| 399 | known[id] = true |
| 400 | } |
| 401 | } |
| 402 | } |
| 403 | infos, err := listAllCanonicalSessionInfo(ctx, a.desktopSessionService("").Query()) |
| 404 | if err != nil { |
| 405 | return err |
| 406 | } |
| 407 | var joined error |
| 408 | entries, readErr := os.ReadDir(a.desktopSessions.root) |
| 409 | if readErr != nil && !os.IsNotExist(readErr) { |
| 410 | return readErr |
| 411 | } |
| 412 | for _, entry := range entries { |
| 413 | if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || known[entry.Name()] { |
| 414 | continue |
| 415 | } |
| 416 | if _, listed := infos[entry.Name()]; listed { |
| 417 | continue |
| 418 | } |
| 419 | path := filepath.Join(a.desktopSessions.root, entry.Name()) |
| 420 | if _, err := os.Lstat(filepath.Join(path, "manifest.json")); os.IsNotExist(err) { |
| 421 | continue |
| 422 | } |
| 423 | joined = errors.Join(joined, a.workspaceRegistry().ReconcileDiscoveredSession(ctx, workspacestate.RecoveryEntry{ |
| 424 | ID: "canonical-" + entry.Name(), SourceKey: "canonical:" + entry.Name(), SessionID: entry.Name(), Format: "canonical", Reason: "unreadable_content", Status: "failed", |
| 425 | }, nil)) |
| 426 | } |
| 427 | for id, info := range infos { |
| 428 | if known[id] { |
| 429 | continue |
| 430 | } |
| 431 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id} |
| 432 | reason := "" |
| 433 | if info.Origin == "" || strings.TrimSpace(info.CWD) == "" { |
| 434 | reason = "workspace_conflict" |
| 435 | } |
| 436 | if _, err := a.desktopSessionService("").Query().Snapshot(ctx, ref); err != nil { |
| 437 | reason = "unreadable_content" |
| 438 | } |
| 439 | if status, ok := state.SessionStates[id]; ok && status.Lifecycle != workspacestate.Active { |
| 440 | reason = "historical_state_unknown" |
| 441 | } |
| 442 | if reason != "" { |
| 443 | err := a.workspaceRegistry().ReconcileDiscoveredSession(ctx, workspacestate.RecoveryEntry{ID: "canonical-" + id, SourceKey: "canonical:" + id, SessionID: id, Format: "canonical", Reason: reason, Status: "pending"}, nil) |
| 444 | joined = errors.Join(joined, err) |
| 445 | continue |
| 446 | } |
| 447 | scope, root := "project", info.CWD |
| 448 | if sameDesktopPath(root, globalWorkspaceRoot()) { |
| 449 | scope, root = "global", "" |
| 450 | } |
| 451 | title := workspaceName(root) |
| 452 | if scope == "global" { |
| 453 | title = globalProjectTitle() |
| 454 | } |
| 455 | err := a.workspaceRegistry().ReconcileDiscoveredSession(ctx, |
| 456 | workspacestate.RecoveryEntry{ID: "canonical-" + id, SourceKey: "canonical:" + id, SessionID: id, Format: "canonical", Status: "pending"}, |
| 457 | &workspacestate.Workspace{ID: desktopWorkspaceID(scope, root), Root: desktopWorkspaceRoot(scope, root), Title: title, Visible: true}) |
| 458 | joined = errors.Join(joined, err) |
| 459 | } |
| 460 | return joined |
| 461 | } |
| 462 | |
| 463 | func (a *App) recoverDesktopSessionOperations(ctx context.Context) error { |
| 464 | return a.recoverDesktopOperations(ctx, true) |
| 465 | } |
| 466 | |
| 467 | func (a *App) recoverDesktopOperations(ctx context.Context, includeHistorical bool) error { |
| 468 | state, err := a.workspaceRegistry().Load(ctx) |
| 469 | if err != nil { |
| 470 | return err |
| 471 | } |
| 472 | replay := func(op workspacestate.Operation) error { |
| 473 | if err := ctx.Err(); err != nil { |
| 474 | return err |
| 475 | } |
| 476 | if !includeHistorical && (op.Kind == "import" || op.Kind == "restore" || op.Kind == "archive-import") { |
| 477 | return nil |
| 478 | } |
| 479 | release, ok := a.tryLockRuntimeMutation("replay session lifecycle") |
| 480 | if !ok { |
| 481 | return errTopicArchiveBusy |
| 482 | } |
| 483 | defer release() |
| 484 | return a.replayDesktopSessionOperation(ctx, state, op) |
| 485 | } |
| 486 | var joined error |
| 487 | for _, op := range state.PendingOperations { |
| 488 | if op.Kind != "purge" || op.Phase == "committed" { |
| 489 | continue |
| 490 | } |
| 491 | joined = errors.Join(joined, replay(op)) |
| 492 | } |
| 493 | for _, op := range state.PendingOperations { |
| 494 | if op.Phase == "committed" || op.Kind == "archive-import" || op.Kind == "command" || op.Kind == "purge" { |
| 495 | continue |
| 496 | } |
| 497 | joined = errors.Join(joined, replay(op)) |
| 498 | } |
| 499 | for _, op := range state.PendingOperations { |
| 500 | if op.Kind != "command" || op.Phase == "committed" { |
| 501 | continue |
| 502 | } |
| 503 | var req SessionLifecycleRequest |
| 504 | if err := json.Unmarshal(op.Request, &req); err != nil { |
| 505 | joined = errors.Join(joined, err) |
| 506 | continue |
| 507 | } |
| 508 | if !includeHistorical && req.Action == "restore" { |
| 509 | continue |
| 510 | } |
| 511 | _, err := a.ApplySessionLifecycle(req) |
| 512 | joined = errors.Join(joined, err) |
| 513 | } |
| 514 | return joined |
| 515 | } |
| 516 | |
| 517 | func (a *App) replayDesktopSessionOperation(ctx context.Context, state workspacestate.State, op workspacestate.Operation) error { |
| 518 | if op.Kind == "purge" { |
| 519 | if len(op.SessionIDs) != 1 { |
| 520 | return workspacestate.ErrMutationConflict |
| 521 | } |
| 522 | if err := a.resumeCanonicalPurge(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: op.SessionIDs[0]}, op); err != nil { |
| 523 | return fmt.Errorf("replay purge session=%s phase=%s expected_generation=%d: %w", op.SessionIDs[0], op.Phase, op.ExpectedGeneration, err) |
| 524 | } |
| 525 | return nil |
| 526 | } |
| 527 | if err := validateDesktopOperationSources(state, op); err != nil { |
| 528 | return err |
| 529 | } |
| 530 | if op.Phase == "prepared" && op.Mapping != nil { |
| 531 | return a.replayPreparedImport(ctx, state, op) |
| 532 | } |
| 533 | if len(op.SessionIDs) == 0 { |
| 534 | return workspacestate.ErrMutationConflict |
| 535 | } |
| 536 | guards := []func(){} |
| 537 | defer func() { |
| 538 | for _, release := range slices.Backward(guards) { |
| 539 | release() |
| 540 | } |
| 541 | }() |
| 542 | removed := []removedSessionRuntime{} |
| 543 | if op.Lifecycle == workspacestate.Archived || op.Lifecycle == workspacestate.Deleted { |
| 544 | var err error |
| 545 | removed, err = a.idleArchiveRuntimes(op.SessionIDs, nil) |
| 546 | if err != nil { |
| 547 | return err |
| 548 | } |
| 549 | } |
| 550 | service := a.desktopSessionService("") |
| 551 | for _, id := range op.SessionIDs { |
| 552 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id} |
| 553 | if runtime, live := service.Runtime(ref); live { |
| 554 | phase := runtime.StateSnapshot().Phase |
| 555 | if phase != session.RuntimeIdle && phase != session.RuntimeRecoveryRequired { |
| 556 | return errTopicHasActiveWork |
| 557 | } |
| 558 | } else { |
| 559 | guard, err := session.NewFilesystemPersistence(a.desktopSessions.root).AcquireMaintenance(id) |
| 560 | if err != nil { |
| 561 | return err |
| 562 | } |
| 563 | guards = append(guards, guard) |
| 564 | } |
| 565 | if _, err := service.Query().Snapshot(ctx, ref); err != nil { |
| 566 | return err |
| 567 | } |
| 568 | if op.WorkspaceID != "" { |
| 569 | if err := a.validateDesktopWorkspaceMembership(ctx, op.WorkspaceID, ref); err != nil { |
| 570 | return err |
| 571 | } |
| 572 | } |
| 573 | } |
| 574 | if op.Phase == "prepared" { |
| 575 | if err := a.workspaceRegistry().PrepareOperationContent(ctx, op.ID, op.SessionIDs, op.Mapping, op.Presentation); err != nil { |
| 576 | return err |
| 577 | } |
| 578 | } |
| 579 | if err := a.workspaceRegistry().CommitOperation(ctx, op.ID); err != nil { |
| 580 | return err |
| 581 | } |
| 582 | if len(removed) > 0 { |
| 583 | a.finishArchivedRuntimeBindings(removed) |
| 584 | } |
| 585 | return nil |
| 586 | } |
| 587 | |
| 588 | func (a *App) restoreLegacyRecoveryPath(path string) error { |
| 589 | dir, err := a.trashedSessionDir(path) |
| 590 | if err != nil { |
| 591 | return err |
| 592 | } |
| 593 | _, key, _, err := validateTrashedSessionPath(dir, path) |
| 594 | if err != nil { |
| 595 | return err |
| 596 | } |
| 597 | target := filepath.Join(dir, key) |
| 598 | if a.sessionDestroying(dir, target) || agent.IsCleanupPending(target) { |
| 599 | return fmt.Errorf("session cleanup is still in progress: %s", key) |
| 600 | } |
| 601 | if a.sessionOpen(dir, target) { |
| 602 | return fmt.Errorf("session is open: %s", key) |
| 603 | } |
| 604 | scope, root := "global", "" |
| 605 | if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && meta.WorkspaceRoot != "" && !sameDesktopPath(meta.WorkspaceRoot, globalWorkspaceRoot()) { |
| 606 | scope, root = "project", meta.WorkspaceRoot |
| 607 | } |
| 608 | if err := a.sourceRecovery(a.bootContext(), path, "legacy-trash", "historical_state_unknown", scope, root); err != nil { |
| 609 | return err |
| 610 | } |
| 611 | fingerprint, err := desktopSourceFingerprint(path) |
| 612 | if err != nil { |
| 613 | return err |
| 614 | } |
| 615 | _, err = a.RestoreRecoveryEntry(desktopRecoveryID(desktopSourceKey(path, ""), fingerprint), "") |
| 616 | return err |
| 617 | } |
| 618 | |
| 619 | func (a *App) selectRecoveryWorkspace(ctx context.Context, entry workspacestate.RecoveryEntry, workspaceID string) (workspacestate.RecoveryEntry, error) { |
| 620 | if workspaceID != "" { |
| 621 | state, err := a.workspaceRegistry().Load(ctx) |
| 622 | if err != nil { |
| 623 | return entry, err |
| 624 | } |
| 625 | valid := false |
| 626 | for _, choice := range a.recoveryWorkspaceChoices(ctx, state, entry) { |
| 627 | valid = valid || choice.ID == workspaceID |
| 628 | } |
| 629 | if !valid { |
| 630 | return entry, errors.New("recovery workspace is not an allowed destination") |
| 631 | } |
| 632 | w := state.Workspaces[workspaceID] |
| 633 | entry.Scope, entry.WorkspaceRoot = "project", w.Root |
| 634 | if workspaceID == workspacestate.GlobalWorkspaceID { |
| 635 | entry.Scope, entry.WorkspaceRoot = "global", "" |
| 636 | } |
| 637 | } |
| 638 | return entry, nil |
| 639 | } |
| 640 | |
| 641 | func (a *App) replayPreparedImport(ctx context.Context, state workspacestate.State, op workspacestate.Operation) error { |
| 642 | mapping := op.Mapping |
| 643 | workspace, ok := state.Workspaces[op.WorkspaceID] |
| 644 | if !ok { |
| 645 | return workspacestate.ErrWorkspaceNotFound |
| 646 | } |
| 647 | scope := "project" |
| 648 | if workspace.ID == workspacestate.GlobalWorkspaceID { |
| 649 | scope = "global" |
| 650 | } |
| 651 | source := desktopMigrationSource{scope: scope, workspaceRoot: workspace.Root, operationID: op.ID, headID: mapping.HeadID} |
| 652 | if mapping.SourceKey == desktopSourceKey(mapping.Path, mapping.HeadID)+":review:"+mapping.Fingerprint { |
| 653 | source.versionFingerprint = mapping.Fingerprint |
| 654 | } |
| 655 | if mapping.Format == "legacy" { |
| 656 | return a.migrateLegacySession(ctx, mapping.Path, source, workspace.ID) |
| 657 | } |
| 658 | if mapping.Format == "canonical" { |
| 659 | source.root = filepath.Dir(mapping.Path) |
| 660 | old, err := session.NewService("migration-source", session.NewFilesystemPersistence(source.root)) |
| 661 | if err != nil { |
| 662 | return err |
| 663 | } |
| 664 | defer func() { _ = old.Shutdown(context.Background()) }() |
| 665 | return a.migrateCanonicalSession(ctx, old, source, workspace.ID, filepath.Base(mapping.Path)) |
| 666 | } |
| 667 | return workspacestate.ErrUnsupportedVersion |
| 668 | } |
| 669 | |
| 670 | func validateRecoveryConflict(entry workspacestate.RecoveryEntry, workspaceID string) error { |
| 671 | if strings.Contains(entry.Reason, "conflict") && !(entry.Reason == "workspace_conflict" && workspaceID != "") { |
| 672 | return errors.New("historical session sources conflict; originals were preserved") |
| 673 | } |
| 674 | return nil |
| 675 | } |
| 676 |