| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "log/slog" |
| 7 | "os" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/control" |
| 13 | "reasonix/internal/sessioncatalog" |
| 14 | ) |
| 15 | |
| 16 | func (a *App) resolveOpenTopicSessionPath(scope, workspaceRoot, sessionPath string) (string, string) { |
| 17 | actualRoot := workspaceRoot |
| 18 | if scope == "global" { |
| 19 | actualRoot = globalWorkspaceRoot() |
| 20 | } |
| 21 | // Keep a live controller on this path (including paused). Opening a |
| 22 | // different ordinary session of the same topic must still switch. |
| 23 | if continued := a.continuePathForOpen(sessionPath); continued != "" { |
| 24 | if a.sessionHasLiveController(sessionPath) { |
| 25 | return actualRoot, sessionPath |
| 26 | } |
| 27 | sessionPath = continued |
| 28 | } |
| 29 | return actualRoot, sessionPath |
| 30 | } |
| 31 | |
| 32 | func (a *App) sessionHasLiveController(path string) bool { |
| 33 | if a == nil { |
| 34 | return false |
| 35 | } |
| 36 | a.mu.RLock() |
| 37 | defer a.mu.RUnlock() |
| 38 | return a.liveRuntimeTabMatchingLocked(nil, path) != nil |
| 39 | } |
| 40 | |
| 41 | func (a *App) skipContinuationRebind(tab *WorkspaceTab, target string) bool { |
| 42 | if tab == nil || tab.Ctrl == nil { |
| 43 | return false |
| 44 | } |
| 45 | next := a.continuePathForOpen(tab.currentSessionPath()) |
| 46 | return next != "" && sessionRuntimeKey(next) == sessionRuntimeKey(target) |
| 47 | } |
| 48 | |
| 49 | func (a *App) continuePathForOpen(path string) string { |
| 50 | path = strings.TrimSpace(path) |
| 51 | if path == "" { |
| 52 | return "" |
| 53 | } |
| 54 | catalog := a.sessionCatalog.Load() |
| 55 | if catalog == nil { |
| 56 | return "" |
| 57 | } |
| 58 | ctx := context.Background() |
| 59 | rec, ok, err := catalog.GetSession(ctx, path) |
| 60 | if err != nil || !ok { |
| 61 | return a.continuePathForMissingParent(ctx, catalog, path) |
| 62 | } |
| 63 | if rec.TopicID == "" { |
| 64 | return "" |
| 65 | } |
| 66 | topic, ok, err := catalog.GetTopic(ctx, sessioncatalog.TopicKey{Scope: rec.Scope, WorkspaceRoot: rec.WorkspaceRoot, TopicID: rec.TopicID}) |
| 67 | if err != nil || !ok { |
| 68 | return "" |
| 69 | } |
| 70 | return sessioncatalog.OrdinaryContinuePath(topic.Sessions, path) |
| 71 | } |
| 72 | |
| 73 | func (a *App) continuePathForMissingParent(ctx context.Context, catalog *sessioncatalog.Catalog, path string) string { |
| 74 | parentID := agent.BranchID(path) |
| 75 | if parentID == "" { |
| 76 | return "" |
| 77 | } |
| 78 | // desktop-tabs.json may still name a parent that lineage folded off the |
| 79 | // ordinary row. Look up the topic by the filename id. |
| 80 | for _, target := range a.sessionCatalogTargets() { |
| 81 | page, err := catalog.ListTopics(ctx, sessioncatalog.TopicPageRequest{ |
| 82 | Scope: target.Scope, WorkspaceRoot: target.WorkspaceRoot, Limit: sessioncatalog.MaxLimit, |
| 83 | }) |
| 84 | if err != nil { |
| 85 | continue |
| 86 | } |
| 87 | for _, topic := range page.Items { |
| 88 | for _, session := range topic.Sessions { |
| 89 | if session.ParentID == parentID || strings.TrimSpace(session.RecoveryGroupID) == parentID { |
| 90 | if next := sessioncatalog.OrdinaryContinuePath(topic.Sessions, path); next != "" { |
| 91 | return next |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | return "" |
| 98 | } |
| 99 | |
| 100 | func (a *App) resumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) { |
| 101 | return a.resumeSessionForTranscript(tabID, path, limit, true) |
| 102 | } |
| 103 | |
| 104 | func (a *App) resumeSessionForTranscript(tabID, path string, limit int, includeHistory bool) (HistoryPage, error) { |
| 105 | started := time.Now() |
| 106 | phases := HistorySwitchPhases{Outcome: "ok"} |
| 107 | defer func() { logSessionSwitchPhases(phases, started) }() |
| 108 | tab, ctrl := a.tabAndCtrlByID(tabID) |
| 109 | if tab == nil || ctrl == nil { |
| 110 | phases.Outcome = "tab_not_ready" |
| 111 | return HistoryPage{}, fmt.Errorf("tab is not ready") |
| 112 | } |
| 113 | if _, isV3 := parseSessionRoute(path); isV3 { |
| 114 | page, err := a.resumeCanonicalSessionForTranscript(tab, ctrl, path, limit, includeHistory) |
| 115 | if err != nil { |
| 116 | phases.Outcome = "v3_rebind_failed" |
| 117 | return HistoryPage{}, err |
| 118 | } |
| 119 | phases.TotalMs = elapsedMs(started) |
| 120 | page.Switch = &phases |
| 121 | return page, nil |
| 122 | } |
| 123 | // Resolve the continuation before the first read so the loaded session, the |
| 124 | // rebound path, and the returned fingerprint all name the same file. |
| 125 | resolveStarted := time.Now() |
| 126 | if continued := a.continuePathForOpen(path); continued != "" { |
| 127 | current := tab.currentSessionPath() |
| 128 | if !tab.hasActiveRuntimeWork() || sessionRuntimeKey(current) != sessionRuntimeKey(path) { |
| 129 | path = continued |
| 130 | } |
| 131 | } |
| 132 | sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path) |
| 133 | if err != nil { |
| 134 | phases.Outcome = "invalid_path" |
| 135 | return HistoryPage{}, err |
| 136 | } |
| 137 | phases.ResolveMs = elapsedMs(resolveStarted) |
| 138 | if identity, ok := ctrl.(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() { |
| 139 | migrateStarted := time.Now() |
| 140 | page, migrateErr := a.continueLegacySessionForTranscript(tab, ctrl, sessionPath, limit, includeHistory, false) |
| 141 | if migrateErr != nil { |
| 142 | phases.Outcome = "legacy_migration_failed" |
| 143 | return HistoryPage{}, migrateErr |
| 144 | } |
| 145 | phases.LoadMs = elapsedMs(migrateStarted) |
| 146 | phases.LoadedCount = len(ctrl.History()) |
| 147 | phases.LoadedBytes = sessionFileBytes(sessionPath) |
| 148 | phases.DurableReads = 1 |
| 149 | phases.TotalMs = elapsedMs(started) |
| 150 | page.Switch = &phases |
| 151 | return page, nil |
| 152 | } |
| 153 | |
| 154 | loadStarted := time.Now() |
| 155 | phases.DurableReads++ |
| 156 | loaded, err := loadResumableSession(sessionPath) |
| 157 | if err != nil { |
| 158 | phases.Outcome = "load_failed" |
| 159 | return HistoryPage{}, err |
| 160 | } |
| 161 | phases.LoadMs = elapsedMs(loadStarted) |
| 162 | phases.LoadedCount = loaded.Len() |
| 163 | phases.LoadedBytes = sessionFileBytes(sessionPath) |
| 164 | |
| 165 | page, err := a.switchToLoadedSessionPage(tab, loaded, sessionPath, false, includeHistory, limit, &phases) |
| 166 | if err != nil { |
| 167 | return HistoryPage{}, err |
| 168 | } |
| 169 | phases.TotalMs = elapsedMs(started) |
| 170 | page.Switch = &phases |
| 171 | return page, nil |
| 172 | } |
| 173 | |
| 174 | // switchToLoadedSessionPage commits tab onto a session that is already loaded |
| 175 | // and optionally builds a legacy page from a matching preload. Modern callers |
| 176 | // take their first screen from the authoritative transcript snapshot instead. |
| 177 | func (a *App) switchToLoadedSessionPage(tab *WorkspaceTab, loaded *agent.Session, sessionPath string, readOnly, includeHistory bool, limit int, phases *HistorySwitchPhases) (HistoryPage, error) { |
| 178 | rebindStarted := time.Now() |
| 179 | if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) { |
| 180 | if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil { |
| 181 | phases.Outcome = "rebind_failed" |
| 182 | return HistoryPage{}, err |
| 183 | } |
| 184 | } |
| 185 | a.setTabReadOnly(tab.ID, readOnly) |
| 186 | // The rebind republishes tab.Ctrl; a nil controller here means the switch did |
| 187 | // not commit, and the caller must keep the previous surface recoverable. |
| 188 | _, reboundCtrl := a.tabAndCtrlByID(tab.ID) |
| 189 | if reboundCtrl == nil { |
| 190 | phases.Outcome = "controller_missing" |
| 191 | return HistoryPage{}, fmt.Errorf("tab is not ready after session rebind") |
| 192 | } |
| 193 | phases.RebindMs = elapsedMs(rebindStarted) |
| 194 | if !includeHistory { |
| 195 | return HistoryPage{Messages: []HistoryMessage{}}, nil |
| 196 | } |
| 197 | |
| 198 | buildStarted := time.Now() |
| 199 | page, durableRead := historyPageForController(tab, reboundCtrl, loaded, sessionPath, 0, limit) |
| 200 | phases.HistoryMs = elapsedMs(buildStarted) |
| 201 | if durableRead { |
| 202 | phases.DurableReads++ |
| 203 | } |
| 204 | phases.HistoryCount = len(page.Messages) |
| 205 | return page, nil |
| 206 | } |
| 207 | |
| 208 | func elapsedMs(started time.Time) int64 { |
| 209 | return time.Since(started).Milliseconds() |
| 210 | } |
| 211 | |
| 212 | // sessionFileBytes reports the durable log size for switch diagnostics. Only the |
| 213 | // size leaves this function; the path is never logged with it. |
| 214 | func sessionFileBytes(path string) int64 { |
| 215 | if info, err := os.Stat(path); err == nil && !info.IsDir() { |
| 216 | return info.Size() |
| 217 | } |
| 218 | return 0 |
| 219 | } |
| 220 | |
| 221 | func logSessionSwitchPhases(phases HistorySwitchPhases, started time.Time) { |
| 222 | slog.Debug("desktop: session switch", |
| 223 | "outcome", phases.Outcome, |
| 224 | "resolve_ms", phases.ResolveMs, |
| 225 | "load_ms", phases.LoadMs, |
| 226 | "rebind_ms", phases.RebindMs, |
| 227 | "history_ms", phases.HistoryMs, |
| 228 | "total_ms", elapsedMs(started), |
| 229 | "loaded_messages", phases.LoadedCount, |
| 230 | "loaded_bytes", phases.LoadedBytes, |
| 231 | "history_entries", phases.HistoryCount, |
| 232 | "durable_reads", phases.DurableReads, |
| 233 | ) |
| 234 | } |
| 235 | |
| 236 | func (a *App) retargetOpenTabsToContinuations() { |
| 237 | if a == nil { |
| 238 | return |
| 239 | } |
| 240 | type candidate struct { |
| 241 | tab *WorkspaceTab |
| 242 | current string |
| 243 | } |
| 244 | a.mu.RLock() |
| 245 | items := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions)) |
| 246 | collect := func(tab *WorkspaceTab) { |
| 247 | if tab == nil || tab.hasActiveRuntimeWork() { |
| 248 | return |
| 249 | } |
| 250 | items = append(items, candidate{tab: tab, current: tab.currentSessionPath()}) |
| 251 | } |
| 252 | for _, tab := range a.tabs { |
| 253 | collect(tab) |
| 254 | } |
| 255 | for _, tab := range a.detachedSessions { |
| 256 | collect(tab) |
| 257 | } |
| 258 | a.mu.RUnlock() |
| 259 | type pending struct { |
| 260 | tab *WorkspaceTab |
| 261 | next string |
| 262 | } |
| 263 | ready := make([]pending, 0, len(items)) |
| 264 | for _, item := range items { |
| 265 | next := a.continuePathForOpen(item.current) |
| 266 | if next == "" || sessionRuntimeKey(next) == sessionRuntimeKey(item.current) { |
| 267 | continue |
| 268 | } |
| 269 | ready = append(ready, pending{tab: item.tab, next: next}) |
| 270 | } |
| 271 | for _, item := range ready { |
| 272 | if item.tab.hasActiveRuntimeWork() { |
| 273 | continue |
| 274 | } |
| 275 | if item.tab.Ctrl == nil { |
| 276 | a.mu.Lock() |
| 277 | if !item.tab.hasActiveRuntimeWork() && (a.tabs[item.tab.ID] == item.tab || a.detachedSessions[sessionRuntimeKey(item.tab.currentSessionPath())] == item.tab) { |
| 278 | item.tab.SessionPath = item.next |
| 279 | a.saveTabsLocked() |
| 280 | } |
| 281 | a.mu.Unlock() |
| 282 | continue |
| 283 | } |
| 284 | if err := a.rebindTabToSessionPath(item.tab, item.next); err != nil { |
| 285 | continue |
| 286 | } |
| 287 | } |
| 288 | } |
| 289 |