588 lines
18 KiB
Go
588 lines
18 KiB
Go
package migration
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/event"
|
|
)
|
|
|
|
// SessionImport records one legacy session source that contributed sessions.
|
|
type SessionImport struct {
|
|
Source string
|
|
Destination string
|
|
Count int
|
|
}
|
|
|
|
// MemoryImport records one legacy memory source that contributed files.
|
|
type MemoryImport struct {
|
|
Source string
|
|
Destination string
|
|
Count int
|
|
}
|
|
|
|
// Result summarizes an explicit migration rescue run.
|
|
type Result struct {
|
|
Config *config.MigrationResult
|
|
ConfigErr error
|
|
MemoryImports []MemoryImport
|
|
MemoryErrs []error
|
|
SessionImports []SessionImport
|
|
SessionErrs []error
|
|
}
|
|
|
|
// Summary returns the final user-visible status for a migration rescue run.
|
|
func (r Result) Summary() string {
|
|
importedSessions := 0
|
|
for _, imp := range r.SessionImports {
|
|
importedSessions += imp.Count
|
|
}
|
|
importedMemory := 0
|
|
for _, imp := range r.MemoryImports {
|
|
importedMemory += imp.Count
|
|
}
|
|
warnings := 0
|
|
if r.ConfigErr != nil {
|
|
warnings++
|
|
}
|
|
warnings += len(r.MemoryErrs)
|
|
warnings += len(r.SessionErrs)
|
|
switch {
|
|
case warnings > 0:
|
|
return fmt.Sprintf("migration rescue completed with %d warning(s): imported %d memory file(s) and %d past session(s)", warnings, importedMemory, importedSessions)
|
|
case r.Config != nil || importedMemory > 0 || importedSessions > 0:
|
|
parts := []string{}
|
|
if r.Config != nil {
|
|
parts = append(parts, "config/credentials")
|
|
}
|
|
if importedMemory > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d memory file(s)", importedMemory))
|
|
}
|
|
if importedSessions > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d past session(s)", importedSessions))
|
|
}
|
|
return "migration rescue complete: imported " + strings.Join(parts, " and ")
|
|
default:
|
|
return "migration rescue complete: no legacy data needed migration"
|
|
}
|
|
}
|
|
|
|
// RunLegacyRescue retries the non-destructive legacy migration path and emits
|
|
// progress notices suitable for both the CLI TUI and desktop frontend.
|
|
func RunLegacyRescue(sink event.Sink) Result {
|
|
sink = event.Sync(sink)
|
|
emit := func(level event.Level, text string) {
|
|
sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text})
|
|
}
|
|
result := Result{}
|
|
if config.IsolatedHomeDir() != "" {
|
|
emit(event.LevelInfo, "migration rescue: REASONIX_HOME is set; implicit legacy migration is skipped")
|
|
emit(event.LevelInfo, result.Summary())
|
|
return result
|
|
}
|
|
emit(event.LevelInfo, "migration rescue: checking legacy config and credentials")
|
|
migrated, err := config.MigrateLegacyIfNeeded()
|
|
result.Config = migrated
|
|
result.ConfigErr = err
|
|
if err != nil {
|
|
emit(event.LevelWarn, "migration rescue: config migration warning: "+err.Error())
|
|
} else if migrated != nil {
|
|
emit(event.LevelInfo, migrated.Notice())
|
|
} else {
|
|
emit(event.LevelInfo, "migration rescue: current config is already present or no legacy config was found")
|
|
}
|
|
emit(event.LevelInfo, "migration rescue: scanning legacy memory")
|
|
memoryResult := migrateLegacyMemorySources(sink, true)
|
|
result.MemoryImports = memoryResult.imports
|
|
result.MemoryErrs = memoryResult.errs
|
|
emit(event.LevelInfo, "migration rescue: scanning legacy sessions")
|
|
sessionResult := migrateLegacySessionSources(sink, true)
|
|
result.SessionImports = sessionResult.imports
|
|
result.SessionErrs = sessionResult.errs
|
|
emit(event.LevelInfo, result.Summary())
|
|
return result
|
|
}
|
|
|
|
// RunLegacyRescueCommand handles the /migrate argument form shared by the CLI
|
|
// TUI and desktop submit path. With no arguments it runs the default rescue;
|
|
// with --from it imports sessions from a user-selected legacy directory.
|
|
func RunLegacyRescueCommand(args string, sink event.Sink) Result {
|
|
source, explicit, err := parseLegacyRescueArgs(args)
|
|
if err != nil {
|
|
sink = event.Sync(sink)
|
|
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "migration rescue: " + err.Error()})
|
|
return Result{SessionErrs: []error{err}}
|
|
}
|
|
if explicit {
|
|
return RunLegacySessionImportFrom(source, sink)
|
|
}
|
|
return RunLegacyRescue(sink)
|
|
}
|
|
|
|
// RunLegacySessionImportFrom imports sessions from a user-selected legacy root.
|
|
// The root may be the old install directory, a data directory, or the sessions
|
|
// directory itself. Only sessions are imported; config and credentials stay on
|
|
// the default non-destructive migration path.
|
|
func RunLegacySessionImportFrom(sourceRoot string, sink event.Sink) Result {
|
|
sink = event.Sync(sink)
|
|
emit := func(level event.Level, text string) {
|
|
sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text})
|
|
}
|
|
result := Result{}
|
|
sourceRoot = strings.TrimSpace(sourceRoot)
|
|
emit(event.LevelInfo, "migration rescue: scanning explicit legacy sessions from "+sourceRoot)
|
|
sources, err := explicitLegacySessionSources(sourceRoot)
|
|
if err != nil {
|
|
result.SessionErrs = append(result.SessionErrs, err)
|
|
emit(event.LevelWarn, "migration rescue: "+err.Error())
|
|
emit(event.LevelInfo, result.Summary())
|
|
return result
|
|
}
|
|
if len(sources) == 0 {
|
|
emit(event.LevelInfo, "migration rescue: no legacy session directories found under "+sourceRoot)
|
|
emit(event.LevelInfo, result.Summary())
|
|
return result
|
|
}
|
|
for _, src := range sources {
|
|
n, err := agent.MigrateLegacySessionsFromExplicitDir(src.dir, config.SessionDir(), config.ProjectSessionDir)
|
|
if err != nil {
|
|
result.SessionErrs = append(result.SessionErrs, fmt.Errorf("%s: %w", src.label, err))
|
|
emit(event.LevelWarn, "migration rescue: skipped "+src.label+": "+err.Error())
|
|
continue
|
|
}
|
|
if n > 0 {
|
|
result.SessionImports = append(result.SessionImports, SessionImport{Source: src.label, Destination: config.SessionDir(), Count: n})
|
|
emit(event.LevelInfo, fmt.Sprintf("imported %d past session(s) from %s — resume them with --resume or the history panel", n, src.label))
|
|
}
|
|
}
|
|
if len(result.SessionImports) == 0 && len(result.SessionErrs) == 0 {
|
|
emit(event.LevelInfo, "migration rescue: no legacy sessions needed migration from "+sourceRoot)
|
|
}
|
|
emit(event.LevelInfo, result.Summary())
|
|
return result
|
|
}
|
|
|
|
// MigrateLegacyMemorySources imports older memory stores during normal boot.
|
|
// It stays quiet unless files were actually copied.
|
|
func MigrateLegacyMemorySources(sink event.Sink) []MemoryImport {
|
|
if config.IsolatedHomeDir() != "" {
|
|
return nil
|
|
}
|
|
sink = event.Sync(sink)
|
|
return migrateLegacyMemorySources(sink, false).imports
|
|
}
|
|
|
|
// MigrateLegacySessionSources imports older session stores during normal boot.
|
|
// It preserves the historical boot-time behavior: notify only when something was
|
|
// imported, and otherwise stay quiet.
|
|
func MigrateLegacySessionSources(sink event.Sink) []SessionImport {
|
|
if config.IsolatedHomeDir() != "" {
|
|
return nil
|
|
}
|
|
sink = event.Sync(sink)
|
|
return migrateLegacySessionSources(sink, false).imports
|
|
}
|
|
|
|
type sessionMigrationResult struct {
|
|
imports []SessionImport
|
|
errs []error
|
|
}
|
|
|
|
type memoryMigrationResult struct {
|
|
imports []MemoryImport
|
|
errs []error
|
|
}
|
|
|
|
func migrateLegacyMemorySources(sink event.Sink, verbose bool) memoryMigrationResult {
|
|
dest := config.MemoryUserDir()
|
|
if strings.TrimSpace(dest) == "" {
|
|
return memoryMigrationResult{}
|
|
}
|
|
type legacyMemorySource struct {
|
|
root string
|
|
label string
|
|
}
|
|
var sources []legacyMemorySource
|
|
addRoot := func(root, label string) {
|
|
root = strings.TrimSpace(root)
|
|
if root == "" || samePath(root, dest) {
|
|
return
|
|
}
|
|
sources = append(sources, legacyMemorySource{root: root, label: label})
|
|
}
|
|
if home, herr := os.UserHomeDir(); herr == nil {
|
|
addRoot(filepath.Join(home, ".reasonix"), "~/.reasonix")
|
|
}
|
|
for _, legacyConfig := range config.LegacyUserConfigPaths() {
|
|
addRoot(filepath.Dir(legacyConfig), filepath.Dir(legacyConfig))
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
result := memoryMigrationResult{}
|
|
for _, src := range sources {
|
|
key := cleanAbs(src.root)
|
|
if key == "" || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
n, err := copyLegacyMemoryRoot(src.root, dest)
|
|
if err != nil {
|
|
result.errs = append(result.errs, fmt.Errorf("%s: %w", src.label, err))
|
|
if verbose {
|
|
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "migration rescue: skipped memory from " + src.label + ": " + err.Error()})
|
|
}
|
|
continue
|
|
}
|
|
if n > 0 {
|
|
result.imports = append(result.imports, MemoryImport{Source: src.label, Destination: dest, Count: n})
|
|
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("imported %d memory file(s) from %s", n, src.label)})
|
|
}
|
|
}
|
|
if verbose && len(result.imports) == 0 && len(result.errs) == 0 {
|
|
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "migration rescue: no legacy memory needed migration"})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func copyLegacyMemoryRoot(srcRoot, destRoot string) (int, error) {
|
|
if samePath(srcRoot, destRoot) {
|
|
return 0, nil
|
|
}
|
|
total := 0
|
|
for _, name := range []string{"REASONIX.md", "AGENTS.md", "CLAUDE.md"} {
|
|
n, err := copyFileIfMissing(filepath.Join(srcRoot, name), filepath.Join(destRoot, name))
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
total += n
|
|
}
|
|
if n, err := copyMissingTree(filepath.Join(srcRoot, "memory"), filepath.Join(destRoot, "memory")); err != nil {
|
|
return total, err
|
|
} else {
|
|
total += n
|
|
}
|
|
projectsDir := filepath.Join(srcRoot, "projects")
|
|
entries, err := os.ReadDir(projectsDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return total, nil
|
|
}
|
|
return total, err
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
slug := entry.Name()
|
|
n, err := copyMissingTree(filepath.Join(projectsDir, slug, "memory"), filepath.Join(destRoot, "projects", slug, "memory"))
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
total += n
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func copyMissingTree(src, dst string) (int, error) {
|
|
info, err := os.Stat(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
if !info.IsDir() {
|
|
return copyFileIfMissing(src, dst)
|
|
}
|
|
count := 0
|
|
err = filepath.WalkDir(src, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
rel, err := filepath.Rel(src, path)
|
|
if err != nil || rel == "." {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
if entry.IsDir() {
|
|
return os.MkdirAll(target, 0o755)
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
n, err := copyFileIfMissing(path, target)
|
|
count += n
|
|
return err
|
|
})
|
|
return count, err
|
|
}
|
|
|
|
func copyFileIfMissing(src, dst string) (int, error) {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
defer in.Close()
|
|
info, err := in.Stat()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return 0, nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return 0, err
|
|
}
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm())
|
|
if err != nil {
|
|
if os.IsExist(err) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
defer out.Close()
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
_ = os.Remove(dst)
|
|
return 0, err
|
|
}
|
|
return 1, nil
|
|
}
|
|
|
|
func migrateLegacySessionSources(sink event.Sink, verbose bool) sessionMigrationResult {
|
|
dest := config.SessionDir()
|
|
if strings.TrimSpace(dest) == "" {
|
|
return sessionMigrationResult{}
|
|
}
|
|
type legacySource struct {
|
|
dir string
|
|
dest string
|
|
label string
|
|
migrate func(srcDir, globalDest string, projectDir func(string) string) (int, error)
|
|
}
|
|
var sources []legacySource
|
|
addFlatSource := func(dir, label string, migrate func(string, string, func(string) string) (int, error)) {
|
|
sources = append(sources, legacySource{
|
|
dir: dir,
|
|
dest: dest,
|
|
label: label,
|
|
migrate: migrate,
|
|
})
|
|
}
|
|
addProjectSources := func(root string) {
|
|
root = strings.TrimSpace(root)
|
|
if root == "" || config.MemoryUserDir() == "" {
|
|
return
|
|
}
|
|
if samePath(root, config.MemoryUserDir()) {
|
|
return
|
|
}
|
|
projectsDir := filepath.Join(root, "projects")
|
|
entries, err := os.ReadDir(projectsDir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
slug := entry.Name()
|
|
srcDir := filepath.Join(projectsDir, slug, "sessions")
|
|
dstDir := filepath.Join(config.MemoryUserDir(), "projects", slug, "sessions")
|
|
sources = append(sources, legacySource{
|
|
dir: srcDir,
|
|
dest: dstDir,
|
|
label: srcDir,
|
|
migrate: agent.MigrateLegacySessionsFromConfigDir,
|
|
})
|
|
}
|
|
}
|
|
if home, herr := os.UserHomeDir(); herr == nil {
|
|
reasonixHome := filepath.Join(home, ".reasonix")
|
|
addFlatSource(filepath.Join(reasonixHome, "sessions"), "~/.reasonix/sessions", agent.MigrateLegacySessions)
|
|
addProjectSources(reasonixHome)
|
|
}
|
|
for _, legacyConfig := range config.LegacyUserConfigPaths() {
|
|
legacyDir := filepath.Join(filepath.Dir(legacyConfig), "sessions")
|
|
addFlatSource(legacyDir, legacyDir, agent.MigrateLegacySessionsFromConfigDir)
|
|
addProjectSources(filepath.Dir(legacyConfig))
|
|
}
|
|
// Back-fill v0.x sessions from the current user config session directory as
|
|
// well. This covers users whose platform config root was redirected before the
|
|
// Go rewrite; their event logs can already live where v2 stores sessions.
|
|
addFlatSource(dest, dest, agent.MigrateLegacySessionsFromConfigDir)
|
|
|
|
seen := map[string]bool{}
|
|
result := sessionMigrationResult{}
|
|
for _, src := range sources {
|
|
if strings.TrimSpace(src.dir) == "" {
|
|
continue
|
|
}
|
|
sourceDest := strings.TrimSpace(src.dest)
|
|
if sourceDest == "" {
|
|
sourceDest = dest
|
|
}
|
|
key := filepath.Clean(src.dir) + "=>" + filepath.Clean(sourceDest)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
n, err := src.migrate(src.dir, sourceDest, config.ProjectSessionDir)
|
|
if err != nil {
|
|
result.errs = append(result.errs, fmt.Errorf("%s: %w", src.label, err))
|
|
if verbose {
|
|
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "migration rescue: skipped " + src.label + ": " + err.Error()})
|
|
}
|
|
continue
|
|
}
|
|
if n > 0 {
|
|
result.imports = append(result.imports, SessionImport{Source: src.label, Destination: sourceDest, Count: n})
|
|
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)})
|
|
}
|
|
}
|
|
if verbose && len(result.imports) == 0 && len(result.errs) == 0 {
|
|
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "migration rescue: no legacy sessions needed migration"})
|
|
}
|
|
return result
|
|
}
|
|
|
|
type explicitSessionSource struct {
|
|
dir string
|
|
label string
|
|
}
|
|
|
|
func parseLegacyRescueArgs(args string) (source string, explicit bool, err error) {
|
|
args = strings.TrimSpace(args)
|
|
if args == "" {
|
|
return "", false, nil
|
|
}
|
|
const flag = "--from"
|
|
switch {
|
|
case args == flag:
|
|
return "", false, fmt.Errorf("--from requires a legacy directory path")
|
|
case strings.HasPrefix(args, flag+"="):
|
|
source = strings.TrimSpace(strings.TrimPrefix(args, flag+"="))
|
|
case len(args) > len(flag) && strings.HasPrefix(args, flag) && (args[len(flag)] == ' ' || args[len(flag)] == '\t'):
|
|
source = strings.TrimSpace(args[len(flag):])
|
|
default:
|
|
first := args
|
|
if i := strings.IndexAny(first, " \t"); i >= 0 {
|
|
first = first[:i]
|
|
}
|
|
return "", false, fmt.Errorf("unknown /migrate option %q; use /migrate --from <legacy-dir>", first)
|
|
}
|
|
source = trimMatchingQuotes(source)
|
|
if source == "" {
|
|
return "", false, fmt.Errorf("--from requires a legacy directory path")
|
|
}
|
|
return source, true, nil
|
|
}
|
|
|
|
func trimMatchingQuotes(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) < 2 {
|
|
return s
|
|
}
|
|
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func explicitLegacySessionSources(root string) ([]explicitSessionSource, error) {
|
|
root = strings.TrimSpace(root)
|
|
if root == "" {
|
|
return nil, fmt.Errorf("--from requires a legacy directory path")
|
|
}
|
|
info, err := os.Stat(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("legacy directory %s is not readable: %w", root, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("legacy path %s is not a directory", root)
|
|
}
|
|
candidates := []string{
|
|
filepath.Join(root, "sessions"),
|
|
filepath.Join(root, ".reasonix", "sessions"),
|
|
filepath.Join(root, "reasonix", "sessions"),
|
|
}
|
|
var out []explicitSessionSource
|
|
seen := map[string]bool{}
|
|
for _, dir := range candidates {
|
|
key := cleanAbs(dir)
|
|
if key == "" || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
if dirLooksLikeLegacySessionDir(dir) {
|
|
out = append(out, explicitSessionSource{dir: dir, label: dir})
|
|
}
|
|
}
|
|
if len(out) == 0 && dirLooksLikeLegacySessionDir(root) {
|
|
out = append(out, explicitSessionSource{dir: root, label: root})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func dirLooksLikeLegacySessionDir(dir string) bool {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && legacySessionArtifactName(entry.Name()) {
|
|
return true
|
|
}
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || entry.Name() == "subagents" {
|
|
continue
|
|
}
|
|
subEntries, err := os.ReadDir(filepath.Join(dir, entry.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, sub := range subEntries {
|
|
if !sub.IsDir() && legacySessionArtifactName(sub.Name()) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func legacySessionArtifactName(name string) bool {
|
|
return strings.HasSuffix(name, ".events.jsonl") ||
|
|
strings.HasSuffix(name, ".jsonl") ||
|
|
strings.HasSuffix(name, ".jsonl.bak")
|
|
}
|
|
|
|
func samePath(a, b string) bool {
|
|
aa := cleanAbs(a)
|
|
bb := cleanAbs(b)
|
|
return aa != "" && bb != "" && aa == bb
|
|
}
|
|
|
|
func cleanAbs(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
path = abs
|
|
}
|
|
return filepath.Clean(path)
|
|
}
|