返回 DeepSeek-Reasonix
migration.go
根目录 / internal / migration / migration.go
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
588 lines GO