From 388c0952fb6748561554a8deed91236794b06ccd Mon Sep 17 00:00:00 2001 From: ZanzyTHEbar Date: Thu, 5 Mar 2026 14:20:22 +0000 Subject: [PATCH] refactor: remove migrate package and memory migrations - Remove pkg/migrate (config, migrate, workspace, xdg) - Remove pkg/memory migrate_* (dailynotes, documents, longterm, sessions, state) - Remove migrate CLI command and memory migrate-sessions subcommand --- .../internal/cli/commands/memory.go | 16 - .../internal/cli/commands/migrate.go | 79 -- cmd/dragonscale/internal/cli/commands/root.go | 1 - .../internal/cli/commands/root_test.go | 1 - pkg/memory/migrate_dailynotes.go | 93 -- pkg/memory/migrate_documents.go | 62 -- pkg/memory/migrate_longterm.go | 50 - pkg/memory/migrate_sessions.go | 166 ---- pkg/memory/migrate_sessions_test.go | 333 ------- pkg/memory/migrate_state.go | 71 -- pkg/migrate/config.go | 393 -------- pkg/migrate/migrate.go | 394 -------- pkg/migrate/migrate_test.go | 871 ------------------ pkg/migrate/workspace.go | 106 --- pkg/migrate/xdg.go | 166 ---- 15 files changed, 2802 deletions(-) delete mode 100644 cmd/dragonscale/internal/cli/commands/migrate.go delete mode 100644 pkg/memory/migrate_dailynotes.go delete mode 100644 pkg/memory/migrate_documents.go delete mode 100644 pkg/memory/migrate_longterm.go delete mode 100644 pkg/memory/migrate_sessions.go delete mode 100644 pkg/memory/migrate_sessions_test.go delete mode 100644 pkg/memory/migrate_state.go delete mode 100644 pkg/migrate/config.go delete mode 100644 pkg/migrate/migrate.go delete mode 100644 pkg/migrate/migrate_test.go delete mode 100644 pkg/migrate/workspace.go delete mode 100644 pkg/migrate/xdg.go diff --git a/cmd/dragonscale/internal/cli/commands/memory.go b/cmd/dragonscale/internal/cli/commands/memory.go index 3058bf5b5..c57200664 100644 --- a/cmd/dragonscale/internal/cli/commands/memory.go +++ b/cmd/dragonscale/internal/cli/commands/memory.go @@ -22,27 +22,11 @@ func buildMemoryCommand(ctx *cli.AppContext) *cobra.Command { }, } - cmd.AddCommand(buildMemoryMigrateSessionsCommand(ctx)) cmd.AddCommand(buildMemoryDBStatusCommand(ctx)) return cmd } -func buildMemoryMigrateSessionsCommand(ctx *cli.AppContext) *cobra.Command { - return &cobra.Command{ - Use: "migrate-sessions", - Short: "Migrate sessions into memory DB", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - if ctx == nil || ctx.Service == nil { - return errors.New("service is not initialized") - } - - return ctx.Service.MemoryMigrateSessions(cmd.Context(), cmd.OutOrStdout()) - }, - } -} - func buildMemoryDBStatusCommand(ctx *cli.AppContext) *cobra.Command { return &cobra.Command{ Use: "db-status", diff --git a/cmd/dragonscale/internal/cli/commands/migrate.go b/cmd/dragonscale/internal/cli/commands/migrate.go deleted file mode 100644 index 6e0317763..000000000 --- a/cmd/dragonscale/internal/cli/commands/migrate.go +++ /dev/null @@ -1,79 +0,0 @@ -package commands - -import ( - "errors" - - "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" - "github.com/ZanzyTHEbar/dragonscale/pkg/dragonscale/sdk" - "github.com/spf13/cobra" -) - -func registerMigrateCommand() { - cli.Register(func(ctx *cli.AppContext) *cobra.Command { - return buildMigrateCommand(ctx) - }) -} - -func buildMigrateCommand(ctx *cli.AppContext) *cobra.Command { - cmd := &cobra.Command{ - Use: "migrate", - Short: "Migrate dragonscale configuration and workspace", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - if ctx == nil || ctx.Service == nil { - return errors.New("service is not initialized") - } - - dryRun, err := cmd.Flags().GetBool("dry-run") - if err != nil { - return err - } - configOnly, err := cmd.Flags().GetBool("config-only") - if err != nil { - return err - } - workspaceOnly, err := cmd.Flags().GetBool("workspace-only") - if err != nil { - return err - } - force, err := cmd.Flags().GetBool("force") - if err != nil { - return err - } - refresh, err := cmd.Flags().GetBool("refresh") - if err != nil { - return err - } - openClawHome, err := cmd.Flags().GetString("openclaw-home") - if err != nil { - return err - } - dragonscaleHome, err := cmd.Flags().GetString("dragonscale-home") - if err != nil { - return err - } - - opts := sdk.MigrateOptions{ - DryRun: dryRun, - ConfigOnly: configOnly, - WorkspaceOnly: workspaceOnly, - Force: force, - Refresh: refresh, - OpenClawHome: openClawHome, - DragonscaleHome: dragonscaleHome, - } - - return ctx.Service.Migrate(cmd.Context(), opts, cmd.OutOrStdout()) - }, - } - - cmd.Flags().Bool("dry-run", false, "Simulate migration without writing") - cmd.Flags().Bool("config-only", false, "Migrate only config files") - cmd.Flags().Bool("workspace-only", false, "Migrate only workspace files") - cmd.Flags().Bool("force", false, "Overwrite existing files") - cmd.Flags().Bool("refresh", false, "Re-run migrations") - cmd.Flags().String("openclaw-home", "", "Path to OpenClaw home directory") - cmd.Flags().String("dragonscale-home", "", "Path to dragonscale home directory") - - return cmd -} diff --git a/cmd/dragonscale/internal/cli/commands/root.go b/cmd/dragonscale/internal/cli/commands/root.go index b159d37e7..9878e7ecb 100644 --- a/cmd/dragonscale/internal/cli/commands/root.go +++ b/cmd/dragonscale/internal/cli/commands/root.go @@ -10,7 +10,6 @@ func RegisterAll() { // Command factories are intentionally registered by their feature modules. registerAgentCommand() registerGatewayCommand() - registerMigrateCommand() registerAuthCommand() registerCronCommand() registerSkillsCommand() diff --git a/cmd/dragonscale/internal/cli/commands/root_test.go b/cmd/dragonscale/internal/cli/commands/root_test.go index 1a38c5028..b923b6be5 100644 --- a/cmd/dragonscale/internal/cli/commands/root_test.go +++ b/cmd/dragonscale/internal/cli/commands/root_test.go @@ -74,7 +74,6 @@ func TestBuildRoot_RegistersExpectedCommands(t *testing.T) { expected := []string{ "agent", "gateway", - "migrate", "auth", "cron", "skills", diff --git a/pkg/memory/migrate_dailynotes.go b/pkg/memory/migrate_dailynotes.go deleted file mode 100644 index 37d5412de..000000000 --- a/pkg/memory/migrate_dailynotes.go +++ /dev/null @@ -1,93 +0,0 @@ -package memory - -import ( - "context" - "log" - "os" - "path/filepath" - "strings" - "time" - - "github.com/ZanzyTHEbar/dragonscale/pkg/ids" -) - -// MigrateDailyNotes performs a one-time migration of daily note files -// (memory/YYYYMM/YYYYMMDD.md) into recall items. Uses a marker file for idempotency. -func MigrateDailyNotes(ctx context.Context, workspace string, delegate MemoryDelegate, agentID string) error { - if delegate == nil { - return nil - } - - memDir := filepath.Join(workspace, "memory") - markerFile := filepath.Join(memDir, ".dailynotes_migrated") - if _, err := os.Stat(markerFile); err == nil { - return nil - } - - migrated := 0 - err := filepath.WalkDir(memDir, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - if filepath.Ext(path) != ".md" { - return nil - } - - base := strings.TrimSuffix(d.Name(), ".md") - if len(base) != 8 { - return nil - } - - noteDate, parseErr := time.Parse("20060102", base) - if parseErr != nil { - return nil - } - - data, readErr := os.ReadFile(path) - if readErr != nil { - return nil - } - - content := strings.TrimSpace(string(data)) - if content == "" { - return nil - } - - dateTag := noteDate.Format("2006-01-02") - item := &RecallItem{ - ID: ids.New(), - AgentID: agentID, - SessionKey: "", - Role: "system", - Sector: SectorEpisodic, - Importance: 0.4, - Salience: 0.4, - DecayRate: 0.01, - Content: content, - Tags: "daily-note," + dateTag, - CreatedAt: noteDate, - UpdatedAt: noteDate, - } - - if err := delegate.InsertRecallItem(ctx, item); err != nil { - log.Printf("[WARN] migrate_dailynotes: failed to insert note %s: %v", base, err) - return nil - } - migrated++ - return nil - }) - - if err != nil { - return err - } - - if migrated > 0 { - marker := []byte(time.Now().Format(time.RFC3339)) - if err := os.WriteFile(markerFile, marker, 0644); err != nil { - log.Printf("[WARN] migrate_dailynotes: failed to write marker: %v", err) - } - log.Printf("[INFO] migrate_dailynotes: migrated %d daily notes to recall items", migrated) - } - - return nil -} diff --git a/pkg/memory/migrate_documents.go b/pkg/memory/migrate_documents.go deleted file mode 100644 index a54ecd2e1..000000000 --- a/pkg/memory/migrate_documents.go +++ /dev/null @@ -1,62 +0,0 @@ -package memory - -import ( - "context" - "log" - "os" - "path/filepath" - "time" - - "github.com/ZanzyTHEbar/dragonscale/pkg/ids" -) - -var bootstrapDocNames = []string{ - "AGENTS.md", - "SOUL.md", - "USER.md", - "IDENTITY.md", -} - -// MigrateDocuments performs a one-time migration of workspace bootstrap files -// into the agent_documents table. Uses a marker file for idempotency. -func MigrateDocuments(ctx context.Context, workspace string, delegate MemoryDelegate, agentID string) error { - if delegate == nil { - return nil - } - - markerFile := filepath.Join(workspace, ".documents_migrated") - if _, err := os.Stat(markerFile); err == nil { - return nil - } - - migrated := 0 - for _, name := range bootstrapDocNames { - filePath := filepath.Join(workspace, name) - data, err := os.ReadFile(filePath) - if err != nil { - continue - } - - doc := &AgentDocument{ - ID: ids.New(), - AgentID: agentID, - Name: name, - Category: "bootstrap", - Content: string(data), - } - if err := delegate.UpsertDocument(ctx, doc); err != nil { - return err - } - migrated++ - } - - if migrated > 0 { - marker := []byte(time.Now().Format(time.RFC3339)) - if err := os.WriteFile(markerFile, marker, 0644); err != nil { - log.Printf("[WARN] migrate_documents: failed to write marker: %v", err) - } - log.Printf("[INFO] migrate_documents: migrated %d bootstrap files to agent_documents", migrated) - } - - return nil -} diff --git a/pkg/memory/migrate_longterm.go b/pkg/memory/migrate_longterm.go deleted file mode 100644 index a140c1aa1..000000000 --- a/pkg/memory/migrate_longterm.go +++ /dev/null @@ -1,50 +0,0 @@ -package memory - -import ( - "context" - "log" - "os" - "path/filepath" - "strings" - "time" -) - -// MigrateLongTermMemory performs a one-time migration of MEMORY.md content -// into the working context tier. Uses a marker file for idempotency. -func MigrateLongTermMemory(ctx context.Context, workspace string, delegate MemoryDelegate, agentID string) error { - if delegate == nil { - return nil - } - - memDir := filepath.Join(workspace, "memory") - markerFile := filepath.Join(memDir, ".longterm_migrated") - if _, err := os.Stat(markerFile); err == nil { - return nil - } - - memFile := filepath.Join(memDir, "MEMORY.md") - data, err := os.ReadFile(memFile) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - content := strings.TrimSpace(string(data)) - if content == "" { - return nil - } - - if err := delegate.UpsertWorkingContext(ctx, agentID, "default", content); err != nil { - return err - } - - marker := []byte(time.Now().Format(time.RFC3339)) - if err := os.WriteFile(markerFile, marker, 0644); err != nil { - log.Printf("[WARN] migrate_longterm: failed to write marker: %v", err) - } - - log.Printf("[INFO] migrate_longterm: migrated MEMORY.md to working context") - return nil -} diff --git a/pkg/memory/migrate_sessions.go b/pkg/memory/migrate_sessions.go deleted file mode 100644 index b844bdddf..000000000 --- a/pkg/memory/migrate_sessions.go +++ /dev/null @@ -1,166 +0,0 @@ -// Package memory provides the 3-tier MemGPT memory system. -// This file handles one-time migration of file-based session data -// into the SQLite-backed recall memory tier. -package memory - -import ( - "context" - "fmt" - jsonv2 "github.com/go-json-experiment/json" - "os" - "path/filepath" - "strings" - "time" - - "github.com/ZanzyTHEbar/dragonscale/pkg/ids" - "github.com/ZanzyTHEbar/dragonscale/pkg/logger" -) - -const migrationMarkerFile = ".sessions_migrated" - -// SessionFile mirrors the on-disk session format from pkg/session. -// Defined here to avoid circular imports. -type SessionFile struct { - Key string `json:"key"` - Messages []SessionMsg `json:"messages"` - Summary string `json:"summary,omitzero"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` -} - -type SessionMsg struct { - Role string `json:"role"` - Content string `json:"content"` -} - -// MigrateSessionsResult holds counts from a session migration run. -type MigrateSessionsResult struct { - SessionsFound int - SessionsMigrated int - ItemsCreated int - Errors int -} - -// MigrateFileSessions reads all JSON session files from sessionsDir and inserts -// their messages as RecallItems into the delegate. It writes a marker file to -// prevent re-running. Safe to call repeatedly — no-ops after first migration. -func MigrateFileSessions(ctx context.Context, del MemoryDelegate, agentID, sessionsDir string) (*MigrateSessionsResult, error) { - if sessionsDir == "" { - return nil, nil - } - - markerPath := filepath.Join(sessionsDir, migrationMarkerFile) - if _, err := os.Stat(markerPath); err == nil { - return nil, nil // already migrated - } - - files, err := os.ReadDir(sessionsDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("read sessions dir: %w", err) - } - - result := &MigrateSessionsResult{} - - for _, f := range files { - if f.IsDir() || filepath.Ext(f.Name()) != ".json" { - continue - } - result.SessionsFound++ - - sessPath := filepath.Join(sessionsDir, f.Name()) - data, err := os.ReadFile(sessPath) - if err != nil { - logger.WarnCF("migrate", "Failed to read session file", - map[string]interface{}{"path": sessPath, "error": err.Error()}) - result.Errors++ - continue - } - - var sess SessionFile - if err := jsonv2.Unmarshal(data, &sess); err != nil { - logger.WarnCF("migrate", "Failed to parse session file", - map[string]interface{}{"path": sessPath, "error": err.Error()}) - result.Errors++ - continue - } - - sessionKey := sess.Key - if sessionKey == "" { - sessionKey = strings.TrimSuffix(f.Name(), ".json") - } - - migrated, err := migrateOneSession(ctx, del, agentID, sessionKey, &sess) - if err != nil { - logger.WarnCF("migrate", "Failed to migrate session", - map[string]interface{}{"session": sessionKey, "error": err.Error()}) - result.Errors++ - continue - } - result.ItemsCreated += migrated - result.SessionsMigrated++ - } - - // Write marker to prevent re-running - if result.SessionsMigrated > 0 || result.SessionsFound == 0 { - markerContent := fmt.Sprintf("migrated_at=%s sessions=%d items=%d errors=%d\n", - time.Now().UTC().Format(time.RFC3339), - result.SessionsMigrated, - result.ItemsCreated, - result.Errors, - ) - os.WriteFile(markerPath, []byte(markerContent), 0644) - } - - if result.SessionsMigrated > 0 { - logger.InfoCF("migrate", "Session migration complete", - map[string]interface{}{ - "sessions_found": result.SessionsFound, - "sessions_migrated": result.SessionsMigrated, - "items_created": result.ItemsCreated, - "errors": result.Errors, - }) - } - - return result, nil -} - -func migrateOneSession(ctx context.Context, del MemoryDelegate, agentID, sessionKey string, sess *SessionFile) (int, error) { - count := 0 - - for _, msg := range sess.Messages { - if strings.TrimSpace(msg.Content) == "" { - continue - } - - item := &RecallItem{ - ID: ids.New(), - AgentID: agentID, - SessionKey: sessionKey, - Role: msg.Role, - Sector: SectorEpisodic, - Importance: 0.3, - Salience: 0.3, - DecayRate: 0.01, - Content: msg.Content, - Tags: "migrated", - } - - if err := del.InsertRecallItem(ctx, item); err != nil { - return count, fmt.Errorf("insert recall item: %w", err) - } - count++ - } - - // If the session had a summary, store it as working context - if sess.Summary != "" { - if err := del.UpsertWorkingContext(ctx, agentID, sessionKey, sess.Summary); err != nil { - logger.WarnCF("migrate", "Failed to store session summary as working context", - map[string]interface{}{"session": sessionKey, "error": err.Error()}) - } - } - - return count, nil -} diff --git a/pkg/memory/migrate_sessions_test.go b/pkg/memory/migrate_sessions_test.go deleted file mode 100644 index a4800bef6..000000000 --- a/pkg/memory/migrate_sessions_test.go +++ /dev/null @@ -1,333 +0,0 @@ -package memory - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - jsonv2 "github.com/go-json-experiment/json" - "github.com/google/go-cmp/cmp" - - "github.com/ZanzyTHEbar/dragonscale/pkg" - "github.com/ZanzyTHEbar/dragonscale/pkg/ids" -) - -// mockDelegate captures all calls for testing migration without a real DB. -type mockDelegate struct { - recallItems []*RecallItem - workingContexts map[string]string -} - -func newMockDelegate() *mockDelegate { - return &mockDelegate{workingContexts: make(map[string]string)} -} - -func (m *mockDelegate) Init(_ context.Context) error { return nil } -func (m *mockDelegate) Close() error { return nil } -func (m *mockDelegate) GetWorkingContext(_ context.Context, agentID, sk string) (*WorkingContext, error) { - if c, ok := m.workingContexts[agentID+"/"+sk]; ok { - return &WorkingContext{AgentID: agentID, SessionKey: sk, Content: c}, nil - } - return nil, nil -} -func (m *mockDelegate) UpsertWorkingContext(_ context.Context, agentID, sk, content string) error { - m.workingContexts[agentID+"/"+sk] = content - return nil -} -func (m *mockDelegate) InsertRecallItem(_ context.Context, item *RecallItem) error { - m.recallItems = append(m.recallItems, item) - return nil -} -func (m *mockDelegate) GetRecallItem(_ context.Context, _ string, _ ids.UUID) (*RecallItem, error) { - return nil, nil -} -func (m *mockDelegate) GetRecallItemsByIDs(_ context.Context, _ string, _ []ids.UUID) (map[ids.UUID]*RecallItem, error) { - return make(map[ids.UUID]*RecallItem), nil -} -func (m *mockDelegate) UpdateRecallItem(_ context.Context, _ *RecallItem) error { return nil } -func (m *mockDelegate) DeleteRecallItem(_ context.Context, _ string, _ ids.UUID) error { return nil } -func (m *mockDelegate) ListRecallItems(_ context.Context, _, _ string, _, _ int) ([]*RecallItem, error) { - return nil, nil -} -func (m *mockDelegate) SearchRecallByKeyword(_ context.Context, _, _ string, _ int) ([]*RecallItem, error) { - return nil, nil -} -func (m *mockDelegate) SearchRecallByFTS(_ context.Context, _, _ string, _ int) ([]*RecallItem, error) { - return nil, nil -} -func (m *mockDelegate) SearchArchivalByVector(_ context.Context, _ Embedding, _, _ int) ([]SearchResult, error) { - return nil, nil -} -func (m *mockDelegate) InsertArchivalChunk(_ context.Context, _ *ArchivalChunk) error { return nil } -func (m *mockDelegate) InsertArchivalChunkBatch(_ context.Context, _ []*ArchivalChunk) error { - return nil -} -func (m *mockDelegate) GetArchivalChunk(_ context.Context, _ string, _ ids.UUID) (*ArchivalChunk, error) { - return nil, nil -} -func (m *mockDelegate) ListArchivalChunks(_ context.Context, _ string, _ ids.UUID) ([]*ArchivalChunk, error) { - return nil, nil -} -func (m *mockDelegate) ListAllArchivalChunks(_ context.Context, _ string, _, _ int) ([]*ArchivalChunk, error) { - return nil, nil -} -func (m *mockDelegate) DeleteArchivalChunks(_ context.Context, _ ids.UUID) error { return nil } -func (m *mockDelegate) InsertSummary(_ context.Context, _ *MemorySummary) error { return nil } -func (m *mockDelegate) ListSummaries(_ context.Context, _, _ string, _ int) ([]*MemorySummary, error) { - return nil, nil -} -func (m *mockDelegate) CountRecallItems(_ context.Context, _, _ string) (int, error) { - return len(m.recallItems), nil -} -func (m *mockDelegate) CountArchivalChunks(_ context.Context, _ string) (int, error) { return 0, nil } -func (m *mockDelegate) HasVectorSearch() bool { return false } -func (m *mockDelegate) HasFTS() bool { return false } -func (m *mockDelegate) GetKV(_ context.Context, _, _ string) (string, error) { return "", nil } -func (m *mockDelegate) UpsertKV(_ context.Context, _, _, _ string) error { return nil } -func (m *mockDelegate) DeleteKV(_ context.Context, _, _ string) error { return nil } -func (m *mockDelegate) ListKVByPrefix(_ context.Context, _, _ string, _ int) (map[string]string, error) { - return nil, nil -} -func (m *mockDelegate) GetDocument(_ context.Context, _, _ string) (*AgentDocument, error) { - return nil, nil -} -func (m *mockDelegate) UpsertDocument(_ context.Context, _ *AgentDocument) error { return nil } -func (m *mockDelegate) DeleteDocument(_ context.Context, _, _ string) error { return nil } -func (m *mockDelegate) ListDocumentsByCategory(_ context.Context, _, _ string) ([]*AgentDocument, error) { - return nil, nil -} -func (m *mockDelegate) ListAllDocuments(_ context.Context, _ string) ([]*AgentDocument, error) { - return nil, nil -} -func (m *mockDelegate) InsertAuditEntry(_ context.Context, _ *AuditEntry) error { return nil } -func (m *mockDelegate) InsertAuditEntryBatch(_ context.Context, _ []*AuditEntry) error { - return nil -} -func (m *mockDelegate) ListAuditEntries(_ context.Context, _ string, _ int) ([]*AuditEntry, error) { - return nil, nil -} -func (m *mockDelegate) ListAuditEntriesByAction(_ context.Context, _, _ string, _ int) ([]*AuditEntry, error) { - return nil, nil -} -func (m *mockDelegate) CountAuditEntries(_ context.Context, _ string) (int, error) { return 0, nil } -func (m *mockDelegate) InsertImmutableMessage(_ context.Context, _ *ImmutableMessage) error { - return nil -} -func (m *mockDelegate) ListImmutableMessages(_ context.Context, _ string, _, _ int) ([]*ImmutableMessage, error) { - return nil, nil -} -func (m *mockDelegate) GetImmutableMessage(_ context.Context, _ ids.UUID) (*ImmutableMessage, error) { - return nil, nil -} -func (m *mockDelegate) InsertMemoryEdge(_ context.Context, _ *MemoryEdge) error { return nil } -func (m *mockDelegate) ListMemoryEdges(_ context.Context, _ ids.UUID) ([]*MemoryEdge, error) { - return nil, nil -} -func (m *mockDelegate) CountMemoryEdgesForItem(_ context.Context, _ ids.UUID) (int, error) { - return 0, nil -} -func (m *mockDelegate) ListRecallItemsForConsolidation(_ context.Context, _ string, _ time.Time, _ int) ([]*RecallItem, error) { - return nil, nil -} -func (m *mockDelegate) SoftDeleteRecallItem(_ context.Context, _ string, _ ids.UUID) error { - return nil -} - -func writeSessionFile(t *testing.T, dir, name string, sess SessionFile) { - t.Helper() - data, err := jsonv2.Marshal(sess) - if err != nil { - t.Fatalf("marshal session: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, name), data, 0644); err != nil { - t.Fatalf("write session file: %v", err) - } -} - -func TestMigrateFileSessions(t *testing.T) { - t.Parallel() - - type tc struct { - name string - setup func(t *testing.T, dir string, del *mockDelegate) - sessionsDir string - want *MigrateSessionsResult - extra func(t *testing.T, dir string, result *MigrateSessionsResult, del *mockDelegate) - } - - tests := []tc{ - { - name: "basic", - setup: func(t *testing.T, dir string, _ *mockDelegate) { - writeSessionFile(t, dir, "sess1.json", SessionFile{ - Key: "session-1", - Messages: []SessionMsg{ - {Role: "user", Content: "Hello"}, - {Role: "assistant", Content: "Hi there!"}, - }, - Created: time.Now().Add(-time.Hour), - Updated: time.Now(), - }) - writeSessionFile(t, dir, "sess2.json", SessionFile{ - Key: "session-2", - Summary: "Talked about Go programming", - Messages: []SessionMsg{ - {Role: "user", Content: "Tell me about Go"}, - }, - }) - }, - want: &MigrateSessionsResult{SessionsFound: 2, SessionsMigrated: 2, ItemsCreated: 3, Errors: 0}, - extra: func(t *testing.T, _ string, _ *MigrateSessionsResult, del *mockDelegate) { - if len(del.recallItems) != 3 { - t.Fatalf("expected 3 recall items, got %d", len(del.recallItems)) - } - first := del.recallItems[0] - if first.Role != "user" || first.Content != "Hello" || first.SessionKey != "session-1" || first.Tags != "migrated" { - t.Errorf("unexpected first recall item: %+v", first) - } - - wc, err := del.GetWorkingContext(t.Context(), pkg.NAME, "session-2") - if err != nil { - t.Fatalf("GetWorkingContext: %v", err) - } - if wc == nil || wc.Content != "Talked about Go programming" { - t.Errorf("expected summary as working context, got %v", wc) - } - }, - }, - { - name: "empty dir", - setup: func(_ *testing.T, _ string, _ *mockDelegate) {}, - want: &MigrateSessionsResult{ - SessionsFound: 0, - SessionsMigrated: 0, - ItemsCreated: 0, - Errors: 0, - }, - }, - { - name: "skip empty messages", - setup: func(t *testing.T, dir string, _ *mockDelegate) { - writeSessionFile(t, dir, "sess.json", SessionFile{ - Key: "s1", - Messages: []SessionMsg{ - {Role: "user", Content: "real content"}, - {Role: "assistant", Content: ""}, - {Role: "user", Content: " "}, - }, - }) - }, - want: &MigrateSessionsResult{SessionsFound: 1, SessionsMigrated: 1, ItemsCreated: 1, Errors: 0}, - }, - { - name: "fallback key from filename", - setup: func(t *testing.T, dir string, _ *mockDelegate) { - writeSessionFile(t, dir, "custom-key.json", SessionFile{ - Key: "", - Messages: []SessionMsg{ - {Role: "user", Content: "test"}, - }, - }) - }, - want: &MigrateSessionsResult{SessionsFound: 1, SessionsMigrated: 1, ItemsCreated: 1, Errors: 0}, - extra: func(t *testing.T, _ string, result *MigrateSessionsResult, del *mockDelegate) { - if got := del.recallItems[0].SessionKey; got != "custom-key" { - t.Errorf("expected 'custom-key' from filename, got %q", got) - } - if result.ItemsCreated != 1 { - t.Fatalf("expected 1 item, got %d", result.ItemsCreated) - } - }, - }, - { - name: "malformed json", - setup: func(t *testing.T, dir string, _ *mockDelegate) { - if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("{broken"), 0644); err != nil { - t.Fatalf("write malformed json: %v", err) - } - writeSessionFile(t, dir, "good.json", SessionFile{ - Key: "g1", - Messages: []SessionMsg{ - {Role: "user", Content: "ok"}, - }, - }) - }, - want: &MigrateSessionsResult{SessionsFound: 2, SessionsMigrated: 1, ItemsCreated: 1, Errors: 1}, - }, - { - name: "nonexistent dir", - sessionsDir: "/nonexistent/path", - want: nil, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - del := newMockDelegate() - sessDir := t.TempDir() - if tt.sessionsDir != "" { - sessDir = tt.sessionsDir - } - if tt.setup != nil { - tt.setup(t, sessDir, del) - } - - got, err := MigrateFileSessions(t.Context(), del, pkg.NAME, sessDir) - if err != nil { - t.Fatalf("MigrateFileSessions: %v", err) - } - if tt.want == nil { - if got != nil { - t.Errorf("expected nil result, got %#v", got) - } - return - } - if got == nil { - t.Fatal("expected migration result, got nil") - } - if diff := cmp.Diff(*tt.want, *got); diff != "" { - t.Errorf("migration result mismatch (-want +got):\n%s", diff) - } - if tt.extra != nil { - tt.extra(t, sessDir, got, del) - } - }) - } -} - -func TestMigrateFileSessions_Idempotent(t *testing.T) { - t.Parallel() - sessDir := t.TempDir() - del := newMockDelegate() - - writeSessionFile(t, sessDir, "sess.json", SessionFile{ - Key: "s1", - Messages: []SessionMsg{ - {Role: "user", Content: "test"}, - }, - }) - - result1, err := MigrateFileSessions(t.Context(), del, pkg.NAME, sessDir) - if err != nil { - t.Fatalf("first migration: %v", err) - } - if result1.SessionsMigrated != 1 { - t.Fatalf("expected 1, got %d", result1.SessionsMigrated) - } - - result2, err := MigrateFileSessions(t.Context(), del, pkg.NAME, sessDir) - if err != nil { - t.Fatalf("second migration: %v", err) - } - if result2 != nil { - t.Error("expected nil result for already-migrated directory") - } - - if len(del.recallItems) != 1 { - t.Errorf("expected 1 recall item (no duplicates), got %d", len(del.recallItems)) - } -} diff --git a/pkg/memory/migrate_state.go b/pkg/memory/migrate_state.go deleted file mode 100644 index 906eb0a85..000000000 --- a/pkg/memory/migrate_state.go +++ /dev/null @@ -1,71 +0,0 @@ -package memory - -import ( - "context" - jsonv2 "github.com/go-json-experiment/json" - "log" - "os" - "path/filepath" - "time" -) - -type legacyState struct { - LastChannel string `json:"last_channel,omitzero"` - LastChatID string `json:"last_chat_id,omitzero"` - Timestamp time.Time `json:"timestamp"` -} - -// MigrateState performs a one-time migration of workspace/state/state.json -// into agent_kv rows. Uses a marker file to ensure idempotency. -func MigrateState(ctx context.Context, workspace string, delegate MemoryDelegate, agentID string) error { - if delegate == nil { - return nil - } - - stateDir := filepath.Join(workspace, "state") - markerFile := filepath.Join(stateDir, ".state_kv_migrated") - - if _, err := os.Stat(markerFile); err == nil { - return nil - } - - stateFile := filepath.Join(stateDir, "state.json") - data, err := os.ReadFile(stateFile) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - var s legacyState - if err := jsonv2.Unmarshal(data, &s); err != nil { - log.Printf("[WARN] migrate_state: failed to parse %s: %v", stateFile, err) - return nil - } - - if s.LastChannel != "" { - if err := delegate.UpsertKV(ctx, agentID, "state:last_channel", s.LastChannel); err != nil { - return err - } - } - if s.LastChatID != "" { - if err := delegate.UpsertKV(ctx, agentID, "state:last_chat_id", s.LastChatID); err != nil { - return err - } - } - if !s.Timestamp.IsZero() { - if err := delegate.UpsertKV(ctx, agentID, "state:timestamp", s.Timestamp.Format(time.RFC3339Nano)); err != nil { - return err - } - } - - os.MkdirAll(stateDir, 0755) - marker := []byte(time.Now().Format(time.RFC3339)) - if err := os.WriteFile(markerFile, marker, 0644); err != nil { - log.Printf("[WARN] migrate_state: failed to write marker: %v", err) - } - - log.Printf("[INFO] migrate_state: migrated state.json to agent_kv") - return nil -} diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go deleted file mode 100644 index d446b006f..000000000 --- a/pkg/migrate/config.go +++ /dev/null @@ -1,393 +0,0 @@ -package migrate - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "unicode" - - jsonv2 "github.com/go-json-experiment/json" - - "github.com/ZanzyTHEbar/dragonscale/pkg/config" -) - -var supportedProviders = map[string]bool{ - "anthropic": true, - "openai": true, - "openrouter": true, - "groq": true, - "zhipu": true, - "vllm": true, - "gemini": true, -} - -var supportedChannels = map[string]bool{ - "telegram": true, - "discord": true, - "whatsapp": true, - "feishu": true, - "qq": true, - "dingtalk": true, - "maixcam": true, -} - -func findOpenClawConfig(openclawHome string) (string, error) { - candidates := []string{ - filepath.Join(openclawHome, "openclaw.json"), - filepath.Join(openclawHome, "config.json"), - } - for _, p := range candidates { - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome) -} - -func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) { - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("reading OpenClaw config: %w", err) - } - - var raw map[string]interface{} - if err := jsonv2.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parsing OpenClaw config: %w", err) - } - - converted := convertKeysToSnake(raw) - result, ok := converted.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("unexpected config format") - } - return result, nil -} - -func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error) { - cfg := config.DefaultConfig() - var warnings []string - - if agents, ok := getMap(data, "agents"); ok { - if defaults, ok := getMap(agents, "defaults"); ok { - if v, ok := getString(defaults, "model"); ok { - cfg.Agents.Defaults.Model = v - } - if v, ok := getFloat(defaults, "max_tokens"); ok { - cfg.Agents.Defaults.MaxTokens = int(v) - } - if v, ok := getFloat(defaults, "temperature"); ok { - cfg.Agents.Defaults.Temperature = v - } - if v, ok := getFloat(defaults, "max_tool_iterations"); ok { - cfg.Agents.Defaults.MaxToolIterations = int(v) - } - if v, ok := getString(defaults, "workspace"); ok { - cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v) - } - } - } - - if providers, ok := getMap(data, "providers"); ok { - for name, val := range providers { - pMap, ok := val.(map[string]interface{}) - if !ok { - continue - } - apiKey, _ := getString(pMap, "api_key") - apiBase, _ := getString(pMap, "api_base") - - if !supportedProviders[name] { - if apiKey != "" || apiBase != "" { - warnings = append(warnings, fmt.Sprintf("Provider '%s' not supported in DragonScale, skipping", name)) - } - continue - } - - pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase} - switch name { - case "anthropic": - cfg.Providers.Anthropic = pc - case "openai": - cfg.Providers.OpenAI = config.OpenAIProviderConfig{ - ProviderConfig: pc, - WebSearch: getBoolOrDefault(pMap, "web_search", true), - } - case "openrouter": - cfg.Providers.OpenRouter = pc - case "groq": - cfg.Providers.Groq = pc - case "zhipu": - cfg.Providers.Zhipu = pc - case "vllm": - cfg.Providers.VLLM = pc - case "gemini": - cfg.Providers.Gemini = pc - } - } - } - - if channels, ok := getMap(data, "channels"); ok { - for name, val := range channels { - cMap, ok := val.(map[string]interface{}) - if !ok { - continue - } - if !supportedChannels[name] { - warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in DragonScale, skipping", name)) - continue - } - enabled, _ := getBool(cMap, "enabled") - allowFrom := getStringSlice(cMap, "allow_from") - - switch name { - case "telegram": - cfg.Channels.Telegram.Enabled = enabled - cfg.Channels.Telegram.AllowFrom = allowFrom - if v, ok := getString(cMap, "token"); ok { - cfg.Channels.Telegram.Token = v - } - case "discord": - cfg.Channels.Discord.Enabled = enabled - cfg.Channels.Discord.AllowFrom = allowFrom - if v, ok := getString(cMap, "token"); ok { - cfg.Channels.Discord.Token = v - } - case "whatsapp": - cfg.Channels.WhatsApp.Enabled = enabled - cfg.Channels.WhatsApp.AllowFrom = allowFrom - if v, ok := getString(cMap, "bridge_url"); ok { - cfg.Channels.WhatsApp.BridgeURL = v - } - case "feishu": - cfg.Channels.Feishu.Enabled = enabled - cfg.Channels.Feishu.AllowFrom = allowFrom - if v, ok := getString(cMap, "app_id"); ok { - cfg.Channels.Feishu.AppID = v - } - if v, ok := getString(cMap, "app_secret"); ok { - cfg.Channels.Feishu.AppSecret = v - } - if v, ok := getString(cMap, "encrypt_key"); ok { - cfg.Channels.Feishu.EncryptKey = v - } - if v, ok := getString(cMap, "verification_token"); ok { - cfg.Channels.Feishu.VerificationToken = v - } - case "qq": - cfg.Channels.QQ.Enabled = enabled - cfg.Channels.QQ.AllowFrom = allowFrom - if v, ok := getString(cMap, "app_id"); ok { - cfg.Channels.QQ.AppID = v - } - if v, ok := getString(cMap, "app_secret"); ok { - cfg.Channels.QQ.AppSecret = v - } - case "dingtalk": - cfg.Channels.DingTalk.Enabled = enabled - cfg.Channels.DingTalk.AllowFrom = allowFrom - if v, ok := getString(cMap, "client_id"); ok { - cfg.Channels.DingTalk.ClientID = v - } - if v, ok := getString(cMap, "client_secret"); ok { - cfg.Channels.DingTalk.ClientSecret = v - } - case "maixcam": - cfg.Channels.MaixCam.Enabled = enabled - cfg.Channels.MaixCam.AllowFrom = allowFrom - if v, ok := getString(cMap, "host"); ok { - cfg.Channels.MaixCam.Host = v - } - if v, ok := getFloat(cMap, "port"); ok { - cfg.Channels.MaixCam.Port = int(v) - } - } - } - } - - if gateway, ok := getMap(data, "gateway"); ok { - if v, ok := getString(gateway, "host"); ok { - cfg.Gateway.Host = v - } - if v, ok := getFloat(gateway, "port"); ok { - cfg.Gateway.Port = int(v) - } - } - - if tools, ok := getMap(data, "tools"); ok { - if web, ok := getMap(tools, "web"); ok { - // Migrate old "search" config to "brave" if api_key is present - if search, ok := getMap(web, "search"); ok { - if v, ok := getString(search, "api_key"); ok { - cfg.Tools.Web.Brave.APIKey = v - if v != "" { - cfg.Tools.Web.Brave.Enabled = true - } - } - if v, ok := getFloat(search, "max_results"); ok { - cfg.Tools.Web.Brave.MaxResults = int(v) - cfg.Tools.Web.DuckDuckGo.MaxResults = int(v) - } - } - } - } - - return cfg, warnings, nil -} - -func MergeConfig(existing, incoming *config.Config) *config.Config { - if existing.Providers.Anthropic.APIKey == "" { - existing.Providers.Anthropic = incoming.Providers.Anthropic - } - if existing.Providers.OpenAI.APIKey == "" { - existing.Providers.OpenAI = incoming.Providers.OpenAI - } - if existing.Providers.OpenRouter.APIKey == "" { - existing.Providers.OpenRouter = incoming.Providers.OpenRouter - } - if existing.Providers.Groq.APIKey == "" { - existing.Providers.Groq = incoming.Providers.Groq - } - if existing.Providers.Zhipu.APIKey == "" { - existing.Providers.Zhipu = incoming.Providers.Zhipu - } - if existing.Providers.VLLM.APIKey == "" && existing.Providers.VLLM.APIBase == "" { - existing.Providers.VLLM = incoming.Providers.VLLM - } - if existing.Providers.Gemini.APIKey == "" { - existing.Providers.Gemini = incoming.Providers.Gemini - } - - if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled { - existing.Channels.Telegram = incoming.Channels.Telegram - } - if !existing.Channels.Discord.Enabled && incoming.Channels.Discord.Enabled { - existing.Channels.Discord = incoming.Channels.Discord - } - if !existing.Channels.WhatsApp.Enabled && incoming.Channels.WhatsApp.Enabled { - existing.Channels.WhatsApp = incoming.Channels.WhatsApp - } - if !existing.Channels.Feishu.Enabled && incoming.Channels.Feishu.Enabled { - existing.Channels.Feishu = incoming.Channels.Feishu - } - if !existing.Channels.QQ.Enabled && incoming.Channels.QQ.Enabled { - existing.Channels.QQ = incoming.Channels.QQ - } - if !existing.Channels.DingTalk.Enabled && incoming.Channels.DingTalk.Enabled { - existing.Channels.DingTalk = incoming.Channels.DingTalk - } - if !existing.Channels.MaixCam.Enabled && incoming.Channels.MaixCam.Enabled { - existing.Channels.MaixCam = incoming.Channels.MaixCam - } - - if existing.Tools.Web.Brave.APIKey == "" { - existing.Tools.Web.Brave = incoming.Tools.Web.Brave - } - - return existing -} - -func camelToSnake(s string) string { - var result strings.Builder - for i, r := range s { - if unicode.IsUpper(r) { - if i > 0 { - prev := rune(s[i-1]) - if unicode.IsLower(prev) || unicode.IsDigit(prev) { - result.WriteRune('_') - } else if unicode.IsUpper(prev) && i+1 < len(s) && unicode.IsLower(rune(s[i+1])) { - result.WriteRune('_') - } - } - result.WriteRune(unicode.ToLower(r)) - } else { - result.WriteRune(r) - } - } - return result.String() -} - -func convertKeysToSnake(data interface{}) interface{} { - switch v := data.(type) { - case map[string]interface{}: - result := make(map[string]interface{}, len(v)) - for key, val := range v { - result[camelToSnake(key)] = convertKeysToSnake(val) - } - return result - case []interface{}: - result := make([]interface{}, len(v)) - for i, val := range v { - result[i] = convertKeysToSnake(val) - } - return result - default: - return data - } -} - -func rewriteWorkspacePath(path string) string { - path = strings.Replace(path, ".openclaw", ".dragonscale", 1) - return path -} - -func getMap(data map[string]interface{}, key string) (map[string]interface{}, bool) { - v, ok := data[key] - if !ok { - return nil, false - } - m, ok := v.(map[string]interface{}) - return m, ok -} - -func getString(data map[string]interface{}, key string) (string, bool) { - v, ok := data[key] - if !ok { - return "", false - } - s, ok := v.(string) - return s, ok -} - -func getFloat(data map[string]interface{}, key string) (float64, bool) { - v, ok := data[key] - if !ok { - return 0, false - } - f, ok := v.(float64) - return f, ok -} - -func getBool(data map[string]interface{}, key string) (bool, bool) { - v, ok := data[key] - if !ok { - return false, false - } - b, ok := v.(bool) - return b, ok -} - -func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool { - if v, ok := getBool(data, key); ok { - return v - } - return defaultVal -} - -func getStringSlice(data map[string]interface{}, key string) []string { - v, ok := data[key] - if !ok { - return []string{} - } - arr, ok := v.([]interface{}) - if !ok { - return []string{} - } - result := make([]string, 0, len(arr)) - for _, item := range arr { - if s, ok := item.(string); ok { - result = append(result, s) - } - } - return result -} diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go deleted file mode 100644 index 0eaa84ac5..000000000 --- a/pkg/migrate/migrate.go +++ /dev/null @@ -1,394 +0,0 @@ -package migrate - -import ( - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/ZanzyTHEbar/dragonscale/pkg/config" -) - -type ActionType int - -const ( - ActionCopy ActionType = iota - ActionSkip - ActionBackup - ActionConvertConfig - ActionCreateDir - ActionMergeConfig -) - -type Options struct { - DryRun bool - ConfigOnly bool - WorkspaceOnly bool - Force bool - Refresh bool - OpenClawHome string - DragonScaleHome string -} - -type Action struct { - Type ActionType - Source string - Destination string - Description string -} - -type Result struct { - FilesCopied int - FilesSkipped int - BackupsCreated int - ConfigMigrated bool - DirsCreated int - Warnings []string - Errors []error -} - -func Run(opts Options) (*Result, error) { - if opts.ConfigOnly && opts.WorkspaceOnly { - return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive") - } - - if opts.Refresh { - opts.WorkspaceOnly = true - } - - openclawHome, err := resolveOpenClawHome(opts.OpenClawHome) - if err != nil { - return nil, err - } - - dragonscaleHome, err := resolveDragonScaleHome(opts.DragonScaleHome) - if err != nil { - return nil, err - } - - if _, err := os.Stat(openclawHome); os.IsNotExist(err) { - return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome) - } - - actions, warnings, err := Plan(opts, openclawHome, dragonscaleHome) - if err != nil { - return nil, err - } - - fmt.Println("Migrating from OpenClaw to DragonScale") - fmt.Printf(" Source: %s\n", openclawHome) - fmt.Printf(" Destination: %s\n", dragonscaleHome) - fmt.Println() - - if opts.DryRun { - PrintPlan(actions, warnings) - return &Result{Warnings: warnings}, nil - } - - if !opts.Force { - PrintPlan(actions, warnings) - if !Confirm() { - fmt.Println("Aborted.") - return &Result{Warnings: warnings}, nil - } - fmt.Println() - } - - result := Execute(actions, openclawHome, dragonscaleHome) - result.Warnings = warnings - return result, nil -} - -func Plan(opts Options, openclawHome, dragonscaleHome string) ([]Action, []string, error) { - var actions []Action - var warnings []string - - force := opts.Force || opts.Refresh - - if !opts.WorkspaceOnly { - configPath, err := findOpenClawConfig(openclawHome) - if err != nil { - if opts.ConfigOnly { - return nil, nil, err - } - warnings = append(warnings, fmt.Sprintf("Config migration skipped: %v", err)) - } else { - actions = append(actions, Action{ - Type: ActionConvertConfig, - Source: configPath, - Destination: filepath.Join(dragonscaleHome, "config.json"), - Description: "convert OpenClaw config to DragonScale format", - }) - - data, err := LoadOpenClawConfig(configPath) - if err == nil { - _, configWarnings, _ := ConvertConfig(data) - warnings = append(warnings, configWarnings...) - } - } - } - - if !opts.ConfigOnly { - srcWorkspace := resolveWorkspace(openclawHome) - dstWorkspace := resolveWorkspace(dragonscaleHome) - - if _, err := os.Stat(srcWorkspace); err == nil { - wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force) - if err != nil { - return nil, nil, fmt.Errorf("planning workspace migration: %w", err) - } - actions = append(actions, wsActions...) - } else { - warnings = append(warnings, "OpenClaw workspace directory not found, skipping workspace migration") - } - } - - return actions, warnings, nil -} - -func Execute(actions []Action, openclawHome, dragonscaleHome string) *Result { - result := &Result{} - - for _, action := range actions { - switch action.Type { - case ActionConvertConfig: - if err := executeConfigMigration(action.Source, action.Destination, dragonscaleHome); err != nil { - result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err)) - fmt.Printf(" ✗ Config migration failed: %v\n", err) - } else { - result.ConfigMigrated = true - fmt.Printf(" ✓ Converted config: %s\n", action.Destination) - } - case ActionCreateDir: - if err := os.MkdirAll(action.Destination, 0755); err != nil { - result.Errors = append(result.Errors, err) - } else { - result.DirsCreated++ - } - case ActionBackup: - bakPath := action.Destination + ".bak" - if err := copyFile(action.Destination, bakPath); err != nil { - result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err)) - fmt.Printf(" ✗ Backup failed: %s\n", action.Destination) - continue - } - result.BackupsCreated++ - fmt.Printf(" ✓ Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination)) - - if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil { - result.Errors = append(result.Errors, err) - continue - } - if err := copyFile(action.Source, action.Destination); err != nil { - result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) - fmt.Printf(" ✗ Copy failed: %s\n", action.Source) - } else { - result.FilesCopied++ - fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome)) - } - case ActionCopy: - if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil { - result.Errors = append(result.Errors, err) - continue - } - if err := copyFile(action.Source, action.Destination); err != nil { - result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) - fmt.Printf(" ✗ Copy failed: %s\n", action.Source) - } else { - result.FilesCopied++ - fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome)) - } - case ActionSkip: - result.FilesSkipped++ - } - } - - return result -} - -func executeConfigMigration(srcConfigPath, dstConfigPath, dragonscaleHome string) error { - data, err := LoadOpenClawConfig(srcConfigPath) - if err != nil { - return err - } - - incoming, _, err := ConvertConfig(data) - if err != nil { - return err - } - - if _, err := os.Stat(dstConfigPath); err == nil { - existing, err := config.LoadConfig(dstConfigPath) - if err != nil { - return fmt.Errorf("loading existing DragonScale config: %w", err) - } - incoming = MergeConfig(existing, incoming) - } - - if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil { - return err - } - return config.SaveConfig(dstConfigPath, incoming) -} - -func Confirm() bool { - fmt.Print("Proceed with migration? (y/n): ") - var response string - fmt.Scanln(&response) - return strings.ToLower(strings.TrimSpace(response)) == "y" -} - -func PrintPlan(actions []Action, warnings []string) { - fmt.Println("Planned actions:") - copies := 0 - skips := 0 - backups := 0 - configCount := 0 - - for _, action := range actions { - switch action.Type { - case ActionConvertConfig: - fmt.Printf(" [config] %s -> %s\n", action.Source, action.Destination) - configCount++ - case ActionCopy: - fmt.Printf(" [copy] %s\n", filepath.Base(action.Source)) - copies++ - case ActionBackup: - fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Destination)) - backups++ - copies++ - case ActionSkip: - if action.Description != "" { - fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description) - } - skips++ - case ActionCreateDir: - fmt.Printf(" [mkdir] %s\n", action.Destination) - } - } - - if len(warnings) > 0 { - fmt.Println() - fmt.Println("Warnings:") - for _, w := range warnings { - fmt.Printf(" - %s\n", w) - } - } - - fmt.Println() - fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n", - copies, configCount, backups, skips) -} - -func PrintSummary(result *Result) { - fmt.Println() - parts := []string{} - if result.FilesCopied > 0 { - parts = append(parts, fmt.Sprintf("%d files copied", result.FilesCopied)) - } - if result.ConfigMigrated { - parts = append(parts, "1 config converted") - } - if result.BackupsCreated > 0 { - parts = append(parts, fmt.Sprintf("%d backups created", result.BackupsCreated)) - } - if result.FilesSkipped > 0 { - parts = append(parts, fmt.Sprintf("%d files skipped", result.FilesSkipped)) - } - - if len(parts) > 0 { - fmt.Printf("Migration complete! %s.\n", strings.Join(parts, ", ")) - } else { - fmt.Println("Migration complete! No actions taken.") - } - - if len(result.Errors) > 0 { - fmt.Println() - fmt.Printf("%d errors occurred:\n", len(result.Errors)) - for _, e := range result.Errors { - fmt.Printf(" - %v\n", e) - } - } -} - -func resolveOpenClawHome(override string) (string, error) { - if override != "" { - return expandHome(override), nil - } - if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" { - return expandHome(envHome), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolving home directory: %w", err) - } - return filepath.Join(home, ".openclaw"), nil -} - -func resolveDragonScaleHome(override string) (string, error) { - if override != "" { - return expandHome(override), nil - } - if envHome := os.Getenv("DRAGONSCALE_HOME"); envHome != "" { - return expandHome(envHome), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolving home directory: %w", err) - } - return filepath.Join(home, ".dragonscale"), nil -} - -func resolveWorkspace(homeDir string) string { - return filepath.Join(homeDir, "workspace") -} - -func expandHome(path string) string { - if path == "" { - return path - } - if path[0] == '~' { - home, _ := os.UserHomeDir() - if len(path) > 1 && path[1] == '/' { - return home + path[1:] - } - return home - } - return path -} - -func backupFile(path string) error { - bakPath := path + ".bak" - return copyFile(path, bakPath) -} - -func copyFile(src, dst string) error { - srcFile, err := os.Open(src) - if err != nil { - return err - } - defer srcFile.Close() - - info, err := srcFile.Stat() - if err != nil { - return err - } - - dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) - if err != nil { - return err - } - defer dstFile.Close() - - _, err = io.Copy(dstFile, srcFile) - return err -} - -func relPath(path, base string) string { - rel, err := filepath.Rel(base, path) - if err != nil { - return filepath.Base(path) - } - return rel -} diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go deleted file mode 100644 index 97b2bd125..000000000 --- a/pkg/migrate/migrate_test.go +++ /dev/null @@ -1,871 +0,0 @@ -package migrate - -import ( - "os" - "path/filepath" - "testing" - - jsonv2 "github.com/go-json-experiment/json" - - "github.com/ZanzyTHEbar/dragonscale/pkg/config" -) - -func TestCamelToSnake(t *testing.T) { - t.Parallel() - tests := []struct { - name string - input string - want string - }{ - {"simple", "apiKey", "api_key"}, - {"two words", "apiBase", "api_base"}, - {"three words", "maxToolIterations", "max_tool_iterations"}, - {"already snake", "api_key", "api_key"}, - {"single word", "enabled", "enabled"}, - {"all lower", "model", "model"}, - {"consecutive caps", "apiURL", "api_url"}, - {"starts upper", "Model", "model"}, - {"bridge url", "bridgeUrl", "bridge_url"}, - {"client id", "clientId", "client_id"}, - {"app secret", "appSecret", "app_secret"}, - {"verification token", "verificationToken", "verification_token"}, - {"allow from", "allowFrom", "allow_from"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := camelToSnake(tt.input) - if got != tt.want { - t.Errorf("camelToSnake(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestConvertKeysToSnake(t *testing.T) { - t.Parallel() - input := map[string]interface{}{ - "apiKey": "test-key", - "apiBase": "https://example.com", - "nested": map[string]interface{}{ - "maxTokens": float64(8192), - "allowFrom": []interface{}{"user1", "user2"}, - "deeperLevel": map[string]interface{}{ - "clientId": "abc", - }, - }, - } - - result := convertKeysToSnake(input) - m, ok := result.(map[string]interface{}) - if !ok { - t.Fatal("expected map[string]interface{}") - } - - if _, ok := m["api_key"]; !ok { - t.Error("expected key 'api_key' after conversion") - } - if _, ok := m["api_base"]; !ok { - t.Error("expected key 'api_base' after conversion") - } - - nested, ok := m["nested"].(map[string]interface{}) - if !ok { - t.Fatal("expected nested map") - } - if _, ok := nested["max_tokens"]; !ok { - t.Error("expected key 'max_tokens' in nested map") - } - if _, ok := nested["allow_from"]; !ok { - t.Error("expected key 'allow_from' in nested map") - } - - deeper, ok := nested["deeper_level"].(map[string]interface{}) - if !ok { - t.Fatal("expected deeper_level map") - } - if _, ok := deeper["client_id"]; !ok { - t.Error("expected key 'client_id' in deeper level") - } -} - -func TestLoadOpenClawConfig(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "openclaw.json") - - openclawConfig := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "sk-ant-test123", - "apiBase": "https://api.anthropic.com", - }, - }, - "agents": map[string]interface{}{ - "defaults": map[string]interface{}{ - "maxTokens": float64(4096), - "model": "claude-3-opus", - }, - }, - } - - data, err := jsonv2.Marshal(openclawConfig) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(configPath, data, 0644); err != nil { - t.Fatal(err) - } - - result, err := LoadOpenClawConfig(configPath) - if err != nil { - t.Fatalf("LoadOpenClawConfig: %v", err) - } - - providers, ok := result["providers"].(map[string]interface{}) - if !ok { - t.Fatal("expected providers map") - } - anthropic, ok := providers["anthropic"].(map[string]interface{}) - if !ok { - t.Fatal("expected anthropic map") - } - if anthropic["api_key"] != "sk-ant-test123" { - t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"]) - } - - agents, ok := result["agents"].(map[string]interface{}) - if !ok { - t.Fatal("expected agents map") - } - defaults, ok := agents["defaults"].(map[string]interface{}) - if !ok { - t.Fatal("expected defaults map") - } - if defaults["max_tokens"] != float64(4096) { - t.Errorf("max_tokens = %v, want 4096", defaults["max_tokens"]) - } -} - -func TestConvertConfig(t *testing.T) { - t.Parallel() - t.Run("providers mapping", func(t *testing.T) { - data := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "api_key": "sk-ant-test", - "api_base": "https://api.anthropic.com", - }, - "openrouter": map[string]interface{}{ - "api_key": "sk-or-test", - }, - "groq": map[string]interface{}{ - "api_key": "gsk-test", - }, - }, - } - - cfg, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 0 { - t.Errorf("expected no warnings, got %v", warnings) - } - if cfg.Providers.Anthropic.APIKey != "sk-ant-test" { - t.Errorf("Anthropic.APIKey = %q, want %q", cfg.Providers.Anthropic.APIKey, "sk-ant-test") - } - if cfg.Providers.OpenRouter.APIKey != "sk-or-test" { - t.Errorf("OpenRouter.APIKey = %q, want %q", cfg.Providers.OpenRouter.APIKey, "sk-or-test") - } - if cfg.Providers.Groq.APIKey != "gsk-test" { - t.Errorf("Groq.APIKey = %q, want %q", cfg.Providers.Groq.APIKey, "gsk-test") - } - }) - - t.Run("unsupported provider warning", func(t *testing.T) { - data := map[string]interface{}{ - "providers": map[string]interface{}{ - "deepseek": map[string]interface{}{ - "api_key": "sk-deep-test", - }, - }, - } - - _, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 1 { - t.Fatalf("expected 1 warning, got %d", len(warnings)) - } - if warnings[0] != "Provider 'deepseek' not supported in DragonScale, skipping" { - t.Errorf("unexpected warning: %s", warnings[0]) - } - }) - - t.Run("channels mapping", func(t *testing.T) { - data := map[string]interface{}{ - "channels": map[string]interface{}{ - "telegram": map[string]interface{}{ - "enabled": true, - "token": "tg-token-123", - "allow_from": []interface{}{"user1"}, - }, - "discord": map[string]interface{}{ - "enabled": true, - "token": "disc-token-456", - }, - }, - } - - cfg, _, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if !cfg.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled") - } - if cfg.Channels.Telegram.Token != "tg-token-123" { - t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token, "tg-token-123") - } - if len(cfg.Channels.Telegram.AllowFrom) != 1 || cfg.Channels.Telegram.AllowFrom[0] != "user1" { - t.Errorf("Telegram.AllowFrom = %v, want [user1]", cfg.Channels.Telegram.AllowFrom) - } - if !cfg.Channels.Discord.Enabled { - t.Error("Discord should be enabled") - } - }) - - t.Run("unsupported channel warning", func(t *testing.T) { - data := map[string]interface{}{ - "channels": map[string]interface{}{ - "email": map[string]interface{}{ - "enabled": true, - }, - }, - } - - _, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 1 { - t.Fatalf("expected 1 warning, got %d", len(warnings)) - } - if warnings[0] != "Channel 'email' not supported in DragonScale, skipping" { - t.Errorf("unexpected warning: %s", warnings[0]) - } - }) - - t.Run("agent defaults", func(t *testing.T) { - data := map[string]interface{}{ - "agents": map[string]interface{}{ - "defaults": map[string]interface{}{ - "model": "claude-3-opus", - "max_tokens": float64(4096), - "temperature": 0.5, - "max_tool_iterations": float64(10), - "workspace": "~/.openclaw/workspace", - }, - }, - } - - cfg, _, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if cfg.Agents.Defaults.Model != "claude-3-opus" { - t.Errorf("Model = %q, want %q", cfg.Agents.Defaults.Model, "claude-3-opus") - } - if cfg.Agents.Defaults.MaxTokens != 4096 { - t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096) - } - if cfg.Agents.Defaults.Temperature != 0.5 { - t.Errorf("Temperature = %f, want %f", cfg.Agents.Defaults.Temperature, 0.5) - } - if cfg.Agents.Defaults.Workspace != "~/.dragonscale/workspace" { - t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.dragonscale/workspace") - } - }) - - t.Run("empty config", func(t *testing.T) { - data := map[string]interface{}{} - - cfg, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 0 { - t.Errorf("expected no warnings, got %v", warnings) - } - if cfg.Agents.Defaults.Model != "glm-4.7" { - t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model) - } - }) -} - -func TestMergeConfig(t *testing.T) { - t.Parallel() - t.Run("fills empty fields", func(t *testing.T) { - existing := config.DefaultConfig() - incoming := config.DefaultConfig() - incoming.Providers.Anthropic.APIKey = "sk-ant-incoming" - incoming.Providers.OpenRouter.APIKey = "sk-or-incoming" - - result := MergeConfig(existing, incoming) - if result.Providers.Anthropic.APIKey != "sk-ant-incoming" { - t.Errorf("Anthropic.APIKey = %q, want %q", result.Providers.Anthropic.APIKey, "sk-ant-incoming") - } - if result.Providers.OpenRouter.APIKey != "sk-or-incoming" { - t.Errorf("OpenRouter.APIKey = %q, want %q", result.Providers.OpenRouter.APIKey, "sk-or-incoming") - } - }) - - t.Run("preserves existing non-empty fields", func(t *testing.T) { - existing := config.DefaultConfig() - existing.Providers.Anthropic.APIKey = "sk-ant-existing" - - incoming := config.DefaultConfig() - incoming.Providers.Anthropic.APIKey = "sk-ant-incoming" - incoming.Providers.OpenAI.APIKey = "sk-oai-incoming" - - result := MergeConfig(existing, incoming) - if result.Providers.Anthropic.APIKey != "sk-ant-existing" { - t.Errorf("Anthropic.APIKey should be preserved, got %q", result.Providers.Anthropic.APIKey) - } - if result.Providers.OpenAI.APIKey != "sk-oai-incoming" { - t.Errorf("OpenAI.APIKey should be filled, got %q", result.Providers.OpenAI.APIKey) - } - }) - - t.Run("merges enabled channels", func(t *testing.T) { - existing := config.DefaultConfig() - incoming := config.DefaultConfig() - incoming.Channels.Telegram.Enabled = true - incoming.Channels.Telegram.Token = "tg-token" - - result := MergeConfig(existing, incoming) - if !result.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled after merge") - } - if result.Channels.Telegram.Token != "tg-token" { - t.Errorf("Telegram.Token = %q, want %q", result.Channels.Telegram.Token, "tg-token") - } - }) - - t.Run("preserves existing enabled channels", func(t *testing.T) { - existing := config.DefaultConfig() - existing.Channels.Telegram.Enabled = true - existing.Channels.Telegram.Token = "existing-token" - - incoming := config.DefaultConfig() - incoming.Channels.Telegram.Enabled = true - incoming.Channels.Telegram.Token = "incoming-token" - - result := MergeConfig(existing, incoming) - if result.Channels.Telegram.Token != "existing-token" { - t.Errorf("Telegram.Token should be preserved, got %q", result.Channels.Telegram.Token) - } - }) -} - -func TestPlanWorkspaceMigration(t *testing.T) { - t.Parallel() - t.Run("copies available files", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644) - os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644) - os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - copyCount := 0 - skipCount := 0 - for _, a := range actions { - if a.Type == ActionCopy { - copyCount++ - } - if a.Type == ActionSkip { - skipCount++ - } - } - if copyCount != 3 { - t.Errorf("expected 3 copies, got %d", copyCount) - } - if skipCount != 2 { - t.Errorf("expected 2 skips (TOOLS.md, HEARTBEAT.md), got %d", skipCount) - } - }) - - t.Run("plans backup for existing destination files", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - backupCount := 0 - for _, a := range actions { - if a.Type == ActionBackup && filepath.Base(a.Destination) == "AGENTS.md" { - backupCount++ - } - } - if backupCount != 1 { - t.Errorf("expected 1 backup action for AGENTS.md, got %d", backupCount) - } - }) - - t.Run("force skips backup", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, true) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - for _, a := range actions { - if a.Type == ActionBackup { - t.Error("expected no backup actions with force=true") - } - } - }) - - t.Run("handles memory directory", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - memDir := filepath.Join(srcDir, "memory") - os.MkdirAll(memDir, 0755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - hasCopy := false - hasDir := false - for _, a := range actions { - if a.Type == ActionCopy && filepath.Base(a.Source) == "MEMORY.md" { - hasCopy = true - } - if a.Type == ActionCreateDir { - hasDir = true - } - } - if !hasCopy { - t.Error("expected copy action for memory/MEMORY.md") - } - if !hasDir { - t.Error("expected create dir action for memory/") - } - }) - - t.Run("handles skills directory", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - skillDir := filepath.Join(srcDir, "skills", "weather") - os.MkdirAll(skillDir, 0755) - os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - hasCopy := false - for _, a := range actions { - if a.Type == ActionCopy && filepath.Base(a.Source) == "SKILL.md" { - hasCopy = true - } - } - if !hasCopy { - t.Error("expected copy action for skills/weather/SKILL.md") - } - }) -} - -func TestFindOpenClawConfig(t *testing.T) { - t.Parallel() - t.Run("finds openclaw.json", func(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(configPath, []byte("{}"), 0644) - - found, err := findOpenClawConfig(tmpDir) - if err != nil { - t.Fatalf("findOpenClawConfig: %v", err) - } - if found != configPath { - t.Errorf("found %q, want %q", found, configPath) - } - }) - - t.Run("falls back to config.json", func(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "config.json") - os.WriteFile(configPath, []byte("{}"), 0644) - - found, err := findOpenClawConfig(tmpDir) - if err != nil { - t.Fatalf("findOpenClawConfig: %v", err) - } - if found != configPath { - t.Errorf("found %q, want %q", found, configPath) - } - }) - - t.Run("prefers openclaw.json over config.json", func(t *testing.T) { - tmpDir := t.TempDir() - openclawPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(openclawPath, []byte("{}"), 0644) - os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644) - - found, err := findOpenClawConfig(tmpDir) - if err != nil { - t.Fatalf("findOpenClawConfig: %v", err) - } - if found != openclawPath { - t.Errorf("should prefer openclaw.json, got %q", found) - } - }) - - t.Run("error when no config found", func(t *testing.T) { - tmpDir := t.TempDir() - - _, err := findOpenClawConfig(tmpDir) - if err == nil { - t.Fatal("expected error when no config found") - } - }) -} - -func TestRewriteWorkspacePath(t *testing.T) { - t.Parallel() - tests := []struct { - name string - input string - want string - }{ - {"default path", "~/.openclaw/workspace", "~/.dragonscale/workspace"}, - {"custom path", "/custom/path", "/custom/path"}, - {"empty", "", ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := rewriteWorkspacePath(tt.input) - if got != tt.want { - t.Errorf("rewriteWorkspacePath(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestRunDryRun(t *testing.T) { - t.Parallel() - openclawHome := t.TempDir() - dragonscaleHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644) - - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "test-key", - }, - }, - } - data, _ := jsonv2.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) - - opts := Options{ - DryRun: true, - OpenClawHome: openclawHome, - DragonScaleHome: dragonscaleHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - dragonWs := filepath.Join(dragonscaleHome, "workspace") - if _, err := os.Stat(filepath.Join(dragonWs, "SOUL.md")); !os.IsNotExist(err) { - t.Error("dry run should not create files") - } - if _, err := os.Stat(filepath.Join(dragonscaleHome, "config.json")); !os.IsNotExist(err) { - t.Error("dry run should not create config") - } - - _ = result -} - -func TestRunFullMigration(t *testing.T) { - t.Parallel() - openclawHome := t.TempDir() - dragonscaleHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644) - os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644) - - memDir := filepath.Join(wsDir, "memory") - os.MkdirAll(memDir, 0755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644) - - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "sk-ant-migrate-test", - }, - "openrouter": map[string]interface{}{ - "apiKey": "sk-or-migrate-test", - }, - }, - "channels": map[string]interface{}{ - "telegram": map[string]interface{}{ - "enabled": true, - "token": "tg-migrate-test", - }, - }, - } - data, _ := jsonv2.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) - - opts := Options{ - Force: true, - OpenClawHome: openclawHome, - DragonScaleHome: dragonscaleHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - dragonWs := filepath.Join(dragonscaleHome, "workspace") - - soulData, err := os.ReadFile(filepath.Join(dragonWs, "SOUL.md")) - if err != nil { - t.Fatalf("reading SOUL.md: %v", err) - } - if string(soulData) != "# Soul from OpenClaw" { - t.Errorf("SOUL.md content = %q, want %q", string(soulData), "# Soul from OpenClaw") - } - - agentsData, err := os.ReadFile(filepath.Join(dragonWs, "AGENTS.md")) - if err != nil { - t.Fatalf("reading AGENTS.md: %v", err) - } - if string(agentsData) != "# Agents from OpenClaw" { - t.Errorf("AGENTS.md content = %q", string(agentsData)) - } - - memData, err := os.ReadFile(filepath.Join(dragonWs, "memory", "MEMORY.md")) - if err != nil { - t.Fatalf("reading memory/MEMORY.md: %v", err) - } - if string(memData) != "# Memory notes" { - t.Errorf("MEMORY.md content = %q", string(memData)) - } - - dragonConfig, err := config.LoadConfig(filepath.Join(dragonscaleHome, "config.json")) - if err != nil { - t.Fatalf("loading DragonScale config: %v", err) - } - if dragonConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" { - t.Errorf("Anthropic.APIKey = %q, want %q", dragonConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test") - } - if dragonConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" { - t.Errorf("OpenRouter.APIKey = %q, want %q", dragonConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test") - } - if !dragonConfig.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled") - } - if dragonConfig.Channels.Telegram.Token != "tg-migrate-test" { - t.Errorf("Telegram.Token = %q, want %q", dragonConfig.Channels.Telegram.Token, "tg-migrate-test") - } - - if result.FilesCopied < 3 { - t.Errorf("expected at least 3 files copied, got %d", result.FilesCopied) - } - if !result.ConfigMigrated { - t.Error("config should have been migrated") - } - if len(result.Errors) > 0 { - t.Errorf("expected no errors, got %v", result.Errors) - } -} - -func TestRunOpenClawNotFound(t *testing.T) { - t.Parallel() - opts := Options{ - OpenClawHome: "/nonexistent/path/to/openclaw", - DragonScaleHome: t.TempDir(), - } - - _, err := Run(opts) - if err == nil { - t.Fatal("expected error when OpenClaw not found") - } -} - -func TestRunMutuallyExclusiveFlags(t *testing.T) { - t.Parallel() - opts := Options{ - ConfigOnly: true, - WorkspaceOnly: true, - } - - _, err := Run(opts) - if err == nil { - t.Fatal("expected error for mutually exclusive flags") - } -} - -func TestBackupFile(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - filePath := filepath.Join(tmpDir, "test.md") - os.WriteFile(filePath, []byte("original content"), 0644) - - if err := backupFile(filePath); err != nil { - t.Fatalf("backupFile: %v", err) - } - - bakPath := filePath + ".bak" - bakData, err := os.ReadFile(bakPath) - if err != nil { - t.Fatalf("reading backup: %v", err) - } - if string(bakData) != "original content" { - t.Errorf("backup content = %q, want %q", string(bakData), "original content") - } -} - -func TestCopyFile(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - srcPath := filepath.Join(tmpDir, "src.md") - dstPath := filepath.Join(tmpDir, "dst.md") - - os.WriteFile(srcPath, []byte("file content"), 0644) - - if err := copyFile(srcPath, dstPath); err != nil { - t.Fatalf("copyFile: %v", err) - } - - data, err := os.ReadFile(dstPath) - if err != nil { - t.Fatalf("reading copy: %v", err) - } - if string(data) != "file content" { - t.Errorf("copy content = %q, want %q", string(data), "file content") - } -} - -func TestRunConfigOnly(t *testing.T) { - t.Parallel() - openclawHome := t.TempDir() - dragonscaleHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) - - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "sk-config-only", - }, - }, - } - data, _ := jsonv2.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) - - opts := Options{ - Force: true, - ConfigOnly: true, - OpenClawHome: openclawHome, - DragonScaleHome: dragonscaleHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if !result.ConfigMigrated { - t.Error("config should have been migrated") - } - - dragonWs := filepath.Join(dragonscaleHome, "workspace") - if _, err := os.Stat(filepath.Join(dragonWs, "SOUL.md")); !os.IsNotExist(err) { - t.Error("config-only should not copy workspace files") - } -} - -func TestRunWorkspaceOnly(t *testing.T) { - t.Parallel() - openclawHome := t.TempDir() - dragonscaleHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) - - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "sk-ws-only", - }, - }, - } - data, _ := jsonv2.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) - - opts := Options{ - Force: true, - WorkspaceOnly: true, - OpenClawHome: openclawHome, - DragonScaleHome: dragonscaleHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if result.ConfigMigrated { - t.Error("workspace-only should not migrate config") - } - - dragonWs := filepath.Join(dragonscaleHome, "workspace") - soulData, err := os.ReadFile(filepath.Join(dragonWs, "SOUL.md")) - if err != nil { - t.Fatalf("reading SOUL.md: %v", err) - } - if string(soulData) != "# Soul" { - t.Errorf("SOUL.md content = %q", string(soulData)) - } -} diff --git a/pkg/migrate/workspace.go b/pkg/migrate/workspace.go deleted file mode 100644 index f45748fac..000000000 --- a/pkg/migrate/workspace.go +++ /dev/null @@ -1,106 +0,0 @@ -package migrate - -import ( - "os" - "path/filepath" -) - -var migrateableFiles = []string{ - "AGENTS.md", - "SOUL.md", - "USER.md", - "TOOLS.md", - "HEARTBEAT.md", -} - -var migrateableDirs = []string{ - "memory", - "skills", -} - -func PlanWorkspaceMigration(srcWorkspace, dstWorkspace string, force bool) ([]Action, error) { - var actions []Action - - for _, filename := range migrateableFiles { - src := filepath.Join(srcWorkspace, filename) - dst := filepath.Join(dstWorkspace, filename) - action := planFileCopy(src, dst, force) - if action.Type != ActionSkip || action.Description != "" { - actions = append(actions, action) - } - } - - for _, dirname := range migrateableDirs { - srcDir := filepath.Join(srcWorkspace, dirname) - if _, err := os.Stat(srcDir); os.IsNotExist(err) { - continue - } - dirActions, err := planDirCopy(srcDir, filepath.Join(dstWorkspace, dirname), force) - if err != nil { - return nil, err - } - actions = append(actions, dirActions...) - } - - return actions, nil -} - -func planFileCopy(src, dst string, force bool) Action { - if _, err := os.Stat(src); os.IsNotExist(err) { - return Action{ - Type: ActionSkip, - Source: src, - Destination: dst, - Description: "source file not found", - } - } - - _, dstExists := os.Stat(dst) - if dstExists == nil && !force { - return Action{ - Type: ActionBackup, - Source: src, - Destination: dst, - Description: "destination exists, will backup and overwrite", - } - } - - return Action{ - Type: ActionCopy, - Source: src, - Destination: dst, - Description: "copy file", - } -} - -func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) { - var actions []Action - - err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - relPath, err := filepath.Rel(srcDir, path) - if err != nil { - return err - } - - dst := filepath.Join(dstDir, relPath) - - if info.IsDir() { - actions = append(actions, Action{ - Type: ActionCreateDir, - Destination: dst, - Description: "create directory", - }) - return nil - } - - action := planFileCopy(path, dst, force) - actions = append(actions, action) - return nil - }) - - return actions, err -} diff --git a/pkg/migrate/xdg.go b/pkg/migrate/xdg.go deleted file mode 100644 index 9ed2b96f1..000000000 --- a/pkg/migrate/xdg.go +++ /dev/null @@ -1,166 +0,0 @@ -package migrate - -import ( - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/ZanzyTHEbar/dragonscale/pkg/config" -) - -// MigrateToXDG moves files from the legacy ~/.dragonscale/workspace/ layout to -// XDG-compliant directories. It is idempotent: files are only copied if they -// don't already exist at the destination, and it never deletes the source. -// -// Layout mapping: -// -// ~/.dragonscale/workspace/AGENT.md → $XDG_CONFIG_HOME/dragonscale/identity/AGENT.md -// ~/.dragonscale/workspace/IDENTITY.md → $XDG_CONFIG_HOME/dragonscale/identity/IDENTITY.md -// ~/.dragonscale/workspace/SOUL.md → $XDG_CONFIG_HOME/dragonscale/identity/SOUL.md -// ~/.dragonscale/workspace/USER.md → $XDG_CONFIG_HOME/dragonscale/identity/USER.md -// ~/.dragonscale/workspace/skills/* → $XDG_DATA_HOME/dragonscale/skills/* -// ~/.dragonscale/workspace/memory/*.db → $XDG_DATA_HOME/dragonscale/dragonscale.db -// ~/.dragonscale/workspace/* → $XDG_DATA_HOME/dragonscale/sandbox/* (remaining files) -func MigrateToXDG(legacyWorkspace string) error { - if legacyWorkspace == "" { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("resolve home: %w", err) - } - legacyWorkspace = filepath.Join(home, ".dragonscale", "workspace") - } - - if _, err := os.Stat(legacyWorkspace); os.IsNotExist(err) { - return nil - } - - identityDir, err := config.IdentityDir() - if err != nil { - return fmt.Errorf("resolve identity dir: %w", err) - } - skillsDir, err := config.SkillsDir() - if err != nil { - return fmt.Errorf("resolve skills dir: %w", err) - } - dataDir, err := config.DataDir() - if err != nil { - return fmt.Errorf("resolve data dir: %w", err) - } - sandboxDir, err := config.SandboxDir() - if err != nil { - return fmt.Errorf("resolve sandbox dir: %w", err) - } - - identityFiles := map[string]bool{ - "AGENT.md": true, "IDENTITY.md": true, - "SOUL.md": true, "USER.md": true, - } - - for name := range identityFiles { - src := filepath.Join(legacyWorkspace, name) - dst := filepath.Join(identityDir, name) - if err := copyIfMissing(src, dst); err != nil { - return fmt.Errorf("migrate %s: %w", name, err) - } - } - - legacySkills := filepath.Join(legacyWorkspace, "skills") - if info, err := os.Stat(legacySkills); err == nil && info.IsDir() { - if err := copyDirIfMissing(legacySkills, skillsDir); err != nil { - return fmt.Errorf("migrate skills: %w", err) - } - } - - legacyDB := filepath.Join(legacyWorkspace, "memory", "dragonscale.db") - if _, err := os.Stat(legacyDB); err == nil { - newDB := filepath.Join(dataDir, "dragonscale.db") - if err := copyIfMissing(legacyDB, newDB); err != nil { - return fmt.Errorf("migrate database: %w", err) - } - } - - skipPrefixes := []string{"AGENT.md", "IDENTITY.md", "SOUL.md", "USER.md"} - - entries, err := os.ReadDir(legacyWorkspace) - if err != nil { - return nil - } - for _, e := range entries { - name := e.Name() - - if identityFiles[name] { - continue - } - if name == "skills" || name == "memory" { - continue - } - - skip := false - for _, p := range skipPrefixes { - if strings.EqualFold(name, p) { - skip = true - break - } - } - if skip { - continue - } - - src := filepath.Join(legacyWorkspace, name) - dst := filepath.Join(sandboxDir, name) - if e.IsDir() { - if err := copyDirIfMissing(src, dst); err != nil { - return fmt.Errorf("migrate %s: %w", name, err) - } - } else { - if err := copyIfMissing(src, dst); err != nil { - return fmt.Errorf("migrate %s: %w", name, err) - } - } - } - - return nil -} - -func copyIfMissing(src, dst string) error { - if _, err := os.Stat(src); os.IsNotExist(err) { - return nil - } - if _, err := os.Stat(dst); err == nil { - return nil - } - - os.MkdirAll(filepath.Dir(dst), 0o700) - - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, in) - return err -} - -func copyDirIfMissing(src, dst string) error { - return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - rel, _ := filepath.Rel(src, path) - target := filepath.Join(dst, rel) - - if d.IsDir() { - return os.MkdirAll(target, 0o700) - } - return copyIfMissing(path, target) - }) -}