返回 DeepSeek-Reasonix
session_workspace_registry.go
根目录 / desktop / session_workspace_registry.go
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "log/slog"
10 "path/filepath"
11 "strings"
12 "sync"
13 "sync/atomic"
14
15 "reasonix/desktop/internal/workspacestate"
16 "reasonix/internal/config"
17 "reasonix/internal/control"
18 "reasonix/internal/session"
19 )
20
21 type freshSessionCreator interface {
22 BindFreshSession(context.Context, string) (session.SessionRef, error)
23 }
24
25 // desktopSessionState groups the Desktop-only persistence and navigation
26 // authority so App does not grow a second set of independent scalar owners.
27 type desktopSessionState struct {
28 beforeMigrationRegistryCommit func() error
29 root string
30 workspaceState *workspacestate.Store
31 navigationSeq atomic.Uint64
32 navigationMu sync.Mutex
33 navigationGeneration uint64
34 navigationCancel context.CancelFunc
35 pruneBlockedPersistence atomic.Uint64
36 pendingCreateRecovered atomic.Uint64
37 }
38
39 func (a *App) beginSessionNavigationContext(navigation ...uint64) (context.Context, func()) {
40 base := a.bootContext()
41 a.desktopSessions.navigationMu.Lock()
42 // Admission and cancellation share this lock: a delayed request must not
43 // cancel a newer intent or register after shutdown's cancellation sweep.
44 var rejected error
45 if a.shuttingDown.Load() {
46 rejected = context.Canceled
47 } else if len(navigation) > 0 && navigation[0] != 0 && a.desktopSessions.navigationSeq.Load() != navigation[0] {
48 rejected = errSessionNavigationSuperseded
49 }
50 if rejected != nil {
51 a.desktopSessions.navigationMu.Unlock()
52 ctx, cancel := context.WithCancelCause(base)
53 cancel(rejected)
54 return ctx, func() {}
55 }
56 if a.desktopSessions.navigationCancel != nil {
57 a.desktopSessions.navigationCancel()
58 }
59 a.desktopSessions.navigationGeneration++
60 generation := a.desktopSessions.navigationGeneration
61 ctx, cancel := context.WithCancel(base)
62 a.desktopSessions.navigationCancel = cancel
63 a.desktopSessions.navigationMu.Unlock()
64 return ctx, func() {
65 cancel()
66 a.desktopSessions.navigationMu.Lock()
67 if a.desktopSessions.navigationGeneration == generation {
68 a.desktopSessions.navigationCancel = nil
69 }
70 a.desktopSessions.navigationMu.Unlock()
71 }
72 }
73
74 func (a *App) cancelSessionNavigation() {
75 if a == nil {
76 return
77 }
78 a.desktopSessions.navigationMu.Lock()
79 a.desktopSessions.navigationGeneration++
80 if a.desktopSessions.navigationCancel != nil {
81 a.desktopSessions.navigationCancel()
82 a.desktopSessions.navigationCancel = nil
83 }
84 a.desktopSessions.navigationMu.Unlock()
85 }
86
87 func newDesktopSessionState() desktopSessionState {
88 return desktopSessionState{
89 root: config.DesktopSessionStoreDir(),
90 workspaceState: newDesktopWorkspaceStore(),
91 }
92 }
93
94 func (a *App) initializeDesktopSessionRoot() {
95 a.sessionServicesMu.Lock()
96 defer a.sessionServicesMu.Unlock()
97 if len(a.sessionServices) == 0 {
98 a.desktopSessions.root = config.DesktopSessionStoreDir()
99 }
100 }
101
102 func restoredWorkspaceID(entry desktopTabEntry) string {
103 if id := strings.TrimSpace(entry.WorkspaceID); id != "" {
104 return id
105 }
106 return desktopWorkspaceID(entry.Scope, entry.WorkspaceRoot)
107 }
108
109 func desktopWorkspaceID(scope, workspaceRoot string) string {
110 if strings.TrimSpace(scope) != "project" {
111 return workspacestate.GlobalWorkspaceID
112 }
113 root := canonicalRuntimeRoot(workspaceRoot)
114 digest := sha256.Sum256([]byte(root))
115 return "project-" + hex.EncodeToString(digest[:12])
116 }
117
118 func desktopWorkspaceOwnerID(state workspacestate.State, scope, workspaceRoot string) string {
119 id := desktopWorkspaceID(scope, workspaceRoot)
120 if strings.TrimSpace(scope) != "project" {
121 return id
122 }
123 if persisted, ok, err := workspacestate.ResolveWorkspaceID(state, workspaceRoot); err == nil && ok {
124 return persisted
125 }
126 return id
127 }
128
129 func (a *App) resolveDesktopWorkspaceID(ctx context.Context, scope, workspaceRoot string) (string, error) {
130 id := desktopWorkspaceID(scope, workspaceRoot)
131 if strings.TrimSpace(scope) != "project" {
132 return id, nil
133 }
134 state, err := a.workspaceRegistry().Load(ctx)
135 if err != nil {
136 return "", err
137 }
138 persisted, ok, err := workspacestate.ResolveWorkspaceID(state, workspaceRoot)
139 if err != nil {
140 return "", err
141 }
142 if ok {
143 return persisted, nil
144 }
145 return id, nil
146 }
147
148 func desktopWorkspaceRoot(scope, workspaceRoot string) string {
149 if strings.TrimSpace(scope) != "project" {
150 return globalWorkspaceRoot()
151 }
152 return filepath.Clean(strings.TrimSpace(workspaceRoot))
153 }
154
155 func (a *App) workspaceRegistry() *workspacestate.Store {
156 if a == nil {
157 return nil
158 }
159 a.sessionServicesMu.Lock()
160 defer a.sessionServicesMu.Unlock()
161 if a.desktopSessions.workspaceState == nil {
162 a.desktopSessions.workspaceState = newDesktopWorkspaceStore()
163 }
164 return a.desktopSessions.workspaceState
165 }
166
167 func newDesktopWorkspaceStore() *workspacestate.Store {
168 path := config.DesktopWorkspaceStatePath()
169 return workspacestate.NewStore(path, func(ctx context.Context) error { return backupDesktopUpgradeMetadataAt(ctx, path) })
170 }
171
172 func (a *App) ensureDesktopWorkspace(ctx context.Context, scope, workspaceRoot string) (string, error) {
173 store := a.workspaceRegistry()
174 if store == nil || store.Path() == "" || store.Path() == "." {
175 return "", errors.New("desktop workspace registry is unavailable")
176 }
177 id := desktopWorkspaceID(scope, workspaceRoot)
178 title := globalProjectTitle()
179 if strings.TrimSpace(scope) == "project" {
180 title = workspaceName(workspaceRoot)
181 }
182 resolvedID, err := store.EnsureWorkspaceResolved(ctx, workspacestate.Workspace{
183 ID: id, Root: desktopWorkspaceRoot(scope, workspaceRoot), Title: title, Visible: true,
184 })
185 return resolvedID, err
186 }
187
188 func (a *App) bindFreshDesktopSession(ctx context.Context, scope, workspaceRoot string, creator freshSessionCreator) (session.SessionRef, string, error) {
189 return a.bindFreshDesktopSessionWithIDs(ctx, scope, workspaceRoot, creator, "", "")
190 }
191
192 func (a *App) bindFreshDesktopSessionWithIDs(ctx context.Context, scope, workspaceRoot string, creator freshSessionCreator, sessionID, operationID string) (session.SessionRef, string, error) {
193 workspaceID, err := a.ensureDesktopWorkspace(ctx, scope, workspaceRoot)
194 if err != nil {
195 return session.SessionRef{}, "", err
196 }
197 if sessionID = strings.TrimSpace(sessionID); sessionID == "" {
198 sessionID = "desktop-" + strings.TrimPrefix(newTabID(), "tab_")
199 }
200 if operationID = strings.TrimSpace(operationID); operationID == "" {
201 operationID = "create-" + strings.TrimPrefix(newTabID(), "tab_")
202 }
203 store := a.workspaceRegistry()
204 if err := store.BeginCreate(ctx, workspacestate.PendingCreate{OperationID: operationID, WorkspaceID: workspaceID, SessionID: sessionID}); err != nil {
205 return session.SessionRef{}, "", err
206 }
207 options := session.CreateOptions{SessionID: sessionID, CWD: desktopWorkspaceRoot(scope, workspaceRoot), Origin: session.SessionOriginNew}
208 var ref session.SessionRef
209 if headerCreator, ok := creator.(interface {
210 BindFreshSessionWithOptions(context.Context, session.CreateOptions) (session.SessionRef, error)
211 }); ok {
212 ref, err = headerCreator.BindFreshSessionWithOptions(ctx, options)
213 } else {
214 ref, err = creator.BindFreshSession(ctx, sessionID)
215 }
216 if err != nil {
217 return session.SessionRef{}, workspaceID, err
218 }
219 if err := a.validateDesktopWorkspaceMembership(ctx, workspaceID, ref); err != nil {
220 return ref, workspaceID, err
221 }
222 if err := store.AttachSession(ctx, operationID, workspaceID, ref.SessionID, ""); err != nil {
223 return ref, workspaceID, err
224 }
225 return ref, workspaceID, nil
226 }
227
228 func (a *App) attachDesktopSession(ctx context.Context, scope, workspaceRoot string, ref session.SessionRef) (string, error) {
229 workspaceID, err := a.ensureDesktopWorkspace(ctx, scope, workspaceRoot)
230 if err != nil {
231 return "", err
232 }
233 if err := a.validateDesktopWorkspaceMembership(ctx, workspaceID, ref); err != nil {
234 return "", err
235 }
236 if err := a.workspaceRegistry().AttachSession(ctx, "", workspaceID, ref.SessionID, ""); err != nil {
237 return "", err
238 }
239 if runtime, ok := a.desktopSessionService("").Runtime(ref); ok {
240 if source := runtime.Session().Manifest().Source; source != nil && source.Path != "" {
241 // Adoption is durable; opening a tab must not re-hash a refreshed
242 // source. The import path owns source validation and registry writes.
243 state, stateErr := a.workspaceRegistry().Load(ctx)
244 adopted := false
245 if stateErr == nil {
246 for _, mapping := range state.SourceMappings {
247 if mapping.SessionID == ref.SessionID && mapping.WorkspaceID == workspaceID &&
248 sessionRuntimeKey(mapping.Path) == sessionRuntimeKey(source.Path) {
249 adopted = true
250 break
251 }
252 }
253 }
254 if !adopted {
255 if fingerprint, err := desktopSourceFingerprint(source.Path); err == nil {
256 if err := a.recordDesktopSource(ctx, source.Path, "legacy", fingerprint, ref.SessionID, workspaceID); err != nil {
257 return "", err
258 }
259 }
260 }
261 }
262 }
263 return workspaceID, nil
264 }
265
266 func (a *App) validateDesktopWorkspaceMembership(ctx context.Context, workspaceID string, ref session.SessionRef) error {
267 if err := validateLocalSessionRef(ref); err != nil {
268 return err
269 }
270 state, err := a.workspaceRegistry().Load(ctx)
271 if err != nil {
272 return err
273 }
274 workspace, ok := state.Workspaces[strings.TrimSpace(workspaceID)]
275 if !ok {
276 return workspacestate.ErrWorkspaceNotFound
277 }
278 info, err := a.desktopSessionService("").Query().Stat(ctx, ref)
279 if err != nil {
280 return err
281 }
282 if info.Origin == "" || strings.TrimSpace(info.CWD) == "" {
283 return fmt.Errorf("desktop session %q has no immutable workspace header", ref.SessionID)
284 }
285 same, identityErr := sameDesktopPathStrict(info.CWD, workspace.Root)
286 if identityErr != nil {
287 return fmt.Errorf("resolve desktop session workspace identity: %w", identityErr)
288 }
289 if !same {
290 return errSessionWorkspaceConflict
291 }
292 return nil
293 }
294
295 func (a *App) attachForkedDesktopSession(ctx context.Context, source *WorkspaceTab, childSessionID string) error {
296 if source == nil || strings.TrimSpace(childSessionID) == "" {
297 return errors.New("desktop fork requires source and child identities")
298 }
299 workspaceID := strings.TrimSpace(source.SessionWorkspace.ID)
300 if workspaceID == "" {
301 var err error
302 workspaceID, err = a.ensureDesktopWorkspace(ctx, source.Scope, source.WorkspaceRoot)
303 if err != nil {
304 return err
305 }
306 }
307 state, err := a.workspaceRegistry().Load(ctx)
308 if err != nil {
309 return err
310 }
311 workspace, ok := state.Workspaces[workspaceID]
312 if !ok {
313 return workspacestate.ErrWorkspaceNotFound
314 }
315 if err := a.validateDesktopWorkspaceMembership(ctx, workspaceID, session.SessionRef{
316 HostID: localDesktopHostID, SessionID: childSessionID,
317 }); err != nil {
318 return err
319 }
320 beforeID := ""
321 for index, id := range workspace.SessionIDs {
322 if id == source.SessionID && index+1 < len(workspace.SessionIDs) {
323 beforeID = workspace.SessionIDs[index+1]
324 break
325 }
326 }
327 return a.workspaceRegistry().AttachSession(ctx, "", workspaceID, childSessionID, beforeID)
328 }
329
330 func (a *App) verifyCanonicalTabRegistryBeforePrune(tab *WorkspaceTab) error {
331 if tab == nil || strings.TrimSpace(tab.SessionID) == "" {
332 return nil
333 }
334 store := a.workspaceRegistry()
335 contained, err := store.Contains(a.bootContext(), tab.SessionID)
336 if err != nil {
337 return err
338 }
339 if contained {
340 return nil
341 }
342 _, err = a.attachDesktopSession(a.bootContext(), tab.Scope, tab.WorkspaceRoot, session.SessionRef{
343 HostID: localDesktopHostID, SessionID: tab.SessionID,
344 })
345 return err
346 }
347
348 func (a *App) persistHiddenTabBeforePrune(id string, tab *WorkspaceTab) error {
349 if tab != nil && tab.hasActiveRuntimeWork() {
350 return nil
351 }
352 if err := a.snapshotTab(tab); err != nil {
353 a.desktopSessions.pruneBlockedPersistence.Add(1)
354 slog.Warn("desktop: snapshot before pruning hidden tab failed", "tab", id, "err", err)
355 return fmt.Errorf("save current session before switching tabs: %w", err)
356 }
357 if err := a.saveTabSessionMetaForCurrentSession(tab); err != nil {
358 a.desktopSessions.pruneBlockedPersistence.Add(1)
359 slog.Warn("desktop: session metadata before pruning hidden tab failed", "tab", id, "err", err)
360 return fmt.Errorf("save current session metadata before switching tabs: %w", err)
361 }
362 if err := a.verifyCanonicalTabRegistryBeforePrune(tab); err != nil {
363 a.desktopSessions.pruneBlockedPersistence.Add(1)
364 slog.Warn("desktop: canonical registry before pruning hidden tab failed", "tab", id, "err", err)
365 return fmt.Errorf("publish current session before switching tabs: %w", err)
366 }
367 return nil
368 }
369
370 func (a *App) prepareDesktopSessionRotation(ctx context.Context, request control.SessionRotationRequest) (control.SessionRotationPlan, error) {
371 if err := validateLocalSessionRef(request.Source); err != nil {
372 return control.SessionRotationPlan{}, err
373 }
374 a.mu.RLock()
375 var owner *WorkspaceTab
376 for _, tab := range a.runtimeTabsLocked() {
377 if tab != nil && tab.SessionID == request.Source.SessionID {
378 owner = tab
379 break
380 }
381 }
382 a.mu.RUnlock()
383 if owner == nil {
384 return control.SessionRotationPlan{}, errors.New("desktop session rotation owner is unavailable")
385 }
386 workspaceID := strings.TrimSpace(owner.SessionWorkspace.ID)
387 if workspaceID == "" {
388 var err error
389 workspaceID, err = a.ensureDesktopWorkspace(ctx, owner.Scope, owner.WorkspaceRoot)
390 if err != nil {
391 return control.SessionRotationPlan{}, err
392 }
393 }
394 contained, err := a.workspaceRegistry().Contains(ctx, request.Source.SessionID)
395 if err != nil {
396 return control.SessionRotationPlan{}, err
397 }
398 if !contained {
399 if err := a.validateDesktopWorkspaceMembership(ctx, workspaceID, request.Source); err != nil {
400 return control.SessionRotationPlan{}, err
401 }
402 if err := a.workspaceRegistry().AttachSession(ctx, "", workspaceID, request.Source.SessionID, ""); err != nil {
403 return control.SessionRotationPlan{}, err
404 }
405 }
406 sessionID := "desktop-" + strings.TrimPrefix(newTabID(), "tab_")
407 operationID := "rotate-" + strings.TrimPrefix(newTabID(), "tab_")
408 store := a.workspaceRegistry()
409 archiveSource := ""
410 if request.Reason == "clear" {
411 archiveSource = request.Source.SessionID
412 }
413 if err := store.BeginCreate(ctx, workspacestate.PendingCreate{OperationID: operationID, WorkspaceID: workspaceID, SessionID: sessionID, ArchiveSource: archiveSource}); err != nil {
414 return control.SessionRotationPlan{}, err
415 }
416 return control.SessionRotationPlan{
417 CreateOptions: session.CreateOptions{
418 SessionID: sessionID, CWD: desktopWorkspaceRoot(owner.Scope, owner.WorkspaceRoot), Origin: session.SessionOriginNew,
419 },
420 Commit: func(commitCtx context.Context, ref session.SessionRef) error {
421 if ref.SessionID != sessionID {
422 return errors.New("desktop session rotation published an unexpected identity")
423 }
424 if err := a.validateDesktopWorkspaceMembership(commitCtx, workspaceID, ref); err != nil {
425 return err
426 }
427 if err := store.CommitRotation(commitCtx, operationID, workspaceID, sessionID, "", archiveSource); err != nil {
428 return err
429 }
430 return nil
431 },
432 }, nil
433 }
434
434 lines GO