mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 18:15:41 +00:00
feat: Introduce a new upgrade command and enhance built-in tool settings with provider and model configuration.
This commit is contained in:
1 parent
d65d792646
commit
86d58e1021
23 files changed
+1195
-170
No files matched your search
+132
-16
@@ -1,15 +1,19 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/upgrade"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
@@ -25,7 +29,7 @@ func doctorCmd() *cobra.Command {
|
||||
|
||||
func runDoctor() {
|
||||
fmt.Println("goclaw doctor")
|
||||
fmt.Printf(" Version: 0.2.0 (protocol %d)\n", protocol.ProtocolVersion)
|
||||
fmt.Printf(" Version: %s (protocol %d)\n", Version, protocol.ProtocolVersion)
|
||||
fmt.Printf(" OS: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
fmt.Printf(" Go: %s\n", runtime.Version())
|
||||
fmt.Println()
|
||||
@@ -45,26 +49,78 @@ func runDoctor() {
|
||||
return
|
||||
}
|
||||
|
||||
// Providers
|
||||
// Database (managed mode only) — open early so we can show DB providers.
|
||||
var db *sql.DB
|
||||
isManaged := cfg.Database.Mode == "managed" && cfg.Database.PostgresDSN != ""
|
||||
if isManaged {
|
||||
fmt.Println()
|
||||
fmt.Println(" Database:")
|
||||
fmt.Printf(" %-12s managed\n", "Mode:")
|
||||
var dbErr error
|
||||
db, dbErr = sql.Open("pgx", cfg.Database.PostgresDSN)
|
||||
if dbErr != nil {
|
||||
fmt.Printf(" %-12s CONNECT FAILED (%s)\n", "Status:", dbErr)
|
||||
db = nil
|
||||
} else if pingErr := db.Ping(); pingErr != nil {
|
||||
fmt.Printf(" %-12s CONNECT FAILED (%s)\n", "Status:", pingErr)
|
||||
db.Close()
|
||||
db = nil
|
||||
} else {
|
||||
defer db.Close()
|
||||
s, schemaErr := upgrade.CheckSchema(db)
|
||||
if schemaErr != nil {
|
||||
fmt.Printf(" %-12s CHECK FAILED (%s)\n", "Schema:", schemaErr)
|
||||
} else if s.Dirty {
|
||||
fmt.Printf(" %-12s v%d (DIRTY — run: goclaw migrate force %d)\n", "Schema:", s.CurrentVersion, s.CurrentVersion-1)
|
||||
} else if s.Compatible {
|
||||
fmt.Printf(" %-12s v%d (up to date)\n", "Schema:", s.CurrentVersion)
|
||||
} else if s.CurrentVersion > s.RequiredVersion {
|
||||
fmt.Printf(" %-12s v%d (binary too old, requires v%d)\n", "Schema:", s.CurrentVersion, s.RequiredVersion)
|
||||
} else {
|
||||
fmt.Printf(" %-12s v%d (upgrade needed — run: goclaw upgrade)\n", "Schema:", s.CurrentVersion)
|
||||
}
|
||||
|
||||
pending, hookErr := upgrade.PendingHooks(context.Background(), db)
|
||||
if hookErr == nil && len(pending) > 0 {
|
||||
fmt.Printf(" %-12s %d pending\n", "Data hooks:", len(pending))
|
||||
} else if hookErr == nil {
|
||||
fmt.Printf(" %-12s all applied\n", "Data hooks:")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Providers — show DB providers in managed mode, config providers otherwise.
|
||||
fmt.Println()
|
||||
fmt.Println(" Providers:")
|
||||
checkProvider("Anthropic", cfg.Providers.Anthropic.APIKey)
|
||||
checkProvider("OpenAI", cfg.Providers.OpenAI.APIKey)
|
||||
checkProvider("OpenRouter", cfg.Providers.OpenRouter.APIKey)
|
||||
checkProvider("Gemini", cfg.Providers.Gemini.APIKey)
|
||||
checkProvider("Groq", cfg.Providers.Groq.APIKey)
|
||||
checkProvider("DeepSeek", cfg.Providers.DeepSeek.APIKey)
|
||||
checkProvider("Mistral", cfg.Providers.Mistral.APIKey)
|
||||
checkProvider("XAI", cfg.Providers.XAI.APIKey)
|
||||
if isManaged && db != nil {
|
||||
checkDBProviders(db)
|
||||
// Also show config-only providers (env vars) not in DB.
|
||||
checkProvider("Anthropic (env)", cfg.Providers.Anthropic.APIKey)
|
||||
checkProvider("OpenAI (env)", cfg.Providers.OpenAI.APIKey)
|
||||
checkProvider("OpenRouter (env)", cfg.Providers.OpenRouter.APIKey)
|
||||
} else {
|
||||
checkProvider("Anthropic", cfg.Providers.Anthropic.APIKey)
|
||||
checkProvider("OpenAI", cfg.Providers.OpenAI.APIKey)
|
||||
checkProvider("OpenRouter", cfg.Providers.OpenRouter.APIKey)
|
||||
checkProvider("Gemini", cfg.Providers.Gemini.APIKey)
|
||||
checkProvider("Groq", cfg.Providers.Groq.APIKey)
|
||||
checkProvider("DeepSeek", cfg.Providers.DeepSeek.APIKey)
|
||||
checkProvider("Mistral", cfg.Providers.Mistral.APIKey)
|
||||
checkProvider("XAI", cfg.Providers.XAI.APIKey)
|
||||
}
|
||||
|
||||
// Channels
|
||||
// Channels — show DB channels in managed mode, config channels otherwise.
|
||||
fmt.Println()
|
||||
fmt.Println(" Channels:")
|
||||
checkChannel("Telegram", cfg.Channels.Telegram.Enabled, cfg.Channels.Telegram.Token != "")
|
||||
checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "")
|
||||
checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "")
|
||||
checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "")
|
||||
checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != "")
|
||||
if isManaged && db != nil {
|
||||
checkDBChannels(db)
|
||||
} else {
|
||||
checkChannel("Telegram", cfg.Channels.Telegram.Enabled, cfg.Channels.Telegram.Token != "")
|
||||
checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "")
|
||||
checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "")
|
||||
checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "")
|
||||
checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != "")
|
||||
}
|
||||
|
||||
// External tools
|
||||
fmt.Println()
|
||||
@@ -106,6 +162,66 @@ func checkChannel(name string, enabled, hasCredentials bool) {
|
||||
fmt.Printf(" %-12s %s\n", name+":", status)
|
||||
}
|
||||
|
||||
func checkDBChannels(db *sql.DB) {
|
||||
rows, err := db.QueryContext(context.Background(),
|
||||
"SELECT name, channel_type, enabled FROM channel_instances ORDER BY channel_type, name")
|
||||
if err != nil {
|
||||
fmt.Printf(" (could not query channels: %s)\n", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var name, channelType string
|
||||
var enabled bool
|
||||
if err := rows.Scan(&name, &channelType, &enabled); err != nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
status := "enabled"
|
||||
if !enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
label := fmt.Sprintf("%s/%s", channelType, name)
|
||||
fmt.Printf(" %-24s %s\n", label+":", status)
|
||||
}
|
||||
if !found {
|
||||
fmt.Println(" (none configured in database)")
|
||||
}
|
||||
}
|
||||
|
||||
func checkDBProviders(db *sql.DB) {
|
||||
rows, err := db.QueryContext(context.Background(),
|
||||
"SELECT name, COALESCE(display_name, name), enabled, (api_key IS NOT NULL AND api_key != '') AS has_key FROM llm_providers ORDER BY name")
|
||||
if err != nil {
|
||||
fmt.Printf(" (could not query providers: %s)\n", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var name, displayName string
|
||||
var enabled, hasKey bool
|
||||
if err := rows.Scan(&name, &displayName, &enabled, &hasKey); err != nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
status := "enabled"
|
||||
if !enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
if !hasKey {
|
||||
status += " (no API key)"
|
||||
}
|
||||
fmt.Printf(" %-16s %s\n", displayName+":", status)
|
||||
}
|
||||
if !found {
|
||||
fmt.Println(" (none configured in database)")
|
||||
}
|
||||
}
|
||||
|
||||
func checkBinary(name string) {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
|
||||
+7
-1
@@ -306,6 +306,12 @@ func runGateway() {
|
||||
var traceCollector *tracing.Collector
|
||||
|
||||
if cfg.Database.Mode == "managed" && cfg.Database.PostgresDSN != "" {
|
||||
// Schema compatibility check: ensure DB schema matches this binary.
|
||||
if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil {
|
||||
slog.Error("schema compatibility check failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
storeCfg := store.StoreConfig{
|
||||
PostgresDSN: cfg.Database.PostgresDSN,
|
||||
Mode: cfg.Database.Mode,
|
||||
@@ -872,7 +878,7 @@ func runGateway() {
|
||||
gatewayMode = "managed"
|
||||
}
|
||||
slog.Info("goclaw gateway starting",
|
||||
"version", "0.2.0",
|
||||
"version", Version,
|
||||
"protocol", protocol.ProtocolVersion,
|
||||
"mode", gatewayMode,
|
||||
"agents", agentRouter.List(),
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -19,23 +20,43 @@ func builtinToolSeedData() []store.BuiltinToolDef {
|
||||
{Name: "edit", DisplayName: "Edit File", Description: "Apply targeted edits to files (search and replace)", Category: "filesystem", Enabled: true},
|
||||
|
||||
// runtime
|
||||
{Name: "exec", DisplayName: "Execute Command", Description: "Execute shell commands in the workspace", Category: "runtime", Enabled: true},
|
||||
{Name: "exec", DisplayName: "Execute Command", Description: "Execute shell commands in the workspace", Category: "runtime", Enabled: true,
|
||||
Metadata: json.RawMessage(`{"config_hint":"Config → Tools → Exec Approval"}`),
|
||||
},
|
||||
|
||||
// web
|
||||
{Name: "web_search", DisplayName: "Web Search", Description: "Search the web using Brave or DuckDuckGo", Category: "web", Enabled: true},
|
||||
{Name: "web_search", DisplayName: "Web Search", Description: "Search the web using Brave or DuckDuckGo", Category: "web", Enabled: true,
|
||||
Metadata: json.RawMessage(`{"config_hint":"Config → Tools → Web Search"}`),
|
||||
},
|
||||
{Name: "web_fetch", DisplayName: "Web Fetch", Description: "Fetch and extract content from web URLs", Category: "web", Enabled: true},
|
||||
|
||||
// memory
|
||||
{Name: "memory_search", DisplayName: "Memory Search", Description: "Search through stored memory entries", Category: "memory", Enabled: true},
|
||||
{Name: "memory_get", DisplayName: "Memory Get", Description: "Retrieve a specific memory entry by key", Category: "memory", Enabled: true},
|
||||
{Name: "memory_search", DisplayName: "Memory Search", Description: "Search through stored memory entries", Category: "memory", Enabled: true,
|
||||
Requires: []string{"memory"},
|
||||
},
|
||||
{Name: "memory_get", DisplayName: "Memory Get", Description: "Retrieve a specific memory entry by key", Category: "memory", Enabled: true,
|
||||
Requires: []string{"memory"},
|
||||
},
|
||||
|
||||
// media
|
||||
{Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: true},
|
||||
{Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: true},
|
||||
{Name: "tts", DisplayName: "Text to Speech", Description: "Convert text to speech audio", Category: "media", Enabled: true},
|
||||
{Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: true,
|
||||
Settings: json.RawMessage(`{"provider":"openrouter","model":"google/gemini-2.5-flash-image"}`),
|
||||
Requires: []string{"vision_provider"},
|
||||
},
|
||||
{Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: true,
|
||||
Settings: json.RawMessage(`{"provider":"openrouter","model":"google/gemini-2.5-flash-image"}`),
|
||||
Requires: []string{"image_gen_provider"},
|
||||
},
|
||||
{Name: "tts", DisplayName: "Text to Speech", Description: "Convert text to speech audio", Category: "media", Enabled: true,
|
||||
Requires: []string{"tts_provider"},
|
||||
Metadata: json.RawMessage(`{"config_hint":"Config → TTS"}`),
|
||||
},
|
||||
|
||||
// browser
|
||||
{Name: "browser", DisplayName: "Browser", Description: "Automate browser interactions (navigate, click, screenshot)", Category: "browser", Enabled: true},
|
||||
{Name: "browser", DisplayName: "Browser", Description: "Automate browser interactions (navigate, click, screenshot)", Category: "browser", Enabled: true,
|
||||
Requires: []string{"browser"},
|
||||
Metadata: json.RawMessage(`{"config_hint":"Config → Tools → Browser"}`),
|
||||
},
|
||||
|
||||
// sessions
|
||||
{Name: "sessions_list", DisplayName: "List Sessions", Description: "List active chat sessions", Category: "sessions", Enabled: true},
|
||||
@@ -47,24 +68,42 @@ func builtinToolSeedData() []store.BuiltinToolDef {
|
||||
{Name: "message", DisplayName: "Message", Description: "Send messages to connected channels (Telegram, Discord, etc.)", Category: "messaging", Enabled: true},
|
||||
|
||||
// scheduling
|
||||
{Name: "cron", DisplayName: "Cron Scheduler", Description: "Schedule recurring tasks with cron expressions", Category: "scheduling", Enabled: true},
|
||||
{Name: "cron", DisplayName: "Cron Scheduler", Description: "Schedule recurring tasks with cron expressions", Category: "scheduling", Enabled: true,
|
||||
Metadata: json.RawMessage(`{"config_hint":"Config → Cron"}`),
|
||||
},
|
||||
|
||||
// subagents
|
||||
{Name: "spawn", DisplayName: "Spawn Subagent", Description: "Spawn an asynchronous background subagent", Category: "subagents", Enabled: true},
|
||||
{Name: "subagent", DisplayName: "Subagent", Description: "Run a synchronous subagent and wait for result", Category: "subagents", Enabled: true},
|
||||
{Name: "spawn", DisplayName: "Spawn Subagent", Description: "Spawn an asynchronous background subagent", Category: "subagents", Enabled: true,
|
||||
Metadata: json.RawMessage(`{"config_hint":"Config → Agents Defaults"}`),
|
||||
},
|
||||
{Name: "subagent", DisplayName: "Subagent", Description: "Run a synchronous subagent and wait for result", Category: "subagents", Enabled: true,
|
||||
Metadata: json.RawMessage(`{"config_hint":"Config → Agents Defaults"}`),
|
||||
},
|
||||
|
||||
// skills
|
||||
{Name: "skill_search", DisplayName: "Skill Search", Description: "Search available skills by keyword or description", Category: "skills", Enabled: true},
|
||||
|
||||
// delegation
|
||||
{Name: "delegate", DisplayName: "Delegate", Description: "Delegate a task to another agent", Category: "delegation", Enabled: true},
|
||||
{Name: "delegate_search", DisplayName: "Delegate Search", Description: "Search for agents to delegate tasks to", Category: "delegation", Enabled: true},
|
||||
{Name: "evaluate_loop", DisplayName: "Evaluate Loop", Description: "Run an evaluate-optimize loop with delegated agents", Category: "delegation", Enabled: true},
|
||||
{Name: "handoff", DisplayName: "Handoff", Description: "Transfer conversation to another agent", Category: "delegation", Enabled: true},
|
||||
{Name: "delegate", DisplayName: "Delegate", Description: "Delegate a task to another agent", Category: "delegation", Enabled: true,
|
||||
Requires: []string{"managed_mode", "agent_links"},
|
||||
},
|
||||
{Name: "delegate_search", DisplayName: "Delegate Search", Description: "Search for agents to delegate tasks to", Category: "delegation", Enabled: true,
|
||||
Requires: []string{"managed_mode", "agent_links"},
|
||||
},
|
||||
{Name: "evaluate_loop", DisplayName: "Evaluate Loop", Description: "Run an evaluate-optimize loop with delegated agents", Category: "delegation", Enabled: true,
|
||||
Requires: []string{"managed_mode", "agent_links"},
|
||||
},
|
||||
{Name: "handoff", DisplayName: "Handoff", Description: "Transfer conversation to another agent", Category: "delegation", Enabled: true,
|
||||
Requires: []string{"managed_mode", "agent_links"},
|
||||
},
|
||||
|
||||
// teams
|
||||
{Name: "team_tasks", DisplayName: "Team Tasks", Description: "Manage tasks within a team of agents", Category: "teams", Enabled: true},
|
||||
{Name: "team_message", DisplayName: "Team Message", Description: "Send messages between team agents", Category: "teams", Enabled: true},
|
||||
{Name: "team_tasks", DisplayName: "Team Tasks", Description: "Manage tasks within a team of agents", Category: "teams", Enabled: true,
|
||||
Requires: []string{"managed_mode", "teams"},
|
||||
},
|
||||
{Name: "team_message", DisplayName: "Team Message", Description: "Send messages between team agents", Category: "teams", Enabled: true,
|
||||
Requires: []string{"managed_mode", "teams"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -10,9 +12,11 @@ import (
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/upgrade"
|
||||
)
|
||||
|
||||
var migrationsDir string
|
||||
@@ -95,6 +99,22 @@ func migrateUpCmd() *cobra.Command {
|
||||
|
||||
v, dirty, _ := m.Version()
|
||||
slog.Info("migration complete", "version", v, "dirty", dirty)
|
||||
|
||||
// Run pending data hooks after SQL migrations.
|
||||
db, dbErr := sql.Open("pgx", dsn)
|
||||
if dbErr != nil {
|
||||
slog.Warn("could not connect for data hooks", "error", dbErr)
|
||||
return nil
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
count, hookErr := upgrade.RunPendingHooks(context.Background(), db)
|
||||
if hookErr != nil {
|
||||
slog.Warn("data hooks failed", "error", hookErr)
|
||||
} else if count > 0 {
|
||||
slog.Info("data hooks applied", "count", count)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ func init() {
|
||||
rootCmd.AddCommand(skillsCmd())
|
||||
rootCmd.AddCommand(sessionsCmd())
|
||||
rootCmd.AddCommand(migrateCmd())
|
||||
rootCmd.AddCommand(upgradeCmd())
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/upgrade"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
func upgradeCmd() *cobra.Command {
|
||||
var dryRun bool
|
||||
var status bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrade database schema and run data migrations",
|
||||
Long: "Applies pending SQL migrations and Go-based data hooks. Safe to run multiple times (idempotent).",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if status {
|
||||
return runUpgradeStatus()
|
||||
}
|
||||
return runUpgrade(dryRun)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be done without applying changes")
|
||||
cmd.Flags().BoolVar(&status, "status", false, "show current upgrade status")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runUpgradeStatus() error {
|
||||
cfg, err := config.Load(resolveConfigPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" App version: %s (protocol %d)\n", Version, protocol.ProtocolVersion)
|
||||
|
||||
if cfg.Database.Mode != "managed" || cfg.Database.PostgresDSN == "" {
|
||||
fmt.Println(" Mode: standalone (no database)")
|
||||
fmt.Println(" Status: N/A (no schema migrations needed)")
|
||||
return nil
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", cfg.Database.PostgresDSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
s, err := upgrade.CheckSchema(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check schema: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" Schema current: %d\n", s.CurrentVersion)
|
||||
fmt.Printf(" Schema required: %d\n", s.RequiredVersion)
|
||||
|
||||
if s.Dirty {
|
||||
fmt.Println(" Status: DIRTY (failed migration)")
|
||||
fmt.Println()
|
||||
fmt.Print(upgrade.FormatError(s))
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.Compatible {
|
||||
fmt.Println(" Status: UP TO DATE")
|
||||
} else if s.CurrentVersion > s.RequiredVersion {
|
||||
fmt.Println(" Status: BINARY TOO OLD")
|
||||
} else {
|
||||
fmt.Printf(" Status: UPGRADE NEEDED (%d -> %d)\n", s.CurrentVersion, s.RequiredVersion)
|
||||
}
|
||||
|
||||
// Check pending data hooks.
|
||||
pending, err := upgrade.PendingHooks(context.Background(), db)
|
||||
if err != nil {
|
||||
slog.Debug("could not check pending data hooks", "error", err)
|
||||
} else if len(pending) > 0 {
|
||||
fmt.Printf("\n Pending data hooks: %d\n", len(pending))
|
||||
for _, name := range pending {
|
||||
fmt.Printf(" - %s\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
if s.NeedsMigration {
|
||||
fmt.Println()
|
||||
fmt.Println(" Run 'goclaw upgrade' to apply all pending changes.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runUpgrade(dryRun bool) error {
|
||||
cfg, err := config.Load(resolveConfigPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Database.Mode != "managed" || cfg.Database.PostgresDSN == "" {
|
||||
fmt.Println("Standalone mode — no database migrations needed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
dsn := cfg.Database.PostgresDSN
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
s, err := upgrade.CheckSchema(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check schema: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" App version: %s (protocol %d)\n", Version, protocol.ProtocolVersion)
|
||||
fmt.Printf(" Schema current: %d\n", s.CurrentVersion)
|
||||
fmt.Printf(" Schema required: %d\n", s.RequiredVersion)
|
||||
fmt.Println()
|
||||
|
||||
if s.Dirty {
|
||||
fmt.Print(upgrade.FormatError(s))
|
||||
return ErrUpgradeFailed
|
||||
}
|
||||
if s.CurrentVersion > s.RequiredVersion {
|
||||
fmt.Print(upgrade.FormatError(s))
|
||||
return ErrUpgradeFailed
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
if s.NeedsMigration {
|
||||
fmt.Printf(" Would apply SQL migrations: v%d -> v%d\n", s.CurrentVersion, s.RequiredVersion)
|
||||
} else {
|
||||
fmt.Println(" SQL schema is up to date.")
|
||||
}
|
||||
|
||||
pending, err := upgrade.PendingHooks(context.Background(), db)
|
||||
if err != nil {
|
||||
slog.Debug("could not check pending data hooks", "error", err)
|
||||
} else if len(pending) > 0 {
|
||||
fmt.Printf(" Would run %d data hook(s):\n", len(pending))
|
||||
for _, name := range pending {
|
||||
fmt.Printf(" - %s\n", name)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" No pending data hooks.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply SQL migrations.
|
||||
if s.NeedsMigration {
|
||||
fmt.Print(" Applying SQL migrations... ")
|
||||
m, err := newMigrator(dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migrator: %w", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
fmt.Println("FAILED")
|
||||
return fmt.Errorf("migrate up: %w", err)
|
||||
}
|
||||
v, _, _ := m.Version()
|
||||
fmt.Printf("OK (v%d -> v%d)\n", s.CurrentVersion, v)
|
||||
} else {
|
||||
fmt.Println(" SQL schema is up to date.")
|
||||
}
|
||||
|
||||
// Run data hooks.
|
||||
fmt.Print(" Running data hooks... ")
|
||||
count, err := upgrade.RunPendingHooks(context.Background(), db)
|
||||
if err != nil {
|
||||
fmt.Println("FAILED")
|
||||
return fmt.Errorf("data hooks: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
fmt.Printf("%d applied\n", count)
|
||||
} else {
|
||||
fmt.Println("none pending")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Upgrade complete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrUpgradeFailed is returned when upgrade cannot proceed.
|
||||
var ErrUpgradeFailed = fmt.Errorf("upgrade cannot proceed")
|
||||
|
||||
// checkSchemaOrAutoUpgrade is called from gateway startup to gate on schema compatibility.
|
||||
// If GOCLAW_AUTO_UPGRADE=true and schema is outdated, it runs the upgrade inline.
|
||||
func checkSchemaOrAutoUpgrade(dsn string) error {
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema check: connect: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return fmt.Errorf("schema check: ping: %w", err)
|
||||
}
|
||||
|
||||
s, err := upgrade.CheckSchema(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema check: %w", err)
|
||||
}
|
||||
|
||||
if s.Compatible {
|
||||
slog.Info("schema check passed", "current", s.CurrentVersion, "required", s.RequiredVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.Dirty {
|
||||
return errors.New(upgrade.FormatError(s))
|
||||
}
|
||||
|
||||
if s.CurrentVersion > s.RequiredVersion {
|
||||
return errors.New(upgrade.FormatError(s))
|
||||
}
|
||||
|
||||
// Schema is outdated — check if auto-upgrade is enabled.
|
||||
if os.Getenv("GOCLAW_AUTO_UPGRADE") == "true" {
|
||||
slog.Info("auto-upgrade: applying migrations", "from", s.CurrentVersion, "to", s.RequiredVersion)
|
||||
|
||||
m, mErr := newMigrator(dsn)
|
||||
if mErr != nil {
|
||||
return fmt.Errorf("auto-upgrade: create migrator: %w", mErr)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
if mErr := m.Up(); mErr != nil && mErr != migrate.ErrNoChange {
|
||||
return fmt.Errorf("auto-upgrade: migrate up: %w", mErr)
|
||||
}
|
||||
|
||||
v, _, _ := m.Version()
|
||||
slog.Info("auto-upgrade: SQL migrations applied", "version", v)
|
||||
|
||||
// Run data hooks.
|
||||
count, hErr := upgrade.RunPendingHooks(context.Background(), db)
|
||||
if hErr != nil {
|
||||
return fmt.Errorf("auto-upgrade: data hooks: %w", hErr)
|
||||
}
|
||||
if count > 0 {
|
||||
slog.Info("auto-upgrade: data hooks applied", "count", count)
|
||||
}
|
||||
|
||||
slog.Info("auto-upgrade complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New(upgrade.FormatError(s))
|
||||
}
|
||||
Reference in new issue
Block a user