| 1 | package migration |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "io" |
| 6 | "io/fs" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/config" |
| 13 | "reasonix/internal/event" |
| 14 | ) |
| 15 | |
| 16 | // SessionImport records one legacy session source that contributed sessions. |
| 17 | type SessionImport struct { |
| 18 | Source string |
| 19 | Destination string |
| 20 | Count int |
| 21 | } |
| 22 | |
| 23 | // MemoryImport records one legacy memory source that contributed files. |
| 24 | type MemoryImport struct { |
| 25 | Source string |
| 26 | Destination string |
| 27 | Count int |
| 28 | } |
| 29 | |
| 30 | // Result summarizes an explicit migration rescue run. |
| 31 | type Result struct { |
| 32 | Config *config.MigrationResult |
| 33 | ConfigErr error |
| 34 | MemoryImports []MemoryImport |
| 35 | MemoryErrs []error |
| 36 | SessionImports []SessionImport |
| 37 | SessionErrs []error |
| 38 | } |
| 39 | |
| 40 | // Summary returns the final user-visible status for a migration rescue run. |
| 41 | func (r Result) Summary() string { |
| 42 | importedSessions := 0 |
| 43 | for _, imp := range r.SessionImports { |
| 44 | importedSessions += imp.Count |
| 45 | } |
| 46 | importedMemory := 0 |
| 47 | for _, imp := range r.MemoryImports { |
| 48 | importedMemory += imp.Count |
| 49 | } |
| 50 | warnings := 0 |
| 51 | if r.ConfigErr != nil { |
| 52 | warnings++ |
| 53 | } |
| 54 | warnings += len(r.MemoryErrs) |
| 55 | warnings += len(r.SessionErrs) |
| 56 | switch { |
| 57 | case warnings > 0: |
| 58 | return fmt.Sprintf("migration rescue completed with %d warning(s): imported %d memory file(s) and %d past session(s)", warnings, importedMemory, importedSessions) |
| 59 | case r.Config != nil || importedMemory > 0 || importedSessions > 0: |
| 60 | parts := []string{} |
| 61 | if r.Config != nil { |
| 62 | parts = append(parts, "config/credentials") |
| 63 | } |
| 64 | if importedMemory > 0 { |
| 65 | parts = append(parts, fmt.Sprintf("%d memory file(s)", importedMemory)) |
| 66 | } |
| 67 | if importedSessions > 0 { |
| 68 | parts = append(parts, fmt.Sprintf("%d past session(s)", importedSessions)) |
| 69 | } |
| 70 | return "migration rescue complete: imported " + strings.Join(parts, " and ") |
| 71 | default: |
| 72 | return "migration rescue complete: no legacy data needed migration" |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // RunLegacyRescue retries the non-destructive legacy migration path and emits |
| 77 | // progress notices suitable for both the CLI TUI and desktop frontend. |
| 78 | func RunLegacyRescue(sink event.Sink) Result { |
| 79 | sink = event.Sync(sink) |
| 80 | emit := func(level event.Level, text string) { |
| 81 | sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text}) |
| 82 | } |
| 83 | result := Result{} |
| 84 | if config.IsolatedHomeDir() != "" { |
| 85 | emit(event.LevelInfo, "migration rescue: REASONIX_HOME is set; implicit legacy migration is skipped") |
| 86 | emit(event.LevelInfo, result.Summary()) |
| 87 | return result |
| 88 | } |
| 89 | emit(event.LevelInfo, "migration rescue: checking legacy config and credentials") |
| 90 | migrated, err := config.MigrateLegacyIfNeeded() |
| 91 | result.Config = migrated |
| 92 | result.ConfigErr = err |
| 93 | if err != nil { |
| 94 | emit(event.LevelWarn, "migration rescue: config migration warning: "+err.Error()) |
| 95 | } else if migrated != nil { |
| 96 | emit(event.LevelInfo, migrated.Notice()) |
| 97 | } else { |
| 98 | emit(event.LevelInfo, "migration rescue: current config is already present or no legacy config was found") |
| 99 | } |
| 100 | emit(event.LevelInfo, "migration rescue: scanning legacy memory") |
| 101 | memoryResult := migrateLegacyMemorySources(sink, true) |
| 102 | result.MemoryImports = memoryResult.imports |
| 103 | result.MemoryErrs = memoryResult.errs |
| 104 | emit(event.LevelInfo, "migration rescue: scanning legacy sessions") |
| 105 | sessionResult := migrateLegacySessionSources(sink, true) |
| 106 | result.SessionImports = sessionResult.imports |
| 107 | result.SessionErrs = sessionResult.errs |
| 108 | emit(event.LevelInfo, result.Summary()) |
| 109 | return result |
| 110 | } |
| 111 | |
| 112 | // RunLegacyRescueCommand handles the /migrate argument form shared by the CLI |
| 113 | // TUI and desktop submit path. With no arguments it runs the default rescue; |
| 114 | // with --from it imports sessions from a user-selected legacy directory. |
| 115 | func RunLegacyRescueCommand(args string, sink event.Sink) Result { |
| 116 | source, explicit, err := parseLegacyRescueArgs(args) |
| 117 | if err != nil { |
| 118 | sink = event.Sync(sink) |
| 119 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "migration rescue: " + err.Error()}) |
| 120 | return Result{SessionErrs: []error{err}} |
| 121 | } |
| 122 | if explicit { |
| 123 | return RunLegacySessionImportFrom(source, sink) |
| 124 | } |
| 125 | return RunLegacyRescue(sink) |
| 126 | } |
| 127 | |
| 128 | // RunLegacySessionImportFrom imports sessions from a user-selected legacy root. |
| 129 | // The root may be the old install directory, a data directory, or the sessions |
| 130 | // directory itself. Only sessions are imported; config and credentials stay on |
| 131 | // the default non-destructive migration path. |
| 132 | func RunLegacySessionImportFrom(sourceRoot string, sink event.Sink) Result { |
| 133 | sink = event.Sync(sink) |
| 134 | emit := func(level event.Level, text string) { |
| 135 | sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text}) |
| 136 | } |
| 137 | result := Result{} |
| 138 | sourceRoot = strings.TrimSpace(sourceRoot) |
| 139 | emit(event.LevelInfo, "migration rescue: scanning explicit legacy sessions from "+sourceRoot) |
| 140 | sources, err := explicitLegacySessionSources(sourceRoot) |
| 141 | if err != nil { |
| 142 | result.SessionErrs = append(result.SessionErrs, err) |
| 143 | emit(event.LevelWarn, "migration rescue: "+err.Error()) |
| 144 | emit(event.LevelInfo, result.Summary()) |
| 145 | return result |
| 146 | } |
| 147 | if len(sources) == 0 { |
| 148 | emit(event.LevelInfo, "migration rescue: no legacy session directories found under "+sourceRoot) |
| 149 | emit(event.LevelInfo, result.Summary()) |
| 150 | return result |
| 151 | } |
| 152 | for _, src := range sources { |
| 153 | n, err := agent.MigrateLegacySessionsFromExplicitDir(src.dir, config.SessionDir(), config.ProjectSessionDir) |
| 154 | if err != nil { |
| 155 | result.SessionErrs = append(result.SessionErrs, fmt.Errorf("%s: %w", src.label, err)) |
| 156 | emit(event.LevelWarn, "migration rescue: skipped "+src.label+": "+err.Error()) |
| 157 | continue |
| 158 | } |
| 159 | if n > 0 { |
| 160 | result.SessionImports = append(result.SessionImports, SessionImport{Source: src.label, Destination: config.SessionDir(), Count: n}) |
| 161 | emit(event.LevelInfo, fmt.Sprintf("imported %d past session(s) from %s — resume them with --resume or the history panel", n, src.label)) |
| 162 | } |
| 163 | } |
| 164 | if len(result.SessionImports) == 0 && len(result.SessionErrs) == 0 { |
| 165 | emit(event.LevelInfo, "migration rescue: no legacy sessions needed migration from "+sourceRoot) |
| 166 | } |
| 167 | emit(event.LevelInfo, result.Summary()) |
| 168 | return result |
| 169 | } |
| 170 | |
| 171 | // MigrateLegacyMemorySources imports older memory stores during normal boot. |
| 172 | // It stays quiet unless files were actually copied. |
| 173 | func MigrateLegacyMemorySources(sink event.Sink) []MemoryImport { |
| 174 | if config.IsolatedHomeDir() != "" { |
| 175 | return nil |
| 176 | } |
| 177 | sink = event.Sync(sink) |
| 178 | return migrateLegacyMemorySources(sink, false).imports |
| 179 | } |
| 180 | |
| 181 | // MigrateLegacySessionSources imports older session stores during normal boot. |
| 182 | // It preserves the historical boot-time behavior: notify only when something was |
| 183 | // imported, and otherwise stay quiet. |
| 184 | func MigrateLegacySessionSources(sink event.Sink) []SessionImport { |
| 185 | if config.IsolatedHomeDir() != "" { |
| 186 | return nil |
| 187 | } |
| 188 | sink = event.Sync(sink) |
| 189 | return migrateLegacySessionSources(sink, false).imports |
| 190 | } |
| 191 | |
| 192 | type sessionMigrationResult struct { |
| 193 | imports []SessionImport |
| 194 | errs []error |
| 195 | } |
| 196 | |
| 197 | type memoryMigrationResult struct { |
| 198 | imports []MemoryImport |
| 199 | errs []error |
| 200 | } |
| 201 | |
| 202 | func migrateLegacyMemorySources(sink event.Sink, verbose bool) memoryMigrationResult { |
| 203 | dest := config.MemoryUserDir() |
| 204 | if strings.TrimSpace(dest) == "" { |
| 205 | return memoryMigrationResult{} |
| 206 | } |
| 207 | type legacyMemorySource struct { |
| 208 | root string |
| 209 | label string |
| 210 | } |
| 211 | var sources []legacyMemorySource |
| 212 | addRoot := func(root, label string) { |
| 213 | root = strings.TrimSpace(root) |
| 214 | if root == "" || samePath(root, dest) { |
| 215 | return |
| 216 | } |
| 217 | sources = append(sources, legacyMemorySource{root: root, label: label}) |
| 218 | } |
| 219 | if home, herr := os.UserHomeDir(); herr == nil { |
| 220 | addRoot(filepath.Join(home, ".reasonix"), "~/.reasonix") |
| 221 | } |
| 222 | for _, legacyConfig := range config.LegacyUserConfigPaths() { |
| 223 | addRoot(filepath.Dir(legacyConfig), filepath.Dir(legacyConfig)) |
| 224 | } |
| 225 | |
| 226 | seen := map[string]bool{} |
| 227 | result := memoryMigrationResult{} |
| 228 | for _, src := range sources { |
| 229 | key := cleanAbs(src.root) |
| 230 | if key == "" || seen[key] { |
| 231 | continue |
| 232 | } |
| 233 | seen[key] = true |
| 234 | n, err := copyLegacyMemoryRoot(src.root, dest) |
| 235 | if err != nil { |
| 236 | result.errs = append(result.errs, fmt.Errorf("%s: %w", src.label, err)) |
| 237 | if verbose { |
| 238 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "migration rescue: skipped memory from " + src.label + ": " + err.Error()}) |
| 239 | } |
| 240 | continue |
| 241 | } |
| 242 | if n > 0 { |
| 243 | result.imports = append(result.imports, MemoryImport{Source: src.label, Destination: dest, Count: n}) |
| 244 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("imported %d memory file(s) from %s", n, src.label)}) |
| 245 | } |
| 246 | } |
| 247 | if verbose && len(result.imports) == 0 && len(result.errs) == 0 { |
| 248 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "migration rescue: no legacy memory needed migration"}) |
| 249 | } |
| 250 | return result |
| 251 | } |
| 252 | |
| 253 | func copyLegacyMemoryRoot(srcRoot, destRoot string) (int, error) { |
| 254 | if samePath(srcRoot, destRoot) { |
| 255 | return 0, nil |
| 256 | } |
| 257 | total := 0 |
| 258 | for _, name := range []string{"REASONIX.md", "AGENTS.md", "CLAUDE.md"} { |
| 259 | n, err := copyFileIfMissing(filepath.Join(srcRoot, name), filepath.Join(destRoot, name)) |
| 260 | if err != nil { |
| 261 | return total, err |
| 262 | } |
| 263 | total += n |
| 264 | } |
| 265 | if n, err := copyMissingTree(filepath.Join(srcRoot, "memory"), filepath.Join(destRoot, "memory")); err != nil { |
| 266 | return total, err |
| 267 | } else { |
| 268 | total += n |
| 269 | } |
| 270 | projectsDir := filepath.Join(srcRoot, "projects") |
| 271 | entries, err := os.ReadDir(projectsDir) |
| 272 | if err != nil { |
| 273 | if os.IsNotExist(err) { |
| 274 | return total, nil |
| 275 | } |
| 276 | return total, err |
| 277 | } |
| 278 | for _, entry := range entries { |
| 279 | if !entry.IsDir() { |
| 280 | continue |
| 281 | } |
| 282 | slug := entry.Name() |
| 283 | n, err := copyMissingTree(filepath.Join(projectsDir, slug, "memory"), filepath.Join(destRoot, "projects", slug, "memory")) |
| 284 | if err != nil { |
| 285 | return total, err |
| 286 | } |
| 287 | total += n |
| 288 | } |
| 289 | return total, nil |
| 290 | } |
| 291 | |
| 292 | func copyMissingTree(src, dst string) (int, error) { |
| 293 | info, err := os.Stat(src) |
| 294 | if err != nil { |
| 295 | if os.IsNotExist(err) { |
| 296 | return 0, nil |
| 297 | } |
| 298 | return 0, err |
| 299 | } |
| 300 | if !info.IsDir() { |
| 301 | return copyFileIfMissing(src, dst) |
| 302 | } |
| 303 | count := 0 |
| 304 | err = filepath.WalkDir(src, func(path string, entry fs.DirEntry, walkErr error) error { |
| 305 | if walkErr != nil { |
| 306 | return walkErr |
| 307 | } |
| 308 | rel, err := filepath.Rel(src, path) |
| 309 | if err != nil || rel == "." { |
| 310 | return err |
| 311 | } |
| 312 | target := filepath.Join(dst, rel) |
| 313 | if entry.IsDir() { |
| 314 | return os.MkdirAll(target, 0o755) |
| 315 | } |
| 316 | info, err := entry.Info() |
| 317 | if err != nil { |
| 318 | return err |
| 319 | } |
| 320 | if !info.Mode().IsRegular() { |
| 321 | return nil |
| 322 | } |
| 323 | n, err := copyFileIfMissing(path, target) |
| 324 | count += n |
| 325 | return err |
| 326 | }) |
| 327 | return count, err |
| 328 | } |
| 329 | |
| 330 | func copyFileIfMissing(src, dst string) (int, error) { |
| 331 | in, err := os.Open(src) |
| 332 | if err != nil { |
| 333 | if os.IsNotExist(err) { |
| 334 | return 0, nil |
| 335 | } |
| 336 | return 0, err |
| 337 | } |
| 338 | defer in.Close() |
| 339 | info, err := in.Stat() |
| 340 | if err != nil { |
| 341 | return 0, err |
| 342 | } |
| 343 | if !info.Mode().IsRegular() { |
| 344 | return 0, nil |
| 345 | } |
| 346 | if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { |
| 347 | return 0, err |
| 348 | } |
| 349 | out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) |
| 350 | if err != nil { |
| 351 | if os.IsExist(err) { |
| 352 | return 0, nil |
| 353 | } |
| 354 | return 0, err |
| 355 | } |
| 356 | defer out.Close() |
| 357 | if _, err := io.Copy(out, in); err != nil { |
| 358 | _ = os.Remove(dst) |
| 359 | return 0, err |
| 360 | } |
| 361 | return 1, nil |
| 362 | } |
| 363 | |
| 364 | func migrateLegacySessionSources(sink event.Sink, verbose bool) sessionMigrationResult { |
| 365 | dest := config.SessionDir() |
| 366 | if strings.TrimSpace(dest) == "" { |
| 367 | return sessionMigrationResult{} |
| 368 | } |
| 369 | type legacySource struct { |
| 370 | dir string |
| 371 | dest string |
| 372 | label string |
| 373 | migrate func(srcDir, globalDest string, projectDir func(string) string) (int, error) |
| 374 | } |
| 375 | var sources []legacySource |
| 376 | addFlatSource := func(dir, label string, migrate func(string, string, func(string) string) (int, error)) { |
| 377 | sources = append(sources, legacySource{ |
| 378 | dir: dir, |
| 379 | dest: dest, |
| 380 | label: label, |
| 381 | migrate: migrate, |
| 382 | }) |
| 383 | } |
| 384 | addProjectSources := func(root string) { |
| 385 | root = strings.TrimSpace(root) |
| 386 | if root == "" || config.MemoryUserDir() == "" { |
| 387 | return |
| 388 | } |
| 389 | if samePath(root, config.MemoryUserDir()) { |
| 390 | return |
| 391 | } |
| 392 | projectsDir := filepath.Join(root, "projects") |
| 393 | entries, err := os.ReadDir(projectsDir) |
| 394 | if err != nil { |
| 395 | return |
| 396 | } |
| 397 | for _, entry := range entries { |
| 398 | if !entry.IsDir() { |
| 399 | continue |
| 400 | } |
| 401 | slug := entry.Name() |
| 402 | srcDir := filepath.Join(projectsDir, slug, "sessions") |
| 403 | dstDir := filepath.Join(config.MemoryUserDir(), "projects", slug, "sessions") |
| 404 | sources = append(sources, legacySource{ |
| 405 | dir: srcDir, |
| 406 | dest: dstDir, |
| 407 | label: srcDir, |
| 408 | migrate: agent.MigrateLegacySessionsFromConfigDir, |
| 409 | }) |
| 410 | } |
| 411 | } |
| 412 | if home, herr := os.UserHomeDir(); herr == nil { |
| 413 | reasonixHome := filepath.Join(home, ".reasonix") |
| 414 | addFlatSource(filepath.Join(reasonixHome, "sessions"), "~/.reasonix/sessions", agent.MigrateLegacySessions) |
| 415 | addProjectSources(reasonixHome) |
| 416 | } |
| 417 | for _, legacyConfig := range config.LegacyUserConfigPaths() { |
| 418 | legacyDir := filepath.Join(filepath.Dir(legacyConfig), "sessions") |
| 419 | addFlatSource(legacyDir, legacyDir, agent.MigrateLegacySessionsFromConfigDir) |
| 420 | addProjectSources(filepath.Dir(legacyConfig)) |
| 421 | } |
| 422 | // Back-fill v0.x sessions from the current user config session directory as |
| 423 | // well. This covers users whose platform config root was redirected before the |
| 424 | // Go rewrite; their event logs can already live where v2 stores sessions. |
| 425 | addFlatSource(dest, dest, agent.MigrateLegacySessionsFromConfigDir) |
| 426 | |
| 427 | seen := map[string]bool{} |
| 428 | result := sessionMigrationResult{} |
| 429 | for _, src := range sources { |
| 430 | if strings.TrimSpace(src.dir) == "" { |
| 431 | continue |
| 432 | } |
| 433 | sourceDest := strings.TrimSpace(src.dest) |
| 434 | if sourceDest == "" { |
| 435 | sourceDest = dest |
| 436 | } |
| 437 | key := filepath.Clean(src.dir) + "=>" + filepath.Clean(sourceDest) |
| 438 | if seen[key] { |
| 439 | continue |
| 440 | } |
| 441 | seen[key] = true |
| 442 | n, err := src.migrate(src.dir, sourceDest, config.ProjectSessionDir) |
| 443 | if err != nil { |
| 444 | result.errs = append(result.errs, fmt.Errorf("%s: %w", src.label, err)) |
| 445 | if verbose { |
| 446 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "migration rescue: skipped " + src.label + ": " + err.Error()}) |
| 447 | } |
| 448 | continue |
| 449 | } |
| 450 | if n > 0 { |
| 451 | result.imports = append(result.imports, SessionImport{Source: src.label, Destination: sourceDest, Count: n}) |
| 452 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("imported %d past session(s) from %s — resume them with --resume or the history panel", n, src.label)}) |
| 453 | } |
| 454 | } |
| 455 | if verbose && len(result.imports) == 0 && len(result.errs) == 0 { |
| 456 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "migration rescue: no legacy sessions needed migration"}) |
| 457 | } |
| 458 | return result |
| 459 | } |
| 460 | |
| 461 | type explicitSessionSource struct { |
| 462 | dir string |
| 463 | label string |
| 464 | } |
| 465 | |
| 466 | func parseLegacyRescueArgs(args string) (source string, explicit bool, err error) { |
| 467 | args = strings.TrimSpace(args) |
| 468 | if args == "" { |
| 469 | return "", false, nil |
| 470 | } |
| 471 | const flag = "--from" |
| 472 | switch { |
| 473 | case args == flag: |
| 474 | return "", false, fmt.Errorf("--from requires a legacy directory path") |
| 475 | case strings.HasPrefix(args, flag+"="): |
| 476 | source = strings.TrimSpace(strings.TrimPrefix(args, flag+"=")) |
| 477 | case len(args) > len(flag) && strings.HasPrefix(args, flag) && (args[len(flag)] == ' ' || args[len(flag)] == '\t'): |
| 478 | source = strings.TrimSpace(args[len(flag):]) |
| 479 | default: |
| 480 | first := args |
| 481 | if i := strings.IndexAny(first, " \t"); i >= 0 { |
| 482 | first = first[:i] |
| 483 | } |
| 484 | return "", false, fmt.Errorf("unknown /migrate option %q; use /migrate --from <legacy-dir>", first) |
| 485 | } |
| 486 | source = trimMatchingQuotes(source) |
| 487 | if source == "" { |
| 488 | return "", false, fmt.Errorf("--from requires a legacy directory path") |
| 489 | } |
| 490 | return source, true, nil |
| 491 | } |
| 492 | |
| 493 | func trimMatchingQuotes(s string) string { |
| 494 | s = strings.TrimSpace(s) |
| 495 | if len(s) < 2 { |
| 496 | return s |
| 497 | } |
| 498 | if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { |
| 499 | return s[1 : len(s)-1] |
| 500 | } |
| 501 | return s |
| 502 | } |
| 503 | |
| 504 | func explicitLegacySessionSources(root string) ([]explicitSessionSource, error) { |
| 505 | root = strings.TrimSpace(root) |
| 506 | if root == "" { |
| 507 | return nil, fmt.Errorf("--from requires a legacy directory path") |
| 508 | } |
| 509 | info, err := os.Stat(root) |
| 510 | if err != nil { |
| 511 | return nil, fmt.Errorf("legacy directory %s is not readable: %w", root, err) |
| 512 | } |
| 513 | if !info.IsDir() { |
| 514 | return nil, fmt.Errorf("legacy path %s is not a directory", root) |
| 515 | } |
| 516 | candidates := []string{ |
| 517 | filepath.Join(root, "sessions"), |
| 518 | filepath.Join(root, ".reasonix", "sessions"), |
| 519 | filepath.Join(root, "reasonix", "sessions"), |
| 520 | } |
| 521 | var out []explicitSessionSource |
| 522 | seen := map[string]bool{} |
| 523 | for _, dir := range candidates { |
| 524 | key := cleanAbs(dir) |
| 525 | if key == "" || seen[key] { |
| 526 | continue |
| 527 | } |
| 528 | seen[key] = true |
| 529 | if dirLooksLikeLegacySessionDir(dir) { |
| 530 | out = append(out, explicitSessionSource{dir: dir, label: dir}) |
| 531 | } |
| 532 | } |
| 533 | if len(out) == 0 && dirLooksLikeLegacySessionDir(root) { |
| 534 | out = append(out, explicitSessionSource{dir: root, label: root}) |
| 535 | } |
| 536 | return out, nil |
| 537 | } |
| 538 | |
| 539 | func dirLooksLikeLegacySessionDir(dir string) bool { |
| 540 | entries, err := os.ReadDir(dir) |
| 541 | if err != nil { |
| 542 | return false |
| 543 | } |
| 544 | for _, entry := range entries { |
| 545 | if !entry.IsDir() && legacySessionArtifactName(entry.Name()) { |
| 546 | return true |
| 547 | } |
| 548 | } |
| 549 | for _, entry := range entries { |
| 550 | if !entry.IsDir() || entry.Name() == "subagents" { |
| 551 | continue |
| 552 | } |
| 553 | subEntries, err := os.ReadDir(filepath.Join(dir, entry.Name())) |
| 554 | if err != nil { |
| 555 | continue |
| 556 | } |
| 557 | for _, sub := range subEntries { |
| 558 | if !sub.IsDir() && legacySessionArtifactName(sub.Name()) { |
| 559 | return true |
| 560 | } |
| 561 | } |
| 562 | } |
| 563 | return false |
| 564 | } |
| 565 | |
| 566 | func legacySessionArtifactName(name string) bool { |
| 567 | return strings.HasSuffix(name, ".events.jsonl") || |
| 568 | strings.HasSuffix(name, ".jsonl") || |
| 569 | strings.HasSuffix(name, ".jsonl.bak") |
| 570 | } |
| 571 | |
| 572 | func samePath(a, b string) bool { |
| 573 | aa := cleanAbs(a) |
| 574 | bb := cleanAbs(b) |
| 575 | return aa != "" && bb != "" && aa == bb |
| 576 | } |
| 577 | |
| 578 | func cleanAbs(path string) string { |
| 579 | path = strings.TrimSpace(path) |
| 580 | if path == "" { |
| 581 | return "" |
| 582 | } |
| 583 | if abs, err := filepath.Abs(path); err == nil { |
| 584 | path = abs |
| 585 | } |
| 586 | return filepath.Clean(path) |
| 587 | } |
| 588 |