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