feat: Introduce a new upgrade command and enhance built-in tool settings with provider and model configuration.

This commit is contained in:
viettranx
2026-02-27 11:38:04 +07:00
parent d65d792646
commit 86d58e1021
23 changed files with 1195 additions and 170 deletions
+32 -4
View File
@@ -452,8 +452,8 @@ export GOCLAW_MODE=managed
export GOCLAW_POSTGRES_DSN="postgres://user:pass@localhost:5432/goclaw?sslmode=disable"
export GOCLAW_ENCRYPTION_KEY=$(openssl rand -hex 32)
# Run migrations
./goclaw migrate up
# Run database upgrade (schema migrations + data hooks)
./goclaw upgrade
# Start gateway
./goclaw
@@ -660,7 +660,13 @@ When `GOCLAW_*_API_KEY` environment variables are set, the gateway automatically
goclaw Start gateway (default command)
goclaw onboard Interactive setup wizard
goclaw version Print version and protocol info
goclaw doctor System health check
goclaw doctor System health check (includes schema status)
In managed mode: reads providers and channels from DB
In standalone mode: reads from config.json + env vars
goclaw upgrade Upgrade database schema and run data hooks
goclaw upgrade --status Show current vs required schema version
goclaw upgrade --dry-run Preview pending changes without applying
goclaw agent list List configured agents
goclaw agent chat Chat with an agent
@@ -712,13 +718,14 @@ See [WebSocket Protocol](websocket-protocol.md) for the real-time RPC protocol (
## Docker Compose
Seven composable files for different deployment scenarios:
Eight composable files for different deployment scenarios:
| File | Purpose |
| -------------------------------- | -------------------------------------------------- |
| `docker-compose.yml` | Base service definition |
| `docker-compose.standalone.yml` | File-based storage with persistent volumes |
| `docker-compose.managed.yml` | PostgreSQL (pgvector/pgvector:pg18) + managed mode |
| `docker-compose.upgrade.yml` | One-shot database upgrade service |
| `docker-compose.selfservice.yml` | Web dashboard UI (nginx + React SPA) |
| `docker-compose.sandbox.yml` | Docker-based code execution sandbox |
| `docker-compose.otel.yml` | OpenTelemetry + Jaeger tracing |
@@ -756,6 +763,27 @@ docker compose -f docker-compose.yml \
curl http://localhost:18790/health
```
### Upgrading (Managed Mode)
When upgrading to a new version, the entrypoint automatically runs `goclaw upgrade` before starting. For explicit control, use the upgrade overlay:
```bash
# Preview pending changes (dry-run)
docker compose -f docker-compose.yml -f docker-compose.managed.yml \
-f docker-compose.upgrade.yml run --rm upgrade --dry-run
# Apply upgrade (schema migrations + data hooks), then remove container
docker compose -f docker-compose.yml -f docker-compose.managed.yml \
-f docker-compose.upgrade.yml run --rm upgrade
# Check current schema status
docker compose -f docker-compose.yml -f docker-compose.managed.yml \
-f docker-compose.upgrade.yml run --rm upgrade --status
# Then restart the gateway with the new image
docker compose -f docker-compose.yml -f docker-compose.managed.yml up -d --build
```
### Environment File (.env)
Use the `prepare-env.sh` script to generate `.env` with auto-generated secrets:
+132 -16
View File
@@ -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
View File
@@ -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(),
+56 -17
View File
@@ -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"},
},
}
}
+20
View File
@@ -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
},
}
+1
View File
@@ -42,6 +42,7 @@ func init() {
rootCmd.AddCommand(skillsCmd())
rootCmd.AddCommand(sessionsCmd())
rootCmd.AddCommand(migrateCmd())
rootCmd.AddCommand(upgradeCmd())
}
func versionCmd() *cobra.Command {
+265
View File
@@ -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))
}
+38
View File
@@ -0,0 +1,38 @@
# Upgrade overlay — one-shot database upgrade service.
#
# Usage:
# # Preview changes (dry-run):
# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.upgrade.yml run --rm upgrade --dry-run
#
# # Apply upgrade:
# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.upgrade.yml run --rm upgrade
#
# # Check status:
# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.upgrade.yml run --rm upgrade --status
#
# The upgrade service runs goclaw upgrade and exits. Use --rm to auto-remove the container.
services:
upgrade:
build:
context: .
dockerfile: Dockerfile
args:
ENABLE_OTEL: "false"
env_file:
- path: .env
required: false
environment:
- GOCLAW_MODE=managed
- GOCLAW_POSTGRES_DSN=postgres://${POSTGRES_USER:-goclaw}:${POSTGRES_PASSWORD:-goclaw}@postgres:5432/${POSTGRES_DB:-goclaw}?sslmode=disable
- GOCLAW_CONFIG=/app/data/config.json
- GOCLAW_MIGRATIONS_DIR=/app/migrations
- GOCLAW_ENCRYPTION_KEY=${GOCLAW_ENCRYPTION_KEY:-}
volumes:
- goclaw-data:/app/data
depends_on:
postgres:
condition: service_healthy
entrypoint: ["/app/goclaw"]
command: ["upgrade"]
restart: "no"
+8 -4
View File
@@ -3,14 +3,18 @@ set -e
case "${1:-serve}" in
serve)
# Managed mode: auto-run migrations before starting
# Managed mode: auto-upgrade (schema migrations + data hooks) before starting.
if [ "$GOCLAW_MODE" = "managed" ] && [ -n "$GOCLAW_POSTGRES_DSN" ]; then
echo "Managed mode: running migrations..."
/app/goclaw migrate up --migrations-dir "$GOCLAW_MIGRATIONS_DIR" || \
echo "Migration warning (may already be up-to-date)"
echo "Managed mode: running upgrade..."
/app/goclaw upgrade || \
echo "Upgrade warning (may already be up-to-date)"
fi
exec /app/goclaw
;;
upgrade)
shift
exec /app/goclaw upgrade "$@"
;;
migrate)
shift
exec /app/goclaw migrate "$@"
+1
View File
@@ -17,6 +17,7 @@ type BuiltinToolDef struct {
Enabled bool `json:"enabled"`
Settings json.RawMessage `json:"settings"`
Requires []string `json:"requires,omitempty"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+19 -6
View File
@@ -20,7 +20,7 @@ func NewPGBuiltinToolStore(db *sql.DB) *PGBuiltinToolStore {
return &PGBuiltinToolStore{db: db}
}
const builtinToolSelectCols = `name, display_name, description, category, enabled, settings, requires, created_at, updated_at`
const builtinToolSelectCols = `name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at`
func (s *PGBuiltinToolStore) List(ctx context.Context) ([]store.BuiltinToolDef, error) {
rows, err := s.db.QueryContext(ctx,
@@ -108,13 +108,14 @@ func (s *PGBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinTool
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
`INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
ON CONFLICT (name) DO UPDATE SET
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
category = EXCLUDED.category,
requires = EXCLUDED.requires,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at`)
if err != nil {
return fmt.Errorf("prepare seed stmt: %w", err)
@@ -127,9 +128,13 @@ func (s *PGBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinTool
if settings == nil {
settings = json.RawMessage("{}")
}
metadata := t.Metadata
if metadata == nil {
metadata = json.RawMessage("{}")
}
_, err := stmt.ExecContext(ctx,
t.Name, t.DisplayName, t.Description, t.Category,
t.Enabled, []byte(settings), pqStringArray(t.Requires), now,
t.Enabled, []byte(settings), pqStringArray(t.Requires), []byte(metadata), now,
)
if err != nil {
return fmt.Errorf("seed tool %s: %w", t.Name, err)
@@ -143,10 +148,11 @@ func (s *PGBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, erro
var def store.BuiltinToolDef
var settings []byte
var requires []byte
var metadata []byte
err := row.Scan(
&def.Name, &def.DisplayName, &def.Description, &def.Category,
&def.Enabled, &settings, &requires, &def.CreatedAt, &def.UpdatedAt,
&def.Enabled, &settings, &requires, &metadata, &def.CreatedAt, &def.UpdatedAt,
)
if err != nil {
return nil, err
@@ -155,6 +161,9 @@ func (s *PGBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, erro
if settings != nil {
def.Settings = json.RawMessage(settings)
}
if metadata != nil {
def.Metadata = json.RawMessage(metadata)
}
scanStringArray(requires, &def.Requires)
return &def, nil
@@ -167,10 +176,11 @@ func (s *PGBuiltinToolStore) scanTools(rows *sql.Rows) ([]store.BuiltinToolDef,
var def store.BuiltinToolDef
var settings []byte
var requires []byte
var metadata []byte
if err := rows.Scan(
&def.Name, &def.DisplayName, &def.Description, &def.Category,
&def.Enabled, &settings, &requires, &def.CreatedAt, &def.UpdatedAt,
&def.Enabled, &settings, &requires, &metadata, &def.CreatedAt, &def.UpdatedAt,
); err != nil {
continue
}
@@ -178,6 +188,9 @@ func (s *PGBuiltinToolStore) scanTools(rows *sql.Rows) ([]store.BuiltinToolDef,
if settings != nil {
def.Settings = json.RawMessage(settings)
}
if metadata != nil {
def.Metadata = json.RawMessage(metadata)
}
scanStringArray(requires, &def.Requires)
result = append(result, def)
+9 -6
View File
@@ -132,12 +132,15 @@ func (t *CreateImageTool) resolveConfig(ctx context.Context) (providerName, mode
Provider string `json:"provider"`
Model string `json:"model"`
}
if json.Unmarshal(raw, &cfg) == nil {
if providerName == "" && cfg.Provider != "" {
providerName = cfg.Provider
}
if model == "" && cfg.Model != "" {
model = cfg.Model
if json.Unmarshal(raw, &cfg) == nil && cfg.Provider != "" {
// DB settings are a provider+model pair — only use if provider is available
if _, err := t.registry.Get(cfg.Provider); err == nil {
if providerName == "" {
providerName = cfg.Provider
}
if model == "" && cfg.Model != "" {
model = cfg.Model
}
}
}
}
+2 -2
View File
@@ -27,12 +27,12 @@ func MediaImagesFromCtx(ctx context.Context) []providers.ImageContent {
// --- ReadImageTool ---
// visionProviderPriority is the order in which providers are tried for vision.
var visionProviderPriority = []string{"gemini", "anthropic", "openrouter"}
var visionProviderPriority = []string{"openrouter", "gemini", "anthropic"}
// visionModelOverrides maps provider names to preferred vision models.
// Providers not listed here use their default model.
var visionModelOverrides = map[string]string{
"openrouter": "google/gemini-2.0-flash-001",
"openrouter": "google/gemini-2.5-flash-image",
}
// ReadImageTool uses a vision-capable provider to describe images attached to the current message.
+93
View File
@@ -0,0 +1,93 @@
package upgrade
import (
"database/sql"
"errors"
"fmt"
)
// SchemaStatus represents the result of a schema compatibility check.
type SchemaStatus struct {
CurrentVersion uint
RequiredVersion uint
Dirty bool
Compatible bool
NeedsMigration bool
}
var (
ErrSchemaOutdated = errors.New("database schema is outdated")
ErrSchemaDirty = errors.New("database schema is dirty (failed migration)")
ErrSchemaAhead = errors.New("database schema is newer than this binary")
)
// CheckSchema queries the schema_migrations table and compares
// against RequiredSchemaVersion to determine compatibility.
func CheckSchema(db *sql.DB) (*SchemaStatus, error) {
var version uint
var dirty bool
err := db.QueryRow("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&version, &dirty)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return &SchemaStatus{
RequiredVersion: RequiredSchemaVersion,
NeedsMigration: true,
}, nil
}
// Table might not exist (fresh DB).
return &SchemaStatus{
RequiredVersion: RequiredSchemaVersion,
NeedsMigration: true,
}, nil
}
s := &SchemaStatus{
CurrentVersion: version,
RequiredVersion: RequiredSchemaVersion,
Dirty: dirty,
}
if dirty {
return s, nil
}
switch {
case version == RequiredSchemaVersion:
s.Compatible = true
case version < RequiredSchemaVersion:
s.NeedsMigration = true
default:
// Schema is ahead — binary is too old.
}
return s, nil
}
// FormatError returns a user-friendly error message for the given status.
func FormatError(s *SchemaStatus) string {
if s.Dirty {
return fmt.Sprintf(
"Database schema is in a dirty state (version %d).\n"+
"This usually means a migration failed partway.\n\n"+
" Fix: ./goclaw migrate force %d\n"+
" Then: ./goclaw upgrade\n",
s.CurrentVersion, s.CurrentVersion-1,
)
}
if s.CurrentVersion > s.RequiredVersion {
return fmt.Sprintf(
"Database schema (v%d) is newer than this binary (requires v%d).\n"+
"You may be running an older version of goclaw.\n\n"+
" Fix: upgrade your goclaw binary to the latest version.\n",
s.CurrentVersion, s.RequiredVersion,
)
}
return fmt.Sprintf(
"Database schema is outdated: current v%d, required v%d.\n\n"+
" Run: ./goclaw upgrade\n"+
" Or: ./goclaw migrate up (SQL-only, no data hooks)\n\n"+
" Docker/CI: set GOCLAW_AUTO_UPGRADE=true to upgrade automatically on startup.\n",
s.CurrentVersion, s.RequiredVersion,
)
}
+128
View File
@@ -0,0 +1,128 @@
package upgrade
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
)
// DataHookFunc is a Go function that runs after a specific schema version's
// SQL migration has been applied.
type DataHookFunc func(ctx context.Context, db *sql.DB) error
type dataHook struct {
SchemaVersion uint
Name string
Fn DataHookFunc
}
var registry []dataHook
// RegisterDataHook registers a Go data migration hook for a specific schema version.
// Name must be unique across all hooks. Hooks for the same version run in
// registration order.
func RegisterDataHook(schemaVersion uint, name string, fn DataHookFunc) {
registry = append(registry, dataHook{
SchemaVersion: schemaVersion,
Name: name,
Fn: fn,
})
}
// PendingHooks returns the names of data hooks that haven't been applied yet.
func PendingHooks(ctx context.Context, db *sql.DB) ([]string, error) {
if err := ensureDataMigrationsTable(ctx, db); err != nil {
return nil, err
}
applied, err := loadApplied(ctx, db)
if err != nil {
return nil, err
}
var pending []string
for _, hook := range registry {
if !applied[hook.Name] {
pending = append(pending, hook.Name)
}
}
return pending, nil
}
// RunPendingHooks executes all data hooks that haven't been applied yet.
// Each hook is tracked in the data_migrations table to ensure idempotency.
func RunPendingHooks(ctx context.Context, db *sql.DB) (int, error) {
if err := ensureDataMigrationsTable(ctx, db); err != nil {
return 0, fmt.Errorf("ensure data_migrations table: %w", err)
}
applied, err := loadApplied(ctx, db)
if err != nil {
return 0, err
}
count := 0
for _, hook := range registry {
if applied[hook.Name] {
continue
}
slog.Info("running data migration hook",
"name", hook.Name,
"schema_version", hook.SchemaVersion,
)
start := time.Now()
if err := hook.Fn(ctx, db); err != nil {
return count, fmt.Errorf("data hook %q failed: %w", hook.Name, err)
}
// Record completion.
_, err := db.ExecContext(ctx,
"INSERT INTO data_migrations (name, version, applied_at) VALUES ($1, $2, NOW())",
hook.Name, hook.SchemaVersion,
)
if err != nil {
return count, fmt.Errorf("record hook %q: %w", hook.Name, err)
}
slog.Info("data migration hook complete",
"name", hook.Name,
"duration", time.Since(start),
)
count++
}
return count, nil
}
func ensureDataMigrationsTable(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS data_migrations (
name VARCHAR(255) PRIMARY KEY,
version INT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
return err
}
func loadApplied(ctx context.Context, db *sql.DB) (map[string]bool, error) {
rows, err := db.QueryContext(ctx, "SELECT name FROM data_migrations")
if err != nil {
return nil, fmt.Errorf("query data_migrations: %w", err)
}
defer rows.Close()
applied := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
applied[name] = true
}
return applied, rows.Err()
}
+13
View File
@@ -0,0 +1,13 @@
package upgrade
// Data migration hooks are registered here.
// Add new hooks when a schema migration requires Go-based data transformation.
//
// Example:
//
// func init() {
// RegisterDataHook(8, "008_backfill_agent_slugs", func(ctx context.Context, db *sql.DB) error {
// // transform data after migration 000008 is applied
// return nil
// })
// }
+5
View File
@@ -0,0 +1,5 @@
package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 6
+1
View File
@@ -1,2 +1,3 @@
ALTER TABLE custom_tools DROP COLUMN IF EXISTS metadata;
DROP INDEX IF EXISTS idx_builtin_tools_category;
DROP TABLE IF EXISTS builtin_tools;
+4
View File
@@ -6,8 +6,12 @@ CREATE TABLE IF NOT EXISTS builtin_tools (
enabled BOOLEAN NOT NULL DEFAULT true,
settings JSONB NOT NULL DEFAULT '{}',
requires TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_builtin_tools_category ON builtin_tools(category);
-- Add metadata column to custom_tools for future extensibility
ALTER TABLE custom_tools ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}';
@@ -1,13 +1,26 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Combobox } from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import type { BuiltinToolData } from "./hooks/use-builtin-tools";
interface Props {
@@ -17,7 +30,166 @@ interface Props {
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>;
}
const MEDIA_TOOLS = new Set(["read_image", "create_image"]);
export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }: Props) {
const isMedia = tool ? MEDIA_TOOLS.has(tool.name) : false;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
{isMedia && tool ? (
<MediaSettingsForm tool={tool} onOpenChange={onOpenChange} onSave={onSave} />
) : (
<JsonSettingsForm tool={tool} onOpenChange={onOpenChange} onSave={onSave} />
)}
</DialogContent>
</Dialog>
);
}
function MediaSettingsForm({
tool,
onOpenChange,
onSave,
}: {
tool: BuiltinToolData;
onOpenChange: (open: boolean) => void;
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>;
}) {
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
const settings = tool.settings ?? {};
const [provider, setProvider] = useState((settings.provider as string) ?? "");
const [model, setModel] = useState((settings.model as string) ?? "");
const [saving, setSaving] = useState(false);
// Resolve provider name → id for model list and verify
const selectedProviderId = useMemo(
() => enabledProviders.find((p) => p.name === provider)?.id,
[enabledProviders, provider],
);
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
useEffect(() => {
const s = tool.settings ?? {};
setProvider((s.provider as string) ?? "");
setModel((s.model as string) ?? "");
}, [tool]);
useEffect(() => {
resetVerify();
}, [provider, model, resetVerify]);
const handleVerify = async () => {
if (!selectedProviderId || !model.trim()) return;
await verify(selectedProviderId, model.trim());
};
const handleSave = async () => {
setSaving(true);
try {
const next: Record<string, unknown> = {};
if (provider) next.provider = provider;
if (model) next.model = model;
await onSave(tool.name, next);
onOpenChange(false);
} finally {
setSaving(false);
}
};
return (
<>
<DialogHeader>
<DialogTitle>{tool.display_name} Settings</DialogTitle>
<DialogDescription>
Configure the LLM provider and model. Leave empty for system defaults.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Provider</Label>
{enabledProviders.length > 0 ? (
<Select
value={provider}
onValueChange={(v) => {
setProvider(v);
setModel("");
}}
>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Combobox
value={provider}
onChange={setProvider}
options={[]}
placeholder="No providers configured"
/>
)}
</div>
<div className="space-y-2">
<Label>Model</Label>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={setModel}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 shrink-0 px-3"
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : "Check"}
</Button>
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-emerald-500" : "text-red-500"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</>
);
}
function JsonSettingsForm({
tool,
onOpenChange,
onSave,
}: {
tool: BuiltinToolData | null;
onOpenChange: (open: boolean) => void;
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>;
}) {
const [json, setJson] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
@@ -45,34 +217,27 @@ export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }:
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Settings: {tool?.display_name ?? tool?.name}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Edit the JSON settings for this tool. For media tools, you can set{" "}
<code className="text-xs">provider</code> and <code className="text-xs">model</code>.
</p>
<Textarea
value={json}
onChange={(e) => setJson(e.target.value)}
rows={10}
className="font-mono text-sm"
placeholder='{"provider": "gemini", "model": "gemini-2.0-flash"}'
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<>
<DialogHeader>
<DialogTitle>Settings: {tool?.display_name ?? tool?.name}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Textarea
value={json}
onChange={(e) => setJson(e.target.value)}
rows={10}
className="font-mono text-sm"
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</>
);
}
@@ -1,18 +1,50 @@
import { useState, useEffect } from "react";
import { Package, RefreshCw, Settings } from "lucide-react";
import { useState } from "react";
import { Package, RefreshCw, Settings, Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { useBuiltinTools, type BuiltinToolData } from "./hooks/use-builtin-tools";
import { BuiltinToolSettingsDialog } from "./builtin-tool-settings-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { usePagination } from "@/hooks/use-pagination";
/* eslint-disable @typescript-eslint/no-explicit-any */
const CATEGORY_LABELS: Record<string, string> = {
filesystem: "Filesystem",
runtime: "Runtime",
web: "Web",
memory: "Memory",
media: "Media",
browser: "Browser",
sessions: "Sessions",
messaging: "Messaging",
scheduling: "Scheduling",
subagents: "Subagents",
skills: "Skills",
delegation: "Delegation",
teams: "Teams",
};
const CATEGORY_ORDER = Object.keys(CATEGORY_LABELS);
function hasEditableSettings(tool: BuiltinToolData): boolean {
return tool.settings != null && Object.keys(tool.settings).length > 0;
}
function getConfigHint(tool: BuiltinToolData): string | undefined {
return (tool.metadata as any)?.config_hint as string | undefined;
}
export function BuiltinToolsPage() {
const { tools, loading, refresh, updateTool } = useBuiltinTools();
@@ -25,15 +57,18 @@ export function BuiltinToolsPage() {
(t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.display_name.toLowerCase().includes(search.toLowerCase()) ||
t.description.toLowerCase().includes(search.toLowerCase()) ||
t.category.toLowerCase().includes(search.toLowerCase()),
t.description.toLowerCase().includes(search.toLowerCase()),
);
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered, { defaultPageSize: 50 });
useEffect(() => {
resetPage();
}, [search, resetPage]);
const grouped = new Map<string, BuiltinToolData[]>();
for (const tool of filtered) {
const cat = tool.category || "general";
if (!grouped.has(cat)) grouped.set(cat, []);
grouped.get(cat)!.push(tool);
}
const sortedCategories = [...grouped.keys()].sort(
(a, b) => (CATEGORY_ORDER.indexOf(a) ?? 99) - (CATEGORY_ORDER.indexOf(b) ?? 99),
);
const handleToggle = async (tool: BuiltinToolData) => {
await updateTool(tool.name, { enabled: !tool.enabled });
@@ -43,16 +78,11 @@ export function BuiltinToolsPage() {
await updateTool(name, { settings });
};
const hasSettings = (tool: BuiltinToolData) =>
tool.settings && Object.keys(tool.settings).length > 0;
const categories = [...new Set(tools.map((t) => t.category))].sort();
return (
<div className="p-6">
<PageHeader
title="Built-in Tools"
description="Manage system built-in tools. Enable/disable tools or configure their settings globally."
description="Manage system built-in tools. Enable/disable or configure settings globally."
actions={
<Button
variant="outline"
@@ -71,16 +101,16 @@ export function BuiltinToolsPage() {
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by name, description, or category..."
placeholder="Search tools..."
className="max-w-sm"
/>
<div className="text-sm text-muted-foreground">
<span className="text-xs text-muted-foreground">
{filtered.length} tool{filtered.length !== 1 ? "s" : ""}
{categories.length > 0 && ` across ${categories.length} categories`}
</div>
{sortedCategories.length > 0 && ` · ${sortedCategories.length} categories`}
</span>
</div>
<div className="mt-4">
<div className="mt-4 space-y-3">
{showSkeleton ? (
<TableSkeleton rows={8} />
) : filtered.length === 0 ? (
@@ -92,68 +122,15 @@ export function BuiltinToolsPage() {
}
/>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">Description</th>
<th className="px-4 py-3 text-left font-medium">Category</th>
<th className="px-4 py-3 text-center font-medium">Enabled</th>
<th className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{pageItems.map((tool) => (
<tr
key={tool.name}
className="border-b last:border-0 hover:bg-muted/30"
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<span className="font-medium">{tool.display_name}</span>
<span className="ml-2 text-xs text-muted-foreground">{tool.name}</span>
</div>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">
{tool.description || "-"}
</td>
<td className="px-4 py-3">
<Badge variant="outline">{tool.category}</Badge>
</td>
<td className="px-4 py-3 text-center">
<Switch
checked={tool.enabled}
onCheckedChange={() => handleToggle(tool)}
/>
</td>
<td className="px-4 py-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setSettingsTool(tool)}
className="gap-1"
>
<Settings className="h-3.5 w-3.5" />
{hasSettings(tool) ? "Edit" : "Settings"}
</Button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
totalPages={pagination.totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize}
sortedCategories.map((category) => (
<CategoryGroup
key={category}
category={category}
tools={grouped.get(category)!}
onToggle={handleToggle}
onSettings={setSettingsTool}
/>
</div>
))
)}
</div>
@@ -168,3 +145,107 @@ export function BuiltinToolsPage() {
</div>
);
}
function CategoryGroup({
category,
tools,
onToggle,
onSettings,
}: {
category: string;
tools: BuiltinToolData[];
onToggle: (tool: BuiltinToolData) => void;
onSettings: (tool: BuiltinToolData) => void;
}) {
return (
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2">
<span className="text-sm font-medium">{CATEGORY_LABELS[category] ?? category}</span>
<Badge variant="secondary" className="h-5 px-1.5 text-[11px]">
{tools.length}
</Badge>
</div>
<div className="divide-y">
{tools.map((tool) => (
<ToolRow key={tool.name} tool={tool} onToggle={onToggle} onSettings={onSettings} />
))}
</div>
</div>
);
}
function ToolRow({
tool,
onToggle,
onSettings,
}: {
tool: BuiltinToolData;
onToggle: (tool: BuiltinToolData) => void;
onSettings: (tool: BuiltinToolData) => void;
}) {
const configHint = getConfigHint(tool);
const editable = hasEditableSettings(tool);
return (
<div className="flex items-center gap-4 px-4 py-2 hover:bg-muted/30 transition-colors">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-medium leading-tight">{tool.display_name}</span>
<code className="text-[11px] text-muted-foreground">{tool.name}</code>
{tool.requires && tool.requires.length > 0 && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="ml-1 h-4 px-1 text-[10px] leading-none cursor-default">
req
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">Requires: {tool.requires.join(", ")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{tool.description && (
<p className="text-xs text-muted-foreground leading-snug truncate mt-0.5">
{tool.description}
</p>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{editable && (
<Button
variant="ghost"
size="sm"
onClick={() => onSettings(tool)}
className="h-7 gap-1 px-2 text-xs"
>
<Settings className="h-3 w-3" />
Settings
</Button>
)}
{!editable && configHint && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
<Info className="h-3 w-3" />
{configHint}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">Configured via the Config page</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Switch
checked={tool.enabled}
onCheckedChange={() => onToggle(tool)}
/>
</div>
</div>
);
}
@@ -9,6 +9,7 @@ export interface BuiltinToolData {
enabled: boolean;
settings: Record<string, unknown>;
requires: string[];
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
File diff suppressed because one or more lines are too long