返回 DeepSeek-Reasonix
saved_tab_reconcile.go
根目录 / desktop / saved_tab_reconcile.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "reasonix/desktop/internal/draftstate"
13 "reasonix/desktop/internal/legacycleanup"
14 "reasonix/desktop/internal/workspacestate"
15 "reasonix/internal/agent"
16 "reasonix/internal/session"
17 )
18
19 type savedTabReconcileOutcome string
20
21 const (
22 restoreTab savedTabReconcileOutcome = "restore"
23 dropStalePresentation savedTabReconcileOutcome = "drop_stale_presentation"
24 archiveEmptyThenDrop savedTabReconcileOutcome = "archive_empty_then_drop"
25 preserveRecovery savedTabReconcileOutcome = "preserve_recovery"
26 preserveError savedTabReconcileOutcome = "preserve_error"
27 )
28
29 type savedTabReconcileDecision struct {
30 outcome savedTabReconcileOutcome
31 reason string
32 identityKind string
33 waitedForMigration bool
34 hadPending bool
35 hadRecoveryOwner bool
36 repairedIdentity bool
37 }
38
39 type savedTabReconcileEvidence struct {
40 registry workspacestate.State
41 registryErr error
42 draftOps []draftstate.Operation
43 draftErr error
44 cleanup legacycleanup.State
45 }
46
47 func (a *App) reconcileTabsBeforeRestore(ctx context.Context, file desktopTabsFile, version uint64) (desktopTabsFile, uint64, bool) {
48 original := file
49 reconciled, changed := a.reconcileSavedTabs(ctx, file)
50 if !a.tabsSnapshotCurrent(version) {
51 return file, version, false
52 }
53 if changed {
54 committedVersion, err := a.persistReconciledTabsFile(reconciled, version)
55 if committedVersion != 0 {
56 version = committedVersion
57 }
58 if errors.Is(err, errTabsSnapshotChanged) {
59 return file, version, false
60 }
61 if err != nil {
62 slog.Warn("desktop_saved_tab_reconcile_persist_failed", "reason", "write_failed")
63 return blockSavedTabUnsafeRestore(original, "identity_repair_write_failed"), version, a.tabsSnapshotCurrent(version)
64 }
65 }
66 return reconciled, version, a.tabsSnapshotCurrent(version)
67 }
68
69 // reconcileSavedTabs filters only presentation entries whose durable identity
70 // is conclusively gone. It runs before restored tabs are published, so a stale
71 // entry can neither block legacy cleanup nor acquire a controller or lease.
72 func (a *App) reconcileSavedTabs(ctx context.Context, file desktopTabsFile) (desktopTabsFile, bool) {
73 file.Tabs = append([]desktopTabEntry(nil), file.Tabs...)
74 if len(file.Tabs) == 0 {
75 file.Tabs = []desktopTabEntry{}
76 return file, false
77 }
78
79 fast := a.loadSavedTabReconcileEvidence(ctx, false)
80 decisions := make([]savedTabReconcileDecision, len(file.Tabs))
81 needsMigration := make([]bool, len(file.Tabs))
82 anyNeedsMigration := false
83 repairedIdentity := false
84 for index := range file.Tabs {
85 if candidate := savedTabRouteCandidateForPath(file.Tabs[index].SessionPath); candidate.kind != "" {
86 decisions[index].identityKind = candidate.kind
87 needsMigration[index] = true
88 anyNeedsMigration = true
89 continue
90 }
91 if sessionID, found, conflict := savedTabPendingSessionIdentity(file.Tabs[index], fast); found && !conflict {
92 file.Tabs[index].SessionID = sessionID
93 repairedIdentity = true
94 }
95 entry := file.Tabs[index]
96 decision, final := a.classifySavedTab(entry, fast, false)
97 decisions[index] = decision
98 needsMigration[index] = !final
99 anyNeedsMigration = anyNeedsMigration || !final
100 }
101
102 if anyNeedsMigration {
103 migrationFinished := a.waitForDesktopMigration(ctx)
104 afterMigration := a.loadSavedTabReconcileEvidence(ctx, true)
105 for index := range file.Tabs {
106 if !needsMigration[index] {
107 continue
108 }
109 identityKind := decisions[index].identityKind
110 if !migrationFinished {
111 decisions[index] = savedTabReconcileDecision{outcome: preserveError, reason: "migration_interrupted", identityKind: identityKind, waitedForMigration: true}
112 continue
113 }
114 if identityKind != "" {
115 normalized, repaired, override := a.normalizeSavedTabRoute(ctx, file.Tabs[index], afterMigration)
116 if override != nil {
117 override.identityKind = identityKind
118 override.waitedForMigration = true
119 decisions[index] = *override
120 continue
121 }
122 file.Tabs[index] = normalized
123 if repaired {
124 repairedIdentity = true
125 }
126 }
127 if sessionID, found, conflict := savedTabPendingSessionIdentity(file.Tabs[index], afterMigration); found && !conflict {
128 file.Tabs[index].SessionID = sessionID
129 repairedIdentity = true
130 }
131 if source := a.savedTabHistoricalSource(file.Tabs[index], afterMigration); source != nil {
132 file.Tabs[index].historicalSource = source
133 decisions[index] = savedTabReconcileDecision{outcome: preserveRecovery, reason: "historical_source_pending", waitedForMigration: true}
134 continue
135 }
136 decision, _ := a.classifySavedTab(file.Tabs[index], afterMigration, true)
137 if identityKind != "" {
138 decision.identityKind = identityKind
139 }
140 decision.repairedIdentity = strings.TrimSpace(file.Tabs[index].SessionID) != "" && strings.TrimSpace(file.Tabs[index].SessionPath) == "" && identityKind != ""
141 decision.waitedForMigration = true
142 decisions[index] = decision
143 }
144 }
145
146 var removed map[string]bool
147 file.Tabs, removed = filterReconciledSavedTabs(file.Tabs, decisions)
148 if len(removed) == 0 && !repairedIdentity {
149 return file, false
150 }
151 repairReconciledTabSelection(&file, removed)
152 return file, true
153 }
154
155 func filterReconciledSavedTabs(tabs []desktopTabEntry, decisions []savedTabReconcileDecision) ([]desktopTabEntry, map[string]bool) {
156 filtered := make([]desktopTabEntry, 0, len(tabs))
157 removed := map[string]bool{}
158 for index, entry := range tabs {
159 decision := decisions[index]
160 if decision.outcome == dropStalePresentation || decision.outcome == archiveEmptyThenDrop {
161 removed[entry.ID] = true
162 } else {
163 if (entry.SessionPath != "" || entry.SessionID != "") && (decision.outcome == preserveError || decision.outcome == preserveRecovery) {
164 entry.restoreBlocked = true
165 entry.restoreBlockReason = decision.reason
166 }
167 filtered = append(filtered, entry)
168 }
169 if decision.outcome == restoreTab && !decision.waitedForMigration && !decision.repairedIdentity {
170 continue
171 }
172 identityKind := decision.identityKind
173 if identityKind == "" {
174 identityKind = savedTabIdentityKind(entry)
175 }
176 slog.Info("desktop_saved_tab_reconciled",
177 "outcome", decision.outcome,
178 "reason", decision.reason,
179 "identity_kind", identityKind,
180 "waited_for_migration", decision.waitedForMigration,
181 "had_pending_operation", decision.hadPending,
182 "had_recovery_owner", decision.hadRecoveryOwner,
183 )
184 }
185 return filtered, removed
186 }
187
188 func (a *App) waitForDesktopMigration(ctx context.Context) bool {
189 if a == nil || a.desktopMigrationDone == nil {
190 return false
191 }
192 if a.beforeSavedTabMigrationWait != nil {
193 a.beforeSavedTabMigrationWait()
194 }
195 select {
196 case <-a.desktopMigrationDone:
197 return !a.desktopMigrationFailed.Load()
198 case <-ctx.Done():
199 return false
200 }
201 }
202
203 func (a *App) loadSavedTabReconcileEvidence(ctx context.Context, includeCleanup bool) savedTabReconcileEvidence {
204 evidence := savedTabReconcileEvidence{}
205 evidence.registry, evidence.registryErr = a.workspaceRegistry().Load(ctx)
206 evidence.draftOps, evidence.draftErr = a.draftStore().PendingOperations(ctx)
207 if includeCleanup && a.legacyCleanup != nil {
208 evidence.cleanup, _ = a.legacyCleanup.Load(ctx)
209 }
210 return evidence
211 }
212
213 func (a *App) classifySavedTab(entry desktopTabEntry, evidence savedTabReconcileEvidence, afterMigration bool) (savedTabReconcileDecision, bool) {
214 if !afterMigration {
215 if evidence.registryErr != nil || evidence.draftErr != nil {
216 return savedTabReconcileDecision{}, false
217 }
218 if savedTabHasMatchingPendingCreate(entry, evidence) {
219 return savedTabReconcileDecision{outcome: restoreTab, reason: "pending_create", hadPending: true}, true
220 }
221 }
222
223 if sessionID := strings.TrimSpace(entry.SessionID); sessionID != "" {
224 return a.classifyCanonicalSavedTab(entry, sessionID, evidence, afterMigration)
225 }
226 if strings.TrimSpace(entry.SessionPath) != "" {
227 if !afterMigration {
228 return savedTabReconcileDecision{}, false
229 }
230 if _, ok, err := legacySessionPathForFileAccess(entry.SessionPath); err != nil || !ok {
231 return savedTabReconcileDecision{outcome: preserveError, reason: "invalid_legacy_path", identityKind: "invalid_legacy"}, true
232 }
233 return a.classifyLegacySavedTab(entry, evidence), true
234 }
235 if strings.TrimSpace(entry.CreateOperationID) != "" && !afterMigration {
236 return savedTabReconcileDecision{}, false
237 }
238 if strings.TrimSpace(entry.CreateOperationID) != "" {
239 return savedTabReconcileDecision{outcome: preserveError, reason: "pending_identity_unresolved", hadPending: true}, true
240 }
241 if afterMigration && (evidence.registryErr != nil || evidence.draftErr != nil) {
242 return savedTabReconcileDecision{outcome: preserveError, reason: "persistence_state_unavailable"}, true
243 }
244 return savedTabReconcileDecision{outcome: dropStalePresentation, reason: "identity_absent"}, true
245 }
246
247 func (a *App) classifyCanonicalSavedTab(entry desktopTabEntry, sessionID string, evidence savedTabReconcileEvidence, afterMigration bool) (savedTabReconcileDecision, bool) {
248 if evidence.registryErr != nil || evidence.draftErr != nil {
249 if afterMigration {
250 return savedTabReconcileDecision{outcome: preserveError, reason: "persistence_state_unavailable"}, true
251 }
252 return savedTabReconcileDecision{}, false
253 }
254 if savedTabHasMatchingPendingCreate(entry, evidence) {
255 return savedTabReconcileDecision{outcome: restoreTab, reason: "pending_create", hadPending: true}, true
256 }
257
258 status, registered := evidence.registry.SessionStates[sessionID]
259 if registered && (status.Lifecycle == workspacestate.Archived || status.Lifecycle == workspacestate.Deleted) {
260 return savedTabReconcileDecision{outcome: dropStalePresentation, reason: "inactive_lifecycle"}, true
261 }
262 info, statErr := a.desktopSessionService("").Query().Stat(a.bootContext(), session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID})
263 if !afterMigration {
264 if registered && status.Lifecycle == workspacestate.Active && statErr == nil {
265 workspace, consistent := savedTabCanonicalWorkspace(evidence.registry, info, sessionID)
266 if consistent && savedTabMatchesWorkspace(entry, workspace) {
267 return savedTabReconcileDecision{outcome: restoreTab, reason: "canonical_active"}, true
268 }
269 }
270 return savedTabReconcileDecision{}, false
271 }
272
273 recoveryOwner := savedTabHasRecoveryOwner(entry, evidence)
274 if registered && status.Lifecycle == workspacestate.Active {
275 if errors.Is(statErr, session.ErrSessionNotFound) || errors.Is(statErr, os.ErrNotExist) {
276 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "registered_session_missing", hadRecoveryOwner: true}, true
277 }
278 if statErr != nil || info.MetadataStatus == session.MetadataFailed {
279 return savedTabReconcileDecision{outcome: preserveError, reason: "canonical_session_unreadable", hadRecoveryOwner: recoveryOwner}, true
280 }
281 workspace, consistent := savedTabCanonicalWorkspace(evidence.registry, info, sessionID)
282 if !consistent {
283 if recoveryOwner {
284 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "recovery_owner_present", hadRecoveryOwner: true}, true
285 }
286 if a.archiveSavedTabEmptyCandidate(entry, evidence.cleanup) {
287 return savedTabReconcileDecision{outcome: archiveEmptyThenDrop, reason: "empty_workspace_conflict"}, true
288 }
289 return savedTabReconcileDecision{outcome: preserveError, reason: "canonical_workspace_conflict", hadRecoveryOwner: recoveryOwner}, true
290 }
291 if !savedTabMatchesWorkspace(entry, workspace) {
292 return savedTabReconcileDecision{outcome: restoreTab, reason: "repair_workspace"}, true
293 }
294 return savedTabReconcileDecision{outcome: restoreTab, reason: "canonical_active"}, true
295 }
296 if statErr == nil {
297 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "canonical_session_unregistered", hadRecoveryOwner: true}, true
298 }
299 if !errors.Is(statErr, session.ErrSessionNotFound) && !errors.Is(statErr, os.ErrNotExist) {
300 return savedTabReconcileDecision{outcome: preserveError, reason: "canonical_session_unreadable", hadRecoveryOwner: recoveryOwner}, true
301 }
302 if recoveryOwner {
303 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "recovery_owner_present", hadRecoveryOwner: true}, true
304 }
305 if present, err := a.savedTabCanonicalArtifactsPresent(entry, sessionID); err != nil {
306 return savedTabReconcileDecision{outcome: preserveError, reason: "canonical_artifacts_unreadable"}, true
307 } else if present {
308 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "canonical_artifacts_present", hadRecoveryOwner: true}, true
309 }
310 return savedTabReconcileDecision{outcome: dropStalePresentation, reason: "canonical_identity_absent"}, true
311 }
312
313 func (a *App) classifyLegacySavedTab(entry desktopTabEntry, evidence savedTabReconcileEvidence) savedTabReconcileDecision {
314 if evidence.registryErr != nil || evidence.draftErr != nil {
315 return savedTabReconcileDecision{outcome: preserveError, reason: "persistence_state_unavailable"}
316 }
317 path := strings.TrimSpace(entry.SessionPath)
318 mapping, mapped, mappingErr := selectedSavedTabSourceMapping(path, evidence.registry)
319 if mappingErr != nil {
320 return savedTabReconcileDecision{outcome: preserveError, reason: "legacy_mapping_unreadable"}
321 }
322 if mapped {
323 switch evidence.registry.SessionStates[mapping.SessionID].Lifecycle {
324 case workspacestate.Active:
325 return savedTabReconcileDecision{outcome: restoreTab, reason: "legacy_source_mapped", hadRecoveryOwner: true}
326 case workspacestate.Archived, workspacestate.Deleted:
327 fingerprint, err := desktopSourceFingerprint(path)
328 if errors.Is(err, os.ErrNotExist) {
329 if _, artifactErr := legacyCleanupSourceFingerprint(path); artifactErr == nil {
330 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "legacy_artifacts_present", hadRecoveryOwner: true}
331 } else if !errors.Is(artifactErr, os.ErrNotExist) {
332 return savedTabReconcileDecision{outcome: preserveError, reason: "legacy_artifacts_unreadable"}
333 }
334 return savedTabReconcileDecision{outcome: dropStalePresentation, reason: "mapped_session_inactive"}
335 }
336 if err != nil {
337 return savedTabReconcileDecision{outcome: preserveError, reason: "legacy_artifacts_unreadable"}
338 }
339 if mapping.Fingerprint != "" && fingerprint == mapping.Fingerprint {
340 return savedTabReconcileDecision{outcome: dropStalePresentation, reason: "mapped_session_inactive"}
341 }
342 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "legacy_source_changed", hadRecoveryOwner: true}
343 default:
344 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "source_mapping_incomplete", hadRecoveryOwner: true}
345 }
346 }
347 if savedTabHasRecoveryOwner(entry, evidence) {
348 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "recovery_owner_present", hadRecoveryOwner: true}
349 }
350 if _, err := legacyCleanupSourceFingerprint(path); err == nil {
351 return savedTabReconcileDecision{outcome: preserveRecovery, reason: "legacy_artifacts_present", hadRecoveryOwner: true}
352 } else if !errors.Is(err, os.ErrNotExist) {
353 return savedTabReconcileDecision{outcome: preserveError, reason: "legacy_artifacts_unreadable"}
354 }
355 return savedTabReconcileDecision{outcome: dropStalePresentation, reason: "legacy_identity_absent"}
356 }
357
358 func (a *App) savedTabCanonicalArtifactsPresent(entry desktopTabEntry, sessionID string) (bool, error) {
359 canonicalPath := filepath.Join(a.desktopSessions.root, sessionID)
360 if _, err := os.Lstat(canonicalPath); err == nil {
361 return true, nil
362 } else if !errors.Is(err, os.ErrNotExist) {
363 return false, err
364 }
365 legacyPath := strings.TrimSpace(entry.SessionPath)
366 if legacyPath == "" {
367 return false, nil
368 }
369 if _, err := legacyCleanupSourceFingerprint(legacyPath); err == nil {
370 return true, nil
371 } else if !errors.Is(err, os.ErrNotExist) {
372 return false, err
373 }
374 return false, nil
375 }
376
377 func selectedSavedTabSourceMapping(path string, state workspacestate.State) (workspacestate.SourceMapping, bool, error) {
378 for _, mapping := range state.SourceMappings {
379 if mapping.HeadID == "" && sessionRuntimeKey(mapping.Path) == sessionRuntimeKey(path) {
380 return mapping, true, nil
381 }
382 }
383 hasHeadMapping := false
384 for _, mapping := range state.SourceMappings {
385 if mapping.HeadID != "" && sessionRuntimeKey(mapping.Path) == sessionRuntimeKey(path) {
386 hasHeadMapping = true
387 break
388 }
389 }
390 if !hasHeadMapping {
391 return workspacestate.SourceMapping{}, false, nil
392 }
393 heads, err := agent.ListSessionHeads(path)
394 if err != nil {
395 return workspacestate.SourceMapping{}, false, err
396 }
397 for _, head := range heads {
398 if !head.Selected || head.Retired {
399 continue
400 }
401 for _, mapping := range state.SourceMappings {
402 if mapping.HeadID == head.ID && sessionRuntimeKey(mapping.Path) == sessionRuntimeKey(path) {
403 return mapping, true, nil
404 }
405 }
406 return workspacestate.SourceMapping{}, false, nil
407 }
408 return workspacestate.SourceMapping{}, false, nil
409 }
410
411 func savedTabHasMatchingPendingCreate(entry desktopTabEntry, evidence savedTabReconcileEvidence) bool {
412 operationID := strings.TrimSpace(entry.CreateOperationID)
413 sessionID := strings.TrimSpace(entry.SessionID)
414 if operationID == "" || sessionID == "" {
415 return false
416 }
417 if pending, ok := evidence.registry.PendingCreates[sessionID]; ok && pending.OperationID == operationID {
418 return true
419 }
420 for _, operation := range evidence.draftOps {
421 if operation.ID == operationID && operation.SessionID == sessionID {
422 return true
423 }
424 }
425 return false
426 }
427
428 func savedTabPendingSessionIdentity(entry desktopTabEntry, evidence savedTabReconcileEvidence) (string, bool, bool) {
429 if strings.TrimSpace(entry.SessionID) != "" {
430 return "", false, false
431 }
432 operationID := strings.TrimSpace(entry.CreateOperationID)
433 if operationID == "" || evidence.registryErr != nil || evidence.draftErr != nil {
434 return "", false, false
435 }
436 resolved := ""
437 accept := func(sessionID string) bool {
438 sessionID = strings.TrimSpace(sessionID)
439 if sessionID == "" {
440 return true
441 }
442 if resolved != "" && resolved != sessionID {
443 return false
444 }
445 resolved = sessionID
446 return true
447 }
448 for sessionID, pending := range evidence.registry.PendingCreates {
449 if pending.OperationID == operationID && !accept(sessionID) {
450 return "", false, true
451 }
452 }
453 for _, operation := range evidence.draftOps {
454 if operation.ID == operationID && !accept(operation.SessionID) {
455 return "", false, true
456 }
457 }
458 for _, operation := range evidence.registry.PendingOperations {
459 if strings.TrimSpace(operation.ID) != operationID {
460 continue
461 }
462 for _, sessionID := range operation.SessionIDs {
463 if !accept(sessionID) {
464 return "", false, true
465 }
466 }
467 if operation.Mapping != nil && !accept(operation.Mapping.SessionID) {
468 return "", false, true
469 }
470 }
471 return resolved, resolved != "", false
472 }
473
474 func savedTabHasRecoveryOwner(entry desktopTabEntry, evidence savedTabReconcileEvidence) bool {
475 sessionID := strings.TrimSpace(entry.SessionID)
476 pathKey := sessionRuntimeKey(entry.SessionPath)
477 if pending, ok := evidence.registry.PendingCreates[sessionID]; sessionID != "" && ok && pending.SessionID == sessionID {
478 return true
479 }
480 for _, operation := range evidence.draftOps {
481 if sessionID != "" && operation.SessionID == sessionID {
482 return true
483 }
484 }
485 for _, operation := range evidence.registry.PendingOperations {
486 if sessionID != "" && slices.Contains(operation.SessionIDs, sessionID) {
487 return true
488 }
489 if operation.Mapping != nil && ((sessionID != "" && operation.Mapping.SessionID == sessionID) || (pathKey != "" && sessionRuntimeKey(operation.Mapping.Path) == pathKey)) {
490 return true
491 }
492 }
493 for _, mapping := range evidence.registry.SourceMappings {
494 if (sessionID != "" && mapping.SessionID == sessionID) || (pathKey != "" && sessionRuntimeKey(mapping.Path) == pathKey) {
495 return true
496 }
497 }
498 for _, recovery := range evidence.registry.RecoveryEntries {
499 if (sessionID != "" && recovery.SessionID == sessionID) || (pathKey != "" && sessionRuntimeKey(recovery.Path) == pathKey) {
500 return true
501 }
502 }
503 return false
504 }
505
506 func savedTabCanonicalWorkspace(state workspacestate.State, info session.SessionInfo, sessionID string) (workspacestate.Workspace, bool) {
507 var owner workspacestate.Workspace
508 for _, workspace := range state.Workspaces {
509 if !slices.Contains(workspace.SessionIDs, sessionID) {
510 continue
511 }
512 if owner.ID != "" {
513 return workspacestate.Workspace{}, false
514 }
515 owner = workspace
516 }
517 if owner.ID == "" || info.Origin == "" || strings.TrimSpace(info.CWD) == "" || !sameDesktopPath(info.CWD, owner.Root) {
518 return workspacestate.Workspace{}, false
519 }
520 return owner, true
521 }
522
523 func savedTabMatchesWorkspace(entry desktopTabEntry, workspace workspacestate.Workspace) bool {
524 return restoredWorkspaceID(entry) == workspace.ID && sameDesktopPath(desktopWorkspaceRoot(entry.Scope, entry.WorkspaceRoot), workspace.Root)
525 }
526
527 func (a *App) archiveSavedTabEmptyCandidate(entry desktopTabEntry, cleanup legacycleanup.State) bool {
528 var candidate legacycleanup.Candidate
529 for _, item := range cleanup.Items {
530 if entry.SessionID != "" && item.SessionID == entry.SessionID {
531 candidate = item
532 break
533 }
534 }
535 if candidate.ID == "" && entry.SessionID == "" {
536 for _, item := range cleanup.Items {
537 if entry.SessionPath != "" && sameDesktopPath(item.SourcePath, entry.SessionPath) {
538 candidate = item
539 break
540 }
541 }
542 }
543 if candidate.ID == "" {
544 return false
545 }
546 if candidate.Restored || candidate.Phase == "archived" || candidate.Phase == "has_content" || candidate.Phase == "protected" {
547 return false
548 }
549 switch candidate.Kind {
550 case "session":
551 a.processLegacyCleanupSession(candidate)
552 case "legacy":
553 a.processLegacyCleanupSource(candidate)
554 default:
555 return false
556 }
557 state, err := a.workspaceRegistry().Load(a.bootContext())
558 if err != nil {
559 return false
560 }
561 sessionID := strings.TrimSpace(entry.SessionID)
562 if sessionID == "" {
563 if refreshed, loadErr := a.legacyCleanup.Load(a.bootContext()); loadErr == nil {
564 sessionID = refreshed.Items[candidate.ID].SessionID
565 }
566 }
567 return sessionID != "" && state.SessionStates[sessionID].Lifecycle == workspacestate.Archived
568 }
569
570 func repairReconciledTabSelection(file *desktopTabsFile, removed map[string]bool) {
571 local := make(map[string]bool, len(file.Tabs))
572 remote := make(map[string]bool, len(file.RemoteTabs))
573 for _, entry := range file.Tabs {
574 local[entry.ID] = true
575 }
576 for _, entry := range file.RemoteTabs {
577 remote[entry.ID] = true
578 }
579 order := make([]string, 0, len(file.TabOrder))
580 for _, id := range file.TabOrder {
581 if !removed[id] && (local[id] || remote[id]) {
582 order = append(order, id)
583 }
584 }
585 file.TabOrder = order
586 if local[file.ActiveTab] || remote[file.ActiveTab] {
587 return
588 }
589 file.ActiveTab = ""
590 if len(order) > 0 {
591 file.ActiveTab = order[0]
592 } else if len(file.Tabs) > 0 {
593 file.ActiveTab = file.Tabs[0].ID
594 } else if len(file.RemoteTabs) > 0 {
595 file.ActiveTab = file.RemoteTabs[0].ID
596 }
597 }
598
599 func savedTabIdentityKind(entry desktopTabEntry) string {
600 if strings.TrimSpace(entry.SessionID) != "" {
601 return "canonical"
602 }
603 if strings.TrimSpace(entry.SessionPath) != "" {
604 return "legacy"
605 }
606 return "none"
607 }
608
608 lines GO