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