| 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 |