返回 DeepSeek-Reasonix
session_workspace_binding.go
根目录 / desktop / session_workspace_binding.go
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
222 lines GO