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
This commit is contained in:
parent
ca5459aabd
commit
388c0952fb
15 changed files with 0 additions and 2802 deletions
|
|
@ -22,27 +22,11 @@ func buildMemoryCommand(ctx *cli.AppContext) *cobra.Command {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(buildMemoryMigrateSessionsCommand(ctx))
|
|
||||||
cmd.AddCommand(buildMemoryDBStatusCommand(ctx))
|
cmd.AddCommand(buildMemoryDBStatusCommand(ctx))
|
||||||
|
|
||||||
return cmd
|
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 {
|
func buildMemoryDBStatusCommand(ctx *cli.AppContext) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "db-status",
|
Use: "db-status",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@ func RegisterAll() {
|
||||||
// Command factories are intentionally registered by their feature modules.
|
// Command factories are intentionally registered by their feature modules.
|
||||||
registerAgentCommand()
|
registerAgentCommand()
|
||||||
registerGatewayCommand()
|
registerGatewayCommand()
|
||||||
registerMigrateCommand()
|
|
||||||
registerAuthCommand()
|
registerAuthCommand()
|
||||||
registerCronCommand()
|
registerCronCommand()
|
||||||
registerSkillsCommand()
|
registerSkillsCommand()
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,6 @@ func TestBuildRoot_RegistersExpectedCommands(t *testing.T) {
|
||||||
expected := []string{
|
expected := []string{
|
||||||
"agent",
|
"agent",
|
||||||
"gateway",
|
"gateway",
|
||||||
"migrate",
|
|
||||||
"auth",
|
"auth",
|
||||||
"cron",
|
"cron",
|
||||||
"skills",
|
"skills",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue