返回 DeepSeek-Reasonix
session_recovery_api.go
根目录 / desktop / session_recovery_api.go
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
676 lines GO