返回 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 snapshot, loadErr := a.workspaceRegistry().VerifySnapshot(ctx); loadErr == nil {
56 bound.title, bound.titleSource = a.canonicalTabTitleWithPresentation(ctx, snapshot.Session(ref.SessionID).Presentation, 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 snapshot, err := a.workspaceRegistry().VerifySnapshot(ctx)
91 if err != nil {
92 return workspacestate.Workspace{}, err
93 }
94 metadata := snapshot.Session(ref.SessionID)
95 if metadata.State.Lifecycle == workspacestate.Deleted {
96 return workspacestate.Workspace{}, session.ErrSessionNotFound
97 }
98 owner := metadata.Workspace
99 if metadata.OwnershipConflict || owner.ID == "" || info.Origin == "" || strings.TrimSpace(info.CWD) == "" {
100 return owner, errSessionWorkspaceConflict
101 }
102 same, identityErr := sameDesktopPathStrict(info.CWD, owner.Root)
103 if identityErr != nil {
104 return owner, identityErr
105 }
106 same = same || (owner.ID == workspacestate.GlobalWorkspaceID && isGlobalWorkspacePath(owner, info.CWD))
107 if !same {
108 return owner, errSessionWorkspaceConflict
109 }
110 return owner, nil
111 }
112
113 func canonicalWorkspaceScope(workspace workspacestate.Workspace) string {
114 if workspace.ID == workspacestate.GlobalWorkspaceID {
115 return "global"
116 }
117 return "project"
118 }
119
120 func canonicalWorkspaceChanged(snap tabRuntimeSnapshot, workspace workspacestate.Workspace) bool {
121 return snap.scope != canonicalWorkspaceScope(workspace) || !sameDesktopPath(desktopWorkspaceRoot(snap.scope, snap.workspaceRoot), workspace.Root)
122 }
123
124 // The caller resolves workspaceChanged before taking App.mu, then publishes
125 // the session/controller in the same critical section. Path identity resolution
126 // may touch the filesystem and must never run while App.mu is held.
127 func applyCanonicalWorkspaceLocked(tab *WorkspaceTab, workspace workspacestate.Workspace, workspaceChanged bool) {
128 if workspaceChanged {
129 tab.TopicID, tab.TopicTitle, tab.topicTitleSource = "", "", ""
130 tab.setPinnedFilesState(nil, nil)
131 }
132 tab.Scope, tab.WorkspaceRoot = canonicalWorkspaceScope(workspace), workspace.Root
133 tab.SessionWorkspace.ID = workspace.ID
134 }
135
136 func canonicalSessionTopicIdentity(state workspacestate.State, sessionID string) (string, string) {
137 presentation := state.Presentation[sessionID]
138 topicID := strings.TrimSpace(presentation.TopicID)
139 if topicID == "" {
140 topicID = "canonical-" + sessionID
141 }
142 return topicID, presentation.Title
143 }
144
145 func (a *App) commitCanonicalSessionBinding(tab *WorkspaceTab, ctrl control.SessionAPI, ref session.SessionRef, workspace workspacestate.Workspace, navigation uint64) error {
146 snapshot, err := a.workspaceRegistry().VerifySnapshot(a.bootContext())
147 if err != nil {
148 return err
149 }
150 presentation := snapshot.Session(ref.SessionID).Presentation
151 topicID := strings.TrimSpace(presentation.TopicID)
152 if topicID == "" {
153 topicID = "canonical-" + ref.SessionID
154 }
155 // The tab name is re-derived from the session log on every bind so the
156 // topicbar can never trail a rename committed while the tab was away.
157 topicTitle, topicSource := a.canonicalTabTitleWithPresentation(a.bootContext(), presentation, ref)
158 workspaceChanged := canonicalWorkspaceChanged(a.tabRuntimeSnapshot(tab), workspace)
159 a.mu.Lock()
160 defer a.mu.Unlock()
161 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != ctrl || (navigation != 0 && a.desktopSessions.navigationSeq.Load() != navigation) {
162 return errSessionNavigationSuperseded
163 }
164 applyCanonicalWorkspaceLocked(tab, workspace, workspaceChanged)
165 setTabSessionIdentity(tab, sessionRoute(ref.SessionID))
166 tab.TopicID, tab.TopicTitle, tab.topicTitleSource = topicID, topicTitle, topicSource
167 a.bindSessionRuntimeKeyLocked(tab, tab.currentSessionIdentity())
168 a.saveTabsLocked()
169 return nil
170 }
171
172 // Old versions could persist A's workspace with B's SessionID. Only repair a
173 // cold surface, and only when the header and registry independently name B.
174 func (a *App) reconcileCanonicalTabWorkspace(ctx context.Context, tab *WorkspaceTab, generation uint64) error {
175 a.mu.RLock()
176 id, ctrl := tab.SessionID, tab.Ctrl
177 a.mu.RUnlock()
178 if id == "" || ctrl != nil {
179 return nil
180 }
181 workspace, err := a.canonicalSessionWorkspace(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: id})
182 if errors.Is(err, session.ErrSessionNotFound) {
183 return nil
184 } // Old-store migration still owns absent v5 identities.
185 if err != nil {
186 return err
187 }
188 workspaceChanged := canonicalWorkspaceChanged(a.tabRuntimeSnapshot(tab), workspace)
189 a.mu.Lock()
190 defer a.mu.Unlock()
191 if a.tabBuildSupersededLocked(tab, generation) || tab.SessionID != id || tab.Ctrl != nil {
192 return errSessionNavigationSuperseded
193 }
194 applyCanonicalWorkspaceLocked(tab, workspace, workspaceChanged)
195 setTabSessionIdentity(tab, sessionRoute(id))
196 a.saveTabsLocked()
197 return nil
198 }
199
200 func (a *App) prepareTabControllerWorkspace(tab *WorkspaceTab, ctx context.Context, generation uint64, appCtx context.Context) bool {
201 a.mu.Lock()
202 // Keep a lease-blocked banner steady across background retries. Ordinary
203 // builds reset readiness before resolving their persisted workspace.
204 if !tab.removed && tab.Ctrl == nil && !tab.StartupErrLeaseHeld {
205 tab.Ready = false
206 clearTabStartupError(tab)
207 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
208 }
209 a.mu.Unlock()
210 if err := a.reconcileCanonicalTabWorkspace(ctx, tab, generation); err != nil {
211 a.recordTabStartupFailure(tab, generation, appCtx, friendlySessionLoadError(err))
212 return false
213 }
214 a.reconcileTabWithPinnedSessionMeta(tab)
215 return true
216 }
217
217 lines GO