返回 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) || (id == workspacestate.GlobalWorkspaceID && isGlobalWorkspacePath(w, 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 != fingerprint {
279 rebased, err := a.workspaceRegistry().RebaseRecovery(ctx, id, fingerprint)
280 if err != nil {
281 return SessionRestoreResult{}, err
282 }
283 if rebased.SessionID != "" {
284 return a.restoreCanonicalSession(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: rebased.SessionID}, operationID, id)
285 }
286 entry.Fingerprint = rebased.Fingerprint
287 }
288 workspaceID, err = a.ensureDesktopWorkspace(ctx, entry.Scope, entry.WorkspaceRoot)
289 if err != nil {
290 return SessionRestoreResult{}, err
291 }
292 state, err := a.workspaceRegistry().Load(ctx)
293 if err != nil {
294 return SessionRestoreResult{}, err
295 }
296 if previous, ok := state.PendingOperations[operationID]; ok && previous.Phase == "committed" {
297 if previous.RecoveryEntryID != id || len(previous.SessionIDs) != 1 {
298 return SessionRestoreResult{}, workspacestate.ErrMutationConflict
299 }
300 return SessionRestoreResult{Session: session.SessionRef{HostID: localDesktopHostID, SessionID: previous.SessionIDs[0]}, WorkspaceID: previous.WorkspaceID, Generation: previous.ResultGeneration}, nil
301 }
302 op := workspacestate.Operation{ID: operationID, Kind: "restore", RecoveryEntryID: id, WorkspaceID: workspaceID, Lifecycle: workspacestate.Active, ExpectedGeneration: state.Generation}
303 if err := a.workspaceRegistry().BeginOperation(ctx, op); err != nil {
304 return SessionRestoreResult{}, err
305 }
306 source := desktopMigrationSource{scope: entry.Scope, workspaceRoot: entry.WorkspaceRoot, operationID: operationID, headID: entry.HeadID}
307 if entry.Reason == "source_changed_after_adoption" {
308 source.versionFingerprint = fingerprint
309 }
310 err = a.convertHistoricalSource(ctx, historicalSource{path: entry.Path, format: entry.Format}, source, workspaceID)
311 if err != nil {
312 return SessionRestoreResult{}, err
313 }
314 state, err = a.workspaceRegistry().Load(ctx)
315 if err != nil {
316 return SessionRestoreResult{}, err
317 }
318 op = state.PendingOperations[operationID]
319 if op.Phase != "committed" || len(op.SessionIDs) != 1 {
320 return SessionRestoreResult{}, errors.New("historical restore is pending")
321 }
322 a.emitProjectTreeChanged()
323 return SessionRestoreResult{Session: session.SessionRef{HostID: localDesktopHostID, SessionID: op.SessionIDs[0]}, WorkspaceID: workspaceID, Generation: op.ResultGeneration}, nil
324 }
325
326 func (a *App) discoverHistoricalTrash(ctx context.Context) error {
327 var joined error
328 for _, dir := range a.knownSessionDirs() {
329 if err := ctx.Err(); err != nil {
330 return err
331 }
332 paths, err := listTrashedSessionFiles(dir)
333 if err != nil {
334 joined = errors.Join(joined, err)
335 continue
336 }
337 for _, path := range paths {
338 scope, root := "global", ""
339 if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && meta.WorkspaceRoot != "" && !a.isGlobalWorkspacePath(ctx, meta.WorkspaceRoot) {
340 scope, root = "project", meta.WorkspaceRoot
341 }
342 // Old "deleted" entries were recoverable trash, not permanent
343 // deletion. Preserve that affordance in the single archived list.
344 if explicitlyDeletedLegacyEntry(path) {
345 fingerprint, err := desktopSourceFingerprint(path)
346 if err != nil {
347 joined = errors.Join(joined, err)
348 continue
349 }
350 source := desktopMigrationSource{scope: scope, workspaceRoot: root, deferArchive: true}
351 if err := a.migrateLegacySession(ctx, path, source, ""); err != nil {
352 joined = errors.Join(joined, err)
353 continue
354 }
355 state, err := a.workspaceRegistry().Load(ctx)
356 if err != nil {
357 joined = errors.Join(joined, err)
358 continue
359 }
360 opID := "archive-import-" + desktopSourceKey(path, "") + "-" + fingerprint
361 if op, ok := state.PendingOperations[opID]; ok && op.Phase == "content_ready" {
362 joined = errors.Join(joined, a.workspaceRegistry().CommitHistoricalArchive(ctx, opID, trashedSessionDeletedAt(path)))
363 }
364 continue
365 }
366 if err := a.sourceRecovery(ctx, path, "legacy-trash", "historical_state_unknown", scope, root); err != nil {
367 joined = errors.Join(joined, err)
368 }
369 joined = errors.Join(joined, a.discoverLegacyHeads(ctx, path, "legacy-trash", scope, root))
370 }
371 }
372 return joined
373 }
374
375 func explicitlyDeletedLegacyEntry(path string) bool {
376 body, err := os.ReadFile(filepath.Join(filepath.Dir(path), sessionTrashMetaFile))
377 if err != nil {
378 return false
379 }
380 var meta trashedSessionMeta
381 return json.Unmarshal(body, &meta) == nil && meta.Kind == "deleted"
382 }
383
384 func (a *App) reconcileUnregisteredSessions(ctx context.Context) error {
385 state, err := a.workspaceRegistry().Load(ctx)
386 if err != nil {
387 return err
388 }
389 known := map[string]bool{}
390 for id, status := range state.SessionStates {
391 if status.Lifecycle == workspacestate.Deleted {
392 known[id] = true
393 }
394 }
395 for _, workspace := range state.Workspaces {
396 for _, id := range workspace.SessionIDs {
397 known[id] = true
398 }
399 }
400 for id := range state.PendingCreates {
401 known[id] = true
402 }
403 for _, op := range state.PendingOperations {
404 if op.Phase != "committed" {
405 for _, id := range op.SessionIDs {
406 known[id] = true
407 }
408 }
409 }
410 infos, err := listAllCanonicalSessionInfo(ctx, a.desktopSessionService("").Query())
411 if err != nil {
412 return err
413 }
414 var joined error
415 entries, readErr := os.ReadDir(a.desktopSessions.root)
416 if readErr != nil && !os.IsNotExist(readErr) {
417 return readErr
418 }
419 for _, entry := range entries {
420 if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || known[entry.Name()] {
421 continue
422 }
423 if _, listed := infos[entry.Name()]; listed {
424 continue
425 }
426 path := filepath.Join(a.desktopSessions.root, entry.Name())
427 if _, err := os.Lstat(filepath.Join(path, "manifest.json")); os.IsNotExist(err) {
428 continue
429 }
430 joined = errors.Join(joined, a.workspaceRegistry().ReconcileDiscoveredSession(ctx, workspacestate.RecoveryEntry{
431 ID: "canonical-" + entry.Name(), SourceKey: "canonical:" + entry.Name(), SessionID: entry.Name(), Format: "canonical", Reason: "unreadable_content", Status: "failed",
432 }, nil))
433 }
434 for id, info := range infos {
435 if known[id] {
436 continue
437 }
438 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id}
439 reason := ""
440 if info.Origin == "" || strings.TrimSpace(info.CWD) == "" {
441 reason = "workspace_conflict"
442 }
443 if _, err := a.desktopSessionService("").Query().Snapshot(ctx, ref); err != nil {
444 reason = "unreadable_content"
445 }
446 if status, ok := state.SessionStates[id]; ok && status.Lifecycle != workspacestate.Active {
447 reason = "historical_state_unknown"
448 }
449 if reason != "" {
450 err := a.workspaceRegistry().ReconcileDiscoveredSession(ctx, workspacestate.RecoveryEntry{ID: "canonical-" + id, SourceKey: "canonical:" + id, SessionID: id, Format: "canonical", Reason: reason, Status: "pending"}, nil)
451 joined = errors.Join(joined, err)
452 continue
453 }
454 scope, root := "project", info.CWD
455 if isGlobalWorkspacePath(state.Workspaces[workspacestate.GlobalWorkspaceID], root) {
456 scope, root = "global", ""
457 }
458 title := workspaceName(root)
459 if scope == "global" {
460 title = globalProjectTitle()
461 }
462 err := a.workspaceRegistry().ReconcileDiscoveredSession(ctx,
463 workspacestate.RecoveryEntry{ID: "canonical-" + id, SourceKey: "canonical:" + id, SessionID: id, Format: "canonical", Status: "pending"},
464 &workspacestate.Workspace{ID: desktopWorkspaceID(scope, root), Root: desktopWorkspaceRoot(scope, root), Title: title, Visible: true})
465 joined = errors.Join(joined, err)
466 }
467 return joined
468 }
469
470 func (a *App) recoverDesktopSessionOperations(ctx context.Context) error {
471 return a.recoverDesktopOperations(ctx, true)
472 }
473
474 func (a *App) recoverDesktopOperations(ctx context.Context, includeHistorical bool) error {
475 state, err := a.workspaceRegistry().Load(ctx)
476 if err != nil {
477 return err
478 }
479 replay := func(op workspacestate.Operation) error {
480 if err := ctx.Err(); err != nil {
481 return err
482 }
483 if !includeHistorical && (op.Kind == "import" || op.Kind == "restore" || op.Kind == "archive-import") {
484 return nil
485 }
486 release, ok := a.tryLockRuntimeMutation("replay session lifecycle")
487 if !ok {
488 return errTopicArchiveBusy
489 }
490 defer release()
491 return a.replayDesktopSessionOperation(ctx, state, op)
492 }
493 joined := a.reconcileTopicRemovals(state)
494 for _, op := range state.PendingOperations {
495 if op.Kind != "purge" || op.Phase == "committed" {
496 continue
497 }
498 joined = errors.Join(joined, replay(op))
499 }
500 for _, op := range state.PendingOperations {
501 if op.Phase == "committed" || op.Kind == "archive-import" || op.Kind == "command" || op.Kind == "purge" {
502 continue
503 }
504 joined = errors.Join(joined, replay(op))
505 }
506 for _, op := range state.PendingOperations {
507 if op.Kind != "command" || op.Phase == "committed" {
508 continue
509 }
510 var req SessionLifecycleRequest
511 if err := json.Unmarshal(op.Request, &req); err != nil {
512 joined = errors.Join(joined, err)
513 continue
514 }
515 if !includeHistorical && req.Action == "restore" {
516 continue
517 }
518 _, err := a.ApplySessionLifecycle(req)
519 joined = errors.Join(joined, err)
520 }
521 return joined
522 }
523
524 func (a *App) replayDesktopSessionOperation(ctx context.Context, state workspacestate.State, op workspacestate.Operation) error {
525 if op.Kind == "purge" {
526 if len(op.SessionIDs) != 1 {
527 return workspacestate.ErrMutationConflict
528 }
529 if err := a.resumeCanonicalPurge(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: op.SessionIDs[0]}, op); err != nil {
530 return fmt.Errorf("replay purge session=%s phase=%s expected_generation=%d: %w", op.SessionIDs[0], op.Phase, op.ExpectedGeneration, err)
531 }
532 return nil
533 }
534 releaseSources, err := freezeArchiveDependencies(ctx, state, op)
535 if err != nil {
536 return err
537 }
538 defer releaseSources()
539 if err := validateDesktopOperationSources(state, op); err != nil {
540 return err
541 }
542 if op.Phase == "prepared" && op.Mapping != nil {
543 return a.replayPreparedImport(ctx, state, op)
544 }
545 if len(op.SessionIDs) == 0 {
546 return workspacestate.ErrMutationConflict
547 }
548 guards := []func(){}
549 defer func() {
550 for _, release := range slices.Backward(guards) {
551 release()
552 }
553 }()
554 removed := []removedSessionRuntime{}
555 if op.Lifecycle == workspacestate.Archived || op.Lifecycle == workspacestate.Deleted {
556 var err error
557 removed, err = a.idleArchiveRuntimes(op.SessionIDs, nil)
558 if err != nil {
559 return err
560 }
561 }
562 service := a.desktopSessionService("")
563 for _, id := range op.SessionIDs {
564 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id}
565 if runtime, live := service.Runtime(ref); live {
566 phase := runtime.StateSnapshot().Phase
567 if phase != session.RuntimeIdle && phase != session.RuntimeRecoveryRequired {
568 return errTopicHasActiveWork
569 }
570 } else {
571 guard, err := session.NewFilesystemPersistence(a.desktopSessions.root).AcquireMaintenance(id)
572 if err != nil {
573 return err
574 }
575 guards = append(guards, guard)
576 }
577 if _, err := service.Query().Snapshot(ctx, ref); err != nil {
578 return err
579 }
580 if op.WorkspaceID != "" {
581 if err := a.validateDesktopWorkspaceMembership(ctx, op.WorkspaceID, ref); err != nil {
582 return err
583 }
584 }
585 }
586 if err := a.validateRecoveredHistoricalArchive(ctx, state, op); err != nil {
587 return err
588 }
589 if op.Phase == "prepared" {
590 if err := a.workspaceRegistry().PrepareOperationContent(ctx, op.ID, op.SessionIDs, op.Mapping, op.Presentation); err != nil {
591 return err
592 }
593 }
594 if err := a.workspaceRegistry().CommitOperation(ctx, op.ID); err != nil {
595 return err
596 }
597 if len(removed) > 0 {
598 a.finishArchivedRuntimeBindings(removed)
599 }
600 return nil
601 }
602
603 func (a *App) restoreLegacyRecoveryPath(path string) error {
604 dir, err := a.trashedSessionDir(path)
605 if err != nil {
606 return err
607 }
608 _, key, _, err := validateTrashedSessionPath(dir, path)
609 if err != nil {
610 return err
611 }
612 target := filepath.Join(dir, key)
613 if a.sessionDestroying(dir, target) || agent.IsCleanupPending(target) {
614 return fmt.Errorf("session cleanup is still in progress: %s", key)
615 }
616 if a.sessionOpen(dir, target) {
617 return fmt.Errorf("session is open: %s", key)
618 }
619 scope, root := "global", ""
620 if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && meta.WorkspaceRoot != "" && !a.isGlobalWorkspacePath(a.bootContext(), meta.WorkspaceRoot) {
621 scope, root = "project", meta.WorkspaceRoot
622 }
623 if err := a.sourceRecovery(a.bootContext(), path, "legacy-trash", "historical_state_unknown", scope, root); err != nil {
624 return err
625 }
626 fingerprint, err := desktopSourceFingerprint(path)
627 if err != nil {
628 return err
629 }
630 _, err = a.RestoreRecoveryEntry(desktopRecoveryID(desktopSourceKey(path, ""), fingerprint), "")
631 return err
632 }
633
634 func (a *App) selectRecoveryWorkspace(ctx context.Context, entry workspacestate.RecoveryEntry, workspaceID string) (workspacestate.RecoveryEntry, error) {
635 if workspaceID != "" {
636 state, err := a.workspaceRegistry().Load(ctx)
637 if err != nil {
638 return entry, err
639 }
640 valid := false
641 for _, choice := range a.recoveryWorkspaceChoices(ctx, state, entry) {
642 valid = valid || choice.ID == workspaceID
643 }
644 if !valid {
645 return entry, errors.New("recovery workspace is not an allowed destination")
646 }
647 w := state.Workspaces[workspaceID]
648 entry.Scope, entry.WorkspaceRoot = "project", w.Root
649 if workspaceID == workspacestate.GlobalWorkspaceID {
650 entry.Scope, entry.WorkspaceRoot = "global", ""
651 }
652 }
653 return entry, nil
654 }
655
656 func (a *App) replayPreparedImport(ctx context.Context, state workspacestate.State, op workspacestate.Operation) error {
657 mapping := op.Mapping
658 workspace, ok := state.Workspaces[op.WorkspaceID]
659 if !ok {
660 return workspacestate.ErrWorkspaceNotFound
661 }
662 scope := "project"
663 if workspace.ID == workspacestate.GlobalWorkspaceID {
664 scope = "global"
665 }
666 source := desktopMigrationSource{scope: scope, workspaceRoot: workspace.Root, operationID: op.ID, headID: mapping.HeadID}
667 if strings.HasPrefix(op.ID, "repair-") {
668 source.registeredSourceKey = mapping.SourceKey
669 old, ref, finish, err := openHistoricalReconciliationSource(ctx, historicalSource{path: mapping.Path, format: mapping.Format, head: mapping.HeadID})
670 if err != nil {
671 return err
672 }
673 defer finish()
674 return a.repairMissingHistoricalTarget(ctx, old, ref, source, mapping.Path, mapping.SessionID, mapping.Fingerprint)
675 }
676 if mapping.SourceKey == desktopSourceKey(mapping.Path, mapping.HeadID)+":review:"+mapping.Fingerprint {
677 source.versionFingerprint = mapping.Fingerprint
678 }
679 if mapping.Format == "legacy" {
680 return a.migrateLegacySession(ctx, mapping.Path, source, workspace.ID)
681 }
682 if mapping.Format == "canonical" {
683 source.root = filepath.Dir(mapping.Path)
684 old, err := session.NewService("migration-source", session.NewFilesystemPersistence(source.root))
685 if err != nil {
686 return err
687 }
688 defer func() { _ = old.Shutdown(context.Background()) }()
689 return a.migrateCanonicalSession(ctx, old, source, workspace.ID, filepath.Base(mapping.Path))
690 }
691 return workspacestate.ErrUnsupportedVersion
692 }
693
694 func validateRecoveryConflict(entry workspacestate.RecoveryEntry, workspaceID string) error {
695 if strings.Contains(entry.Reason, "conflict") && !(entry.Reason == "workspace_conflict" && workspaceID != "") {
696 return errors.New("historical session sources conflict; originals were preserved")
697 }
698 return nil
699 }
700
700 lines GO