返回 DeepSeek-Reasonix
session_purge_sources.go
根目录 / desktop / session_purge_sources.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "reasonix/desktop/internal/workspacestate"
12 "reasonix/internal/session"
13 )
14
15 var errPurgeSourceRetained = errors.New("migrated source must be retained")
16
17 // Cleanup is opt-in in the original tombstone transaction. Replaying an older
18 // purge must not grant new authority to remove upgrade evidence. Canonical
19 // directories use the same ownership lock and crash-safe staging as native
20 // deletion; shared content pools and legacy multi-head files remain untouched.
21 func (a *App) purgeMigratedSources(ctx context.Context, id string) error {
22 state, err := a.workspaceRegistry().Load(ctx)
23 if err != nil {
24 return err
25 }
26 op := state.PendingOperations["purge-"+id]
27 if len(op.Request) == 0 {
28 return nil
29 }
30 var plan workspacestate.PurgeSourceCleanup
31 if err := json.Unmarshal(op.Request, &plan); err != nil {
32 return err
33 }
34 if plan.Version != 1 {
35 return workspacestate.ErrUnsupportedVersion
36 }
37 for _, mapping := range plan.Sources {
38 if mapping.SessionID != id || mapping.Format != "canonical" || mapping.HeadID != "" || !filepath.IsAbs(mapping.Path) {
39 return workspacestate.ErrMutationConflict
40 }
41 // Never let a legacy receipt authorize deletion within the live store.
42 if pathsOverlapForPurge(mapping.Path, a.desktopSessions.root) || !purgeSourceExclusive(state, id, mapping) {
43 continue
44 }
45 // An offline or relocated historical root is not required to delete
46 // the canonical conversation. The adoption tombstone still hides it
47 // if it returns. Never follow a replaced directory symlink for cleanup.
48 rootInfo, rootErr := os.Lstat(filepath.Dir(mapping.Path))
49 if os.IsNotExist(rootErr) || (rootErr == nil && !rootInfo.IsDir()) {
50 continue
51 }
52 if rootErr != nil {
53 return rootErr
54 }
55 if info, err := os.Lstat(mapping.Path); err == nil && !info.IsDir() {
56 continue
57 }
58 fs := session.NewFilesystemPersistence(filepath.Dir(mapping.Path))
59 err := fs.PurgeWithTombstone(ctx, filepath.Base(mapping.Path), func() error {
60 // Both directory ownership and writer ownership are held here.
61 // Imports hold shared directory ownership through registry commit.
62 current, err := a.workspaceRegistry().Load(ctx)
63 if err != nil {
64 return err
65 }
66 if !purgeSourceExclusive(current, id, mapping) {
67 return errPurgeSourceRetained
68 }
69 fingerprint, err := desktopSourceFingerprint(mapping.Path)
70 if err == nil && fingerprint != mapping.Fingerprint {
71 return errPurgeSourceRetained
72 }
73 // An absent source can be a completed rename from a prior attempt.
74 // FilesystemPersistence verifies ownership of any staged directory.
75 if err != nil && !os.IsNotExist(err) {
76 return errPurgeSourceRetained
77 }
78 a.lifecycleCheckpoint("before-source-cleanup")
79 return nil
80 })
81 if err != nil && !errors.Is(err, errPurgeSourceRetained) {
82 return err
83 }
84 }
85 return nil
86 }
87
88 func purgeSourceExclusive(state workspacestate.State, id string, source workspacestate.SourceMapping) bool {
89 if state.SessionStates[id].Lifecycle != workspacestate.Deleted || workspacestate.ClassifyPurge(state, id) != workspacestate.PurgeTombstoned {
90 return false
91 }
92 registered, ok := state.SourceMappings[source.SourceKey]
93 if !ok || registered.Path != source.Path || registered.SessionID != id || registered.Fingerprint != source.Fingerprint {
94 return false
95 }
96 for _, mapping := range state.SourceMappings {
97 if state.SessionStates[mapping.SessionID].Lifecycle != workspacestate.Deleted && purgeMappingReferences(mapping, source.Path) {
98 return false
99 }
100 }
101 for _, op := range state.PendingOperations {
102 if op.Phase != "committed" && op.Mapping != nil && purgeMappingReferences(*op.Mapping, source.Path) {
103 return false
104 }
105 }
106 for _, recovery := range state.RecoveryEntries {
107 if recovery.Status != "restored" && pathsOverlapForPurge(recovery.Path, source.Path) {
108 return false
109 }
110 }
111 return true
112 }
113
114 func purgeMappingReferences(mapping workspacestate.SourceMapping, source string) bool {
115 if pathsOverlapForPurge(mapping.Path, source) {
116 return true
117 }
118 for _, artifact := range mapping.RetainedArtifacts {
119 if pathsOverlapForPurge(artifact, source) {
120 return true
121 }
122 }
123 return false
124 }
125
126 func pathsOverlapForPurge(left, right string) bool {
127 if left == "" || right == "" {
128 return false
129 }
130 l, errLeft := canonicalRuntimeRootErr(left)
131 r, errRight := canonicalRuntimeRootErr(right)
132 if errLeft != nil || errRight != nil {
133 return true // Uncertain ownership is never deletion authority.
134 }
135 return l == r || strings.HasPrefix(l, r+string(filepath.Separator)) || strings.HasPrefix(r, l+string(filepath.Separator))
136 }
137
137 lines GO