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