返回 DeepSeek-Reasonix
workspace_identity.go
根目录 / desktop / internal / workspacestate / workspace_identity.go
1 package workspacestate
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "os"
10 "slices"
11 "strings"
12 "time"
13
14 "reasonix/internal/pathidentity"
15 )
16
17 func (s *Store) EnsureWorkspace(ctx context.Context, workspace Workspace) error {
18 _, err := s.EnsureWorkspaceResolved(ctx, workspace)
19 return err
20 }
21
22 // ResolveWorkspaceID returns the persisted owner of root's physical directory.
23 // Path-based projections must use this owner instead of deriving a fresh ID
24 // from a newer path spelling or identity scheme.
25 func ResolveWorkspaceID(state State, root string) (string, bool, error) {
26 root = strings.TrimSpace(root)
27 if root == "" {
28 return "", false, nil
29 }
30 identity, err := pathidentity.Resolve(root, pathidentity.Options{FollowLeaf: true})
31 if err != nil {
32 return "", false, fmt.Errorf("resolve workspace root: %w", err)
33 }
34 matches := matchingWorkspaceIDs(state, identity)
35 if len(matches) == 0 {
36 return "", false, nil
37 }
38 return canonicalWorkspaceOwner(state, matches), true, nil
39 }
40
41 // matchingWorkspaceIDs names every record whose root resolves to the candidate
42 // directory right now. A record this host cannot resolve is skipped: a
43 // directory that cannot be reached is not the one the candidate just resolved,
44 // and one unreachable record must not fail every other workspace's lookup.
45 func matchingWorkspaceIDs(state State, candidate pathidentity.Identity) []string {
46 matches := make([]string, 0, 1)
47 if candidate.Key == "" {
48 return matches
49 }
50 for id, existing := range state.Workspaces {
51 if strings.TrimSpace(existing.Root) == "" {
52 continue
53 }
54 identity, err := pathidentity.Resolve(existing.Root, pathidentity.Options{FollowLeaf: true})
55 if err == nil && identity.Key == candidate.Key {
56 matches = append(matches, id)
57 }
58 }
59 slices.Sort(matches)
60 return matches
61 }
62
63 // canonicalWorkspaceOwner names the record that keeps a directory when several
64 // claim it. Global is addressed by that fixed ID across the app and can never
65 // be removed, so it wins; otherwise the oldest registration survives, and the
66 // sorted order settles equal or missing timestamps.
67 func canonicalWorkspaceOwner(state State, matches []string) string {
68 owner := ""
69 for _, id := range matches {
70 if id == GlobalWorkspaceID {
71 return id
72 }
73 if owner == "" || state.Workspaces[id].CreatedAt.Before(state.Workspaces[owner].CreatedAt) {
74 owner = id
75 }
76 }
77 return owner
78 }
79
80 // absorbDuplicateWorkspaceIdentities folds the losing records for one physical
81 // directory into owner. Registrations written before physical identity could
82 // record a directory twice, and every later resolution over it stays ambiguous
83 // until exactly one record owns it again. Sessions and their organization move
84 // with the record, so the repair never drops history.
85 func absorbDuplicateWorkspaceIdentities(state *State, owner string, matches []string) {
86 target, ok := state.Workspaces[owner]
87 if !ok || len(matches) < 2 {
88 return
89 }
90 for _, id := range matches {
91 duplicate, exists := state.Workspaces[id]
92 if id == owner || !exists {
93 continue
94 }
95 for _, sessionID := range duplicate.SessionIDs {
96 if !contains(target.SessionIDs, sessionID) {
97 target.SessionIDs = append(target.SessionIDs, sessionID)
98 }
99 }
100 target.Visible = target.Visible || duplicate.Visible
101 if strings.TrimSpace(target.Title) == "" {
102 target.Title = duplicate.Title
103 }
104 absorbOrganization(&target, duplicate.Organization)
105 repointWorkspaceReferences(state, id, owner)
106 delete(state.Workspaces, id)
107 state.WorkspaceIDs = remove(state.WorkspaceIDs, id)
108 }
109 target.UpdatedAt = time.Now().UTC()
110 state.Workspaces[owner] = target
111 }
112
113 func absorbOrganization(target *Workspace, source *Organization) {
114 if source == nil {
115 return
116 }
117 if target.Organization == nil {
118 target.Organization = &Organization{}
119 }
120 merged := target.Organization
121 normalizeOrganization(merged)
122 for _, key := range source.Order {
123 if !slices.Contains(merged.Order, key) {
124 merged.Order = append(merged.Order, key)
125 }
126 }
127 for _, group := range source.Groups {
128 index := slices.IndexFunc(merged.Groups, func(existing OrganizationGroup) bool { return existing.ID == group.ID })
129 if index < 0 {
130 merged.Groups = append(merged.Groups, group)
131 continue
132 }
133 for _, member := range group.Members {
134 if !slices.Contains(merged.Groups[index].Members, member) {
135 merged.Groups[index].Members = append(merged.Groups[index].Members, member)
136 }
137 }
138 }
139 for key, imported := range source.Imported {
140 if imported {
141 merged.Imported[key] = true
142 }
143 }
144 merged.ManualOrderEnabled = merged.ManualOrderEnabled || source.ManualOrderEnabled
145 merged.MigrationVersion = max(merged.MigrationVersion, source.MigrationVersion)
146 merged.Revision++
147 }
148
149 // repointWorkspaceReferences moves every record that addresses a workspace by
150 // ID. An in-flight create, purge, or recovery entry keeps its owner across the
151 // repair instead of resolving to a workspace this state no longer has.
152 func repointWorkspaceReferences(state *State, from, to string) {
153 for key, mapping := range state.SourceMappings {
154 if mapping.WorkspaceID == from {
155 mapping.WorkspaceID = to
156 state.SourceMappings[key] = mapping
157 }
158 }
159 for key, pending := range state.PendingCreates {
160 if pending.WorkspaceID == from {
161 pending.WorkspaceID = to
162 state.PendingCreates[key] = pending
163 }
164 }
165 for key, operation := range state.PendingOperations {
166 if operation.WorkspaceID == from {
167 operation.WorkspaceID = to
168 state.PendingOperations[key] = operation
169 }
170 }
171 for key, entry := range state.RecoveryEntries {
172 if entry.WorkspaceID == from {
173 entry.WorkspaceID = to
174 state.RecoveryEntries[key] = entry
175 }
176 }
177 }
178
179 // directoryVerified reports whether path names a directory that exists now. A
180 // path resolved through a missing leaf borrows the links and case rules of
181 // whichever ancestor happens to exist, so two distinct directories on an
182 // unreachable volume can share a key. Folding records is not reversible, so it
183 // is only done on a key the directory itself produced.
184 func directoryVerified(path string) bool {
185 info, err := os.Stat(path)
186 return err == nil && info.IsDir()
187 }
188
189 // EnsureWorkspaceResolved registers workspace or returns the authoritative ID
190 // of an existing workspace with the same physical directory identity.
191 func (s *Store) EnsureWorkspaceResolved(ctx context.Context, workspace Workspace) (string, error) {
192 workspace.ID = strings.TrimSpace(workspace.ID)
193 if workspace.ID == "" {
194 return "", errors.New("workspace id is required")
195 }
196 resolvedID := workspace.ID
197 err := s.mutate(ctx, func(state *State) error {
198 var candidate pathidentity.Identity
199 var resolveErr error
200 if strings.TrimSpace(workspace.Root) != "" {
201 candidate, resolveErr = pathidentity.Resolve(workspace.Root, pathidentity.Options{FollowLeaf: true})
202 if resolveErr != nil {
203 return fmt.Errorf("resolve workspace root: %w", resolveErr)
204 }
205 }
206 revalidateCandidate := func() error {
207 if candidate.Key == "" {
208 return nil
209 }
210 latest, latestErr := pathidentity.Resolve(workspace.Root, pathidentity.Options{FollowLeaf: true})
211 if latestErr != nil {
212 return fmt.Errorf("revalidate workspace root: %w", latestErr)
213 }
214 if latest.Key != candidate.Key {
215 return fmt.Errorf("%w: workspace root identity changed during registration", ErrMutationConflict)
216 }
217 return nil
218 }
219 if candidate.Key != "" {
220 matches := matchingWorkspaceIDs(*state, candidate)
221 if len(matches) > 0 {
222 if err := revalidateCandidate(); err != nil {
223 return err
224 }
225 resolvedID = canonicalWorkspaceOwner(*state, matches)
226 if len(matches) > 1 && directoryVerified(candidate.PhysicalPath) {
227 absorbDuplicateWorkspaceIdentities(state, resolvedID, matches)
228 }
229 return nil
230 }
231 }
232 now := time.Now().UTC()
233 current, exists := state.Workspaces[workspace.ID]
234 if exists {
235 if current.Root == workspace.Root || (current.Root == "" && workspace.Root == "") {
236 return revalidateCandidate()
237 }
238 if workspace.ID == GlobalWorkspaceID || candidate.Key == "" {
239 return ErrMutationConflict
240 }
241 resolvedID = versionedWorkspaceID(candidate.Key)
242 if fallback, fallbackExists := state.Workspaces[resolvedID]; fallbackExists {
243 identity, resolveErr := pathidentity.Resolve(fallback.Root, pathidentity.Options{FollowLeaf: true})
244 if resolveErr != nil {
245 return fmt.Errorf("resolve collided workspace %q: %w", resolvedID, resolveErr)
246 }
247 if identity.Key != candidate.Key {
248 return ErrMutationConflict
249 }
250 if err := revalidateCandidate(); err != nil {
251 return err
252 }
253 return nil
254 }
255 }
256 if err := revalidateCandidate(); err != nil {
257 return err
258 }
259 workspace.ID = resolvedID
260 workspace.SessionIDs = []string{}
261 workspace.CreatedAt, workspace.UpdatedAt = now, now
262 state.Workspaces[workspace.ID] = workspace
263 state.WorkspaceIDs = append(state.WorkspaceIDs, workspace.ID)
264 return nil
265 })
266 return resolvedID, err
267 }
268
269 func versionedWorkspaceID(identityKey string) string {
270 sum := sha256.Sum256([]byte("reasonix-workspace-pathidentity-v2\x00" + identityKey))
271 return "project-v2-" + hex.EncodeToString(sum[:12])
272 }
273
273 lines GO