返回 DeepSeek-Reasonix
topic_legacy_migration.go
根目录 / desktop / topic_legacy_migration.go
1 package main
2
3 import (
4 "path/filepath"
5 "strings"
6 "sync"
7
8 "reasonix/internal/agent"
9 "reasonix/internal/config"
10 )
11
12 // legacyMigrationMu serializes the lockless load-modify-save of the projects /
13 // topic-title files: this migration runs from every concurrent buildTabController
14 // and from ListProjectTree, so without it parallel runs lose each other's appends.
15 var legacyMigrationMu sync.Mutex
16
17 func migrateLegacySessionsIntoGlobalTopics(dir string) []string {
18 return migrateLegacySessionsIntoGlobalTopicsWithGates(dir, topicMigrationDone, topicIndexRepairDone, ignoreMigratedSession)
19 }
20
21 // forceMigrateLegacySessionsIntoGlobalTopicsWithPaths bypasses disposable
22 // completion markers for explicit reconciliation. The directory signature
23 // normally keeps background passes cheap, but no signature can be an authority
24 // boundary: an old CLI, restored backup, or coarse filesystem timestamp must
25 // still have a path that deterministically re-evaluates every session.
26 func forceMigrateLegacySessionsIntoGlobalTopicsWithPaths(dir string) ([]string, []string) {
27 paths := []string{}
28 topics := migrateLegacySessionsIntoGlobalTopicsWithGates(dir, topicMigrationNeverDone, topicMigrationNeverDone,
29 func(path string) { paths = append(paths, path) })
30 return topics, paths
31 }
32
33 func topicMigrationNeverDone(string) bool { return false }
34 func ignoreMigratedSession(string) {}
35
36 func noteMigratedSession(topics []string, topicID, path string, onMigrated func(string)) []string {
37 onMigrated(path)
38 return append(topics, topicID)
39 }
40
41 func migrateLegacySessionsIntoGlobalTopicsWithGates(dir string, migrationDone, repairDone func(string) bool, onMigrated func(string)) []string {
42 if strings.TrimSpace(dir) == "" {
43 return nil
44 }
45 repairedTopicIDs := repairIndexedSessionTopicsWithGate(dir, repairDone)
46 // One-shot per dir: once the migration pass has completed, skip the full
47 // per-render session scan entirely.
48 if migrationDone(dir) {
49 return repairedTopicIDs
50 }
51 scope, workspaceRoot, topicTitleRoot, ok := legacyMigrationTargetForDir(dir)
52 if !ok {
53 return nil
54 }
55 legacyMigrationMu.Lock()
56 defer legacyMigrationMu.Unlock()
57 // Re-check under the lock: another render may have completed the pass while
58 // this one waited.
59 if migrationDone(dir) {
60 return nil
61 }
62 infos, err := agent.ListSessionOrder(dir)
63 if err != nil {
64 return nil // transient read error — retry on the next render, leave unmarked
65 }
66
67 var migratedTopicIDs []string
68 var titles map[string]string
69 var topicTitles map[string]string
70 var topicSources map[string]string
71 // deferred stays false only when every session was either migrated or is
72 // permanently non-migratable. A transient skip (unreadable meta, empty
73 // session that may gain content, failed write) sets it, keeping the dir
74 deferred := false
75 for _, info := range infos {
76 if sessionOrderInfoIsHiddenRecovery(info, dir) {
77 continue
78 }
79 if strings.TrimSpace(info.TopicID) != "" {
80 continue
81 }
82 if meta, ok, err := agent.LoadBranchMeta(info.Path); err != nil {
83 deferred = true
84 continue
85 } else if ok && !legacySessionMetaMatchesMigrationTarget(meta, scope, workspaceRoot) {
86 continue
87 }
88 topicID := legacySessionTopicID(info.Path)
89 if topicID == "" {
90 continue
91 }
92 preview, turns := agent.SessionPreview(info.Path)
93 if turns == 0 {
94 deferred = true // empty now, but a later turn could make it migratable
95 continue
96 }
97 if titles == nil {
98 titles = loadSessionTitles(dir)
99 }
100 title := strings.TrimSpace(titles[filepath.Base(info.Path)])
101 if title == "" {
102 title = topicTitleFromText(preview)
103 } else if normalized := topicTitleFromText(title); normalized != "" {
104 title = normalized
105 }
106 if title == "" {
107 when := info.LastActivityAt
108 if when.IsZero() {
109 when = info.ModTime
110 }
111 if when.IsZero() {
112 title = "历史会话"
113 } else {
114 title = "历史会话 " + when.Local().Format("2006-01-02")
115 }
116 }
117
118 migrated, err := func() (bool, error) {
119 // Read-modify-write on the branch-meta sidecar: hold the per-path
120 // meta lock so agent-side writers (autosave revision bumps,
121 // in-flight markers) can't interleave between the load and save
122 unlock, lockErr := agent.LockSessionMetaPath(info.Path)
123 if lockErr != nil {
124 return false, lockErr
125 }
126 defer unlock()
127 meta, err := agent.EnsureBranchMetaLocked(info.Path)
128 if err != nil {
129 return false, err
130 }
131 // Preserve scoped sessions only when their existing ownership matches
132 // the directory being migrated.
133 if !legacySessionMetaMatchesMigrationTarget(meta, scope, workspaceRoot) {
134 return false, nil
135 }
136 meta.Scope = scope
137 meta.WorkspaceRoot = workspaceRoot
138 meta.TopicID = topicID
139 meta.TopicTitle = title
140 return true, agent.SaveBranchMetaPreserveUpdatedLocked(info.Path, meta)
141 }()
142 if err != nil {
143 deferred = true
144 continue
145 }
146 if !migrated {
147 continue
148 }
149 if topicTitles == nil {
150 topicTitles = loadTopicTitles(topicTitleRoot)
151 }
152 if topicSources == nil {
153 topicSources = loadTopicTitleSources(topicTitleRoot)
154 }
155 if strings.TrimSpace(topicTitles[topicID]) == "" {
156 topicTitles[topicID] = title
157 topicSources[topicID] = topicTitleSourceManual
158 }
159 migratedTopicIDs = noteMigratedSession(migratedTopicIDs, topicID, info.Path, onMigrated)
160 }
161 if len(migratedTopicIDs) == 0 {
162 if !deferred {
163 markTopicMigrationDone(dir) // nothing left to migrate — gate future scans
164 }
165 return repairedTopicIDs
166 }
167 _ = prependTopicsInProjectsFile(workspaceRoot, migratedTopicIDs, false)
168 // Same fresh tombstone re-check as the repair pass: these are whole-map
169 // saves, so a concurrent DeleteTopic of an unrelated topic must not have
170 // its title written back by this migration batch.
171 pruneDeletedTopicEntries(topicTitles, topicSources)
172 if topicTitles != nil || topicSources != nil {
173 _ = saveTopicTitleIndex(topicTitleRoot, topicTitles, topicSources)
174 }
175 invalidateTopicSessionIndex(dir)
176 if !deferred {
177 markTopicMigrationDone(dir) // pass complete with nothing deferred
178 }
179 return uniqueStrings(append(repairedTopicIDs, migratedTopicIDs...))
180 }
181
182 // pruneDeletedTopicEntries drops tombstoned topics from scan-built title and
183 // source maps just before they are persisted, re-reading DeletedTopics so a
184 // DeleteTopic that landed after the scan snapshot wins. The maps are loaded
185 // whole at scan start and saved whole at the end; without this re-check the
186 // save would write the deleted topic's stale entries back, and a title-map
187 func pruneDeletedTopicEntries(maps ...map[string]string) []string {
188 deleted := loadProjectsFile().DeletedTopics
189 if len(deleted) == 0 {
190 return nil
191 }
192 for _, m := range maps {
193 for _, id := range deleted {
194 delete(m, id)
195 }
196 }
197 return deleted
198 }
199
200 func repairIndexedSessionTopicsWithGate(dir string, repairDone func(string) bool) []string {
201 if strings.TrimSpace(dir) == "" || repairDone(dir) {
202 return nil
203 }
204 scope, workspaceRoot, topicTitleRoot, ok := legacyMigrationTargetForDir(dir)
205 if !ok {
206 return nil
207 }
208 legacyMigrationMu.Lock()
209 defer legacyMigrationMu.Unlock()
210 if repairDone(dir) {
211 return nil
212 }
213 infos, err := agent.ListSessionOrder(dir)
214 if err != nil {
215 return nil
216 }
217
218 topicTitles, err := loadTopicTitlesForUpdate(topicTitleRoot)
219 if err != nil {
220 return nil
221 }
222 topicSources, err := loadTopicTitleSourcesForUpdate(topicTitleRoot)
223 if err != nil {
224 return nil
225 }
226 projects := loadProjectsFile()
227 deletedTopics := projects.DeletedTopics
228 // Repair only topics missing from the sidebar index. Skipping topics that
229 // are already listed and titled keeps steady-state rescans write-free:
230 // otherwise every rescan (any session activity invalidates the marker)
231 indexedTopics := projects.GlobalTopics
232 if scope == "project" {
233 indexedTopics = nil
234 if i := projectIndexByRoot(projects.Projects, workspaceRoot); i >= 0 {
235 indexedTopics = projects.Projects[i].Topics
236 }
237 }
238 indexedSet := make(map[string]bool, len(indexedTopics))
239 for _, id := range indexedTopics {
240 indexedSet[id] = true
241 }
242 var repairedTopicIDs []string
243 var sessionTitles map[string]string
244 titlesChanged := false
245 sourcesChanged := false
246 deferred := false
247 for _, info := range infos {
248 if sessionOrderInfoIsHiddenRecovery(info, dir) {
249 continue
250 }
251 topicID := strings.TrimSpace(info.TopicID)
252 if topicID == "" {
253 continue
254 }
255 if indexedSet[topicID] && strings.TrimSpace(topicTitles[topicID]) != "" {
256 continue // fully indexed already — nothing to repair, skip the meta read
257 }
258 meta, ok, err := agent.LoadBranchMeta(info.Path)
259 if err != nil {
260 deferred = true
261 continue
262 }
263 if !ok || strings.TrimSpace(meta.TopicID) == "" {
264 continue
265 }
266 if containsDesktopString(deletedTopics, topicID) {
267 continue
268 }
269 if !legacySessionScopeMatchesMigrationTarget(meta, scope, workspaceRoot) {
270 continue
271 }
272 title, titleChanged, err := repairedIndexedSessionTopicTitle(dir, &sessionTitles, topicTitles, topicID, info, meta)
273 if err != nil {
274 deferred = true
275 continue
276 }
277 repairedTopicIDs = append(repairedTopicIDs, topicID)
278 if titleChanged {
279 topicTitles[topicID] = title
280 titlesChanged = true
281 }
282 if strings.TrimSpace(topicSources[topicID]) == "" {
283 topicSources[topicID] = topicTitleSourceManual
284 sourcesChanged = true
285 }
286 }
287 if len(repairedTopicIDs) > 0 {
288 // Re-check tombstones right before persisting: a DeleteTopic landing
289 // after the scan snapshot must win. The prepend re-filters under the
290 // projects-file lock; the whole-map title/source saves and the
291 if deletedNow := pruneDeletedTopicEntries(topicTitles, topicSources); len(deletedNow) > 0 {
292 deletedSet := make(map[string]bool, len(deletedNow))
293 for _, id := range deletedNow {
294 deletedSet[id] = true
295 }
296 live := repairedTopicIDs[:0]
297 for _, id := range repairedTopicIDs {
298 if !deletedSet[id] {
299 live = append(live, id)
300 }
301 }
302 repairedTopicIDs = live
303 }
304 }
305 if len(repairedTopicIDs) > 0 {
306 if err := prependTopicsInProjectsFile(workspaceRoot, repairedTopicIDs, false); err != nil {
307 deferred = true
308 }
309 if titlesChanged || sourcesChanged {
310 if err := saveTopicTitleIndex(topicTitleRoot, topicTitles, topicSources); err != nil {
311 deferred = true
312 }
313 }
314 }
315 if !deferred {
316 markTopicIndexRepairDone(dir)
317 return uniqueStrings(repairedTopicIDs)
318 }
319 return nil
320 }
321
322 func repairedIndexedSessionTopicTitle(dir string, sessionTitles *map[string]string, topicTitles map[string]string, topicID string, info agent.SessionOrderInfo, meta agent.BranchMeta) (string, bool, error) {
323 if strings.TrimSpace(topicTitles[topicID]) != "" {
324 return "", false, nil
325 }
326 if *sessionTitles == nil {
327 var err error
328 *sessionTitles, err = loadSessionTitlesWithError(dir)
329 if err != nil {
330 return "", false, err
331 }
332 }
333 title, err := indexedSessionTopicTitle(*sessionTitles, info, meta)
334 if err != nil {
335 // The transcript is still the authority when its listing projection is
336 // stale. Leave the topic untouched so a later pass can retry instead of
337 // certifying a permanent default title.
338 return "", false, err
339 }
340 if title == "" {
341 title = defaultTopicTitle
342 }
343 return title, true, nil
344 }
345
346 func indexedSessionTopicTitle(sessionTitles map[string]string, info agent.SessionOrderInfo, meta agent.BranchMeta) (string, error) {
347 if title := topicTitleFromText(meta.TopicTitle); title != "" {
348 return title, nil
349 }
350 if title := topicTitleFromText(info.TopicTitle); title != "" {
351 return title, nil
352 }
353 if title := topicTitleFromText(sessionTitles[filepath.Base(info.Path)]); title != "" {
354 return title, nil
355 }
356 if !info.ListingProjectionFresh() {
357 // Pre-upgrade sidecars can identify the transcript generation without the
358 // newer listing fields. This one-shot repair must decode the transcript
359 // rather than certify a generic title from a stale projection.
360 preview, _, err := agent.SessionPreviewWithError(info.Path)
361 if err != nil {
362 return "", err
363 }
364 return topicTitleFromText(preview), nil
365 }
366 return topicTitleFromText(info.Preview), nil
367 }
368
369 func sessionOrderInfoIsAutomaticRecovery(info agent.SessionOrderInfo) bool {
370 return info.Recovered ||
371 strings.TrimSpace(info.RecoveryDigest) != "" ||
372 isAutomaticRecoverySessionPath(info.Path)
373 }
374
375 func sessionInfoIsAutomaticRecovery(info agent.SessionInfo) bool {
376 return info.Recovered ||
377 strings.TrimSpace(info.RecoveryDigest) != "" ||
378 isAutomaticRecoverySessionPath(info.Path)
379 }
380
381 func sessionOrderInfoIsUnmodifiedRecoveryCopy(info agent.SessionOrderInfo, parentDir string) bool {
382 return sessionOrderInfoIsAutomaticRecovery(info) &&
383 agent.RecoveryBranchCoveredByParent(info.Path, parentDir)
384 }
385
386 func sessionInfoIsUnmodifiedRecoveryCopy(info agent.SessionInfo, parentDir string) bool {
387 return sessionInfoIsAutomaticRecovery(info) &&
388 agent.RecoveryBranchCoveredByParent(info.Path, parentDir)
389 }
390
391 func isAutomaticRecoverySessionPath(path string) bool {
392 return agent.LooksLikeRecoveryFilename(path)
393 }
394
395 func legacyMigrationTargetForDir(dir string) (scope, workspaceRoot, topicTitleRoot string, ok bool) {
396 dir = cleanDesktopPath(dir)
397 if dir == "" {
398 return "", "", "", false
399 }
400 if sameDesktopPath(dir, config.SessionDir()) || sameDesktopPath(dir, desktopSessionDir(globalWorkspaceRoot())) {
401 return "global", "", "", true
402 }
403 for _, p := range loadProjectsFile().Projects {
404 if sameDesktopPath(config.ProjectSessionDir(p.Root), dir) {
405 return "project", p.Root, p.Root, true
406 }
407 }
408 return "", "", "", false
409 }
410
410 lines GO