| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/desktop/internal/workspacestate" |
| 9 | "reasonix/internal/config" |
| 10 | "reasonix/internal/control" |
| 11 | "reasonix/internal/session" |
| 12 | ) |
| 13 | |
| 14 | var errSessionWorkspaceConflict = errors.New("session workspace identity is inconsistent; the session files were left unchanged") |
| 15 | |
| 16 | // canonicalTabBinding is what a controller build publishes to its tab once |
| 17 | // the durable session is bound: identity, workspace, and the projected name. |
| 18 | type canonicalTabBinding struct { |
| 19 | ref session.SessionRef |
| 20 | workspaceID string |
| 21 | title string |
| 22 | titleSource string |
| 23 | } |
| 24 | |
| 25 | // canonicalSeedTitle keeps a manual topic name chosen before any session |
| 26 | // existed. It lives only in the legacy topic map, so the first canonical bind |
| 27 | // seeds it into the presentation row where every reader of the session sees it. |
| 28 | func canonicalSeedTitle(title, source string) string { |
| 29 | if source != topicTitleSourceManual || isDefaultTopicTitle(title) { |
| 30 | return "" |
| 31 | } |
| 32 | return strings.TrimSpace(title) |
| 33 | } |
| 34 | |
| 35 | // bindTabCanonicalSessionTopic binds the session, publishes its topic |
| 36 | // identity, and resolves the tab name from the session log. A restored or |
| 37 | // reopened tab starts from the legacy topic map, which a canonical rename |
| 38 | // never writes; only the log can name the session it is now bound to. |
| 39 | func (a *App) bindTabCanonicalSessionTopic( |
| 40 | ctx context.Context, |
| 41 | identity control.IdentityLifecycle, |
| 42 | cfg *config.Config, |
| 43 | scope, workspaceRoot, sessionID, legacyPath, model string, |
| 44 | modelFallback bool, |
| 45 | topicID, seedTitle string, |
| 46 | ) (canonicalTabBinding, error) { |
| 47 | ref, workspaceID, err := a.bindTabCanonicalSession(ctx, identity, cfg, scope, workspaceRoot, sessionID, legacyPath, model, modelFallback) |
| 48 | if err != nil { |
| 49 | return canonicalTabBinding{}, err |
| 50 | } |
| 51 | if err := a.workspaceRegistry().EnsureSessionTopic(ctx, ref.SessionID, topicID, seedTitle); err != nil { |
| 52 | return canonicalTabBinding{}, err |
| 53 | } |
| 54 | bound := canonicalTabBinding{ref: ref, workspaceID: workspaceID} |
| 55 | if state, loadErr := a.workspaceRegistry().Load(ctx); loadErr == nil { |
| 56 | bound.title, bound.titleSource = a.canonicalTabTitle(ctx, state, ref) |
| 57 | } |
| 58 | return bound, nil |
| 59 | } |
| 60 | |
| 61 | // applyLocked publishes the binding to the tab. The caller holds App.mu. |
| 62 | func (b canonicalTabBinding) applyLocked(tab *WorkspaceTab) { |
| 63 | tab.SessionID, tab.SessionPath, tab.SessionWorkspace.ID = b.ref.SessionID, "", b.workspaceID |
| 64 | if b.title != "" { |
| 65 | tab.TopicTitle, tab.topicTitleSource = b.title, b.titleSource |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func controllerSessionDirectoryMatches(desiredDir, ctrlDir, path string) bool { |
| 70 | if desiredDir == "" || sameDesktopPath(ctrlDir, desiredDir) { |
| 71 | return true |
| 72 | } |
| 73 | if path == "" { |
| 74 | return false |
| 75 | } |
| 76 | validPath, _, err := validateSessionPath(ctrlDir, path) |
| 77 | return err == nil && sessionRuntimeKey(validPath) == sessionRuntimeKey(path) |
| 78 | } |
| 79 | |
| 80 | // Resolve navigation from durable membership and the immutable header, never |
| 81 | // from the current surface. Both authorities must agree before execution. |
| 82 | func (a *App) canonicalSessionWorkspace(ctx context.Context, ref session.SessionRef) (workspacestate.Workspace, error) { |
| 83 | if err := validateLocalSessionRef(ref); err != nil { |
| 84 | return workspacestate.Workspace{}, err |
| 85 | } |
| 86 | info, err := a.desktopSessionService("").Query().Stat(ctx, ref) |
| 87 | if err != nil { |
| 88 | return workspacestate.Workspace{}, err |
| 89 | } |
| 90 | state, err := a.workspaceRegistry().Load(ctx) |
| 91 | if err != nil { |
| 92 | return workspacestate.Workspace{}, err |
| 93 | } |
| 94 | if state.SessionStates[ref.SessionID].Lifecycle == workspacestate.Deleted { |
| 95 | return workspacestate.Workspace{}, session.ErrSessionNotFound |
| 96 | } |
| 97 | var owner workspacestate.Workspace |
| 98 | for _, workspace := range state.Workspaces { |
| 99 | for _, id := range workspace.SessionIDs { |
| 100 | if id != ref.SessionID { |
| 101 | continue |
| 102 | } |
| 103 | if owner.ID != "" { |
| 104 | return owner, errSessionWorkspaceConflict |
| 105 | } |
| 106 | owner = workspace |
| 107 | } |
| 108 | } |
| 109 | if owner.ID == "" || info.Origin == "" || strings.TrimSpace(info.CWD) == "" { |
| 110 | return owner, errSessionWorkspaceConflict |
| 111 | } |
| 112 | same, identityErr := sameDesktopPathStrict(info.CWD, owner.Root) |
| 113 | if identityErr != nil { |
| 114 | return owner, identityErr |
| 115 | } |
| 116 | if !same { |
| 117 | return owner, errSessionWorkspaceConflict |
| 118 | } |
| 119 | return owner, nil |
| 120 | } |
| 121 | |
| 122 | func canonicalWorkspaceScope(workspace workspacestate.Workspace) string { |
| 123 | if workspace.ID == workspacestate.GlobalWorkspaceID { |
| 124 | return "global" |
| 125 | } |
| 126 | return "project" |
| 127 | } |
| 128 | |
| 129 | func canonicalWorkspaceChanged(snap tabRuntimeSnapshot, workspace workspacestate.Workspace) bool { |
| 130 | return snap.scope != canonicalWorkspaceScope(workspace) || !sameDesktopPath(desktopWorkspaceRoot(snap.scope, snap.workspaceRoot), workspace.Root) |
| 131 | } |
| 132 | |
| 133 | // The caller resolves workspaceChanged before taking App.mu, then publishes |
| 134 | // the session/controller in the same critical section. Path identity resolution |
| 135 | // may touch the filesystem and must never run while App.mu is held. |
| 136 | func applyCanonicalWorkspaceLocked(tab *WorkspaceTab, workspace workspacestate.Workspace, workspaceChanged bool) { |
| 137 | if workspaceChanged { |
| 138 | tab.TopicID, tab.TopicTitle, tab.topicTitleSource = "", "", "" |
| 139 | tab.setPinnedFilesState(nil, nil) |
| 140 | } |
| 141 | tab.Scope, tab.WorkspaceRoot = canonicalWorkspaceScope(workspace), workspace.Root |
| 142 | tab.SessionWorkspace.ID = workspace.ID |
| 143 | } |
| 144 | |
| 145 | func canonicalSessionTopicIdentity(state workspacestate.State, sessionID string) (string, string) { |
| 146 | presentation := state.Presentation[sessionID] |
| 147 | topicID := strings.TrimSpace(presentation.TopicID) |
| 148 | if topicID == "" { |
| 149 | topicID = "canonical-" + sessionID |
| 150 | } |
| 151 | return topicID, presentation.Title |
| 152 | } |
| 153 | |
| 154 | func (a *App) commitCanonicalSessionBinding(tab *WorkspaceTab, ctrl control.SessionAPI, ref session.SessionRef, workspace workspacestate.Workspace, navigation uint64) error { |
| 155 | state, err := a.workspaceRegistry().Load(a.bootContext()) |
| 156 | if err != nil { |
| 157 | return err |
| 158 | } |
| 159 | topicID, _ := canonicalSessionTopicIdentity(state, ref.SessionID) |
| 160 | // The tab name is re-derived from the session log on every bind so the |
| 161 | // topicbar can never trail a rename committed while the tab was away. |
| 162 | topicTitle, topicSource := a.canonicalTabTitle(a.bootContext(), state, ref) |
| 163 | workspaceChanged := canonicalWorkspaceChanged(a.tabRuntimeSnapshot(tab), workspace) |
| 164 | a.mu.Lock() |
| 165 | defer a.mu.Unlock() |
| 166 | if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != ctrl || (navigation != 0 && a.desktopSessions.navigationSeq.Load() != navigation) { |
| 167 | return errSessionNavigationSuperseded |
| 168 | } |
| 169 | applyCanonicalWorkspaceLocked(tab, workspace, workspaceChanged) |
| 170 | setTabSessionIdentity(tab, sessionRoute(ref.SessionID)) |
| 171 | tab.TopicID, tab.TopicTitle, tab.topicTitleSource = topicID, topicTitle, topicSource |
| 172 | a.bindSessionRuntimeKeyLocked(tab, tab.currentSessionIdentity()) |
| 173 | a.saveTabsLocked() |
| 174 | return nil |
| 175 | } |
| 176 | |
| 177 | // Old versions could persist A's workspace with B's SessionID. Only repair a |
| 178 | // cold surface, and only when the header and registry independently name B. |
| 179 | func (a *App) reconcileCanonicalTabWorkspace(ctx context.Context, tab *WorkspaceTab, generation uint64) error { |
| 180 | a.mu.RLock() |
| 181 | id, ctrl := tab.SessionID, tab.Ctrl |
| 182 | a.mu.RUnlock() |
| 183 | if id == "" || ctrl != nil { |
| 184 | return nil |
| 185 | } |
| 186 | workspace, err := a.canonicalSessionWorkspace(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: id}) |
| 187 | if errors.Is(err, session.ErrSessionNotFound) { |
| 188 | return nil |
| 189 | } // Old-store migration still owns absent v5 identities. |
| 190 | if err != nil { |
| 191 | return err |
| 192 | } |
| 193 | workspaceChanged := canonicalWorkspaceChanged(a.tabRuntimeSnapshot(tab), workspace) |
| 194 | a.mu.Lock() |
| 195 | defer a.mu.Unlock() |
| 196 | if a.tabBuildSupersededLocked(tab, generation) || tab.SessionID != id || tab.Ctrl != nil { |
| 197 | return errSessionNavigationSuperseded |
| 198 | } |
| 199 | applyCanonicalWorkspaceLocked(tab, workspace, workspaceChanged) |
| 200 | setTabSessionIdentity(tab, sessionRoute(id)) |
| 201 | a.saveTabsLocked() |
| 202 | return nil |
| 203 | } |
| 204 | |
| 205 | func (a *App) prepareTabControllerWorkspace(tab *WorkspaceTab, ctx context.Context, generation uint64, appCtx context.Context) bool { |
| 206 | a.mu.Lock() |
| 207 | // Keep a lease-blocked banner steady across background retries. Ordinary |
| 208 | // builds reset readiness before resolving their persisted workspace. |
| 209 | if !tab.removed && tab.Ctrl == nil && !tab.StartupErrLeaseHeld { |
| 210 | tab.Ready = false |
| 211 | clearTabStartupError(tab) |
| 212 | a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil) |
| 213 | } |
| 214 | a.mu.Unlock() |
| 215 | if err := a.reconcileCanonicalTabWorkspace(ctx, tab, generation); err != nil { |
| 216 | a.recordTabStartupFailure(tab, generation, appCtx, friendlySessionLoadError(err)) |
| 217 | return false |
| 218 | } |
| 219 | a.reconcileTabWithPinnedSessionMeta(tab) |
| 220 | return true |
| 221 | } |
| 222 |