mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-10 06:20:43 +00:00
feat: Introduce a new upgrade command and enhance built-in tool settings with provider and model configuration.
This commit is contained in:
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
// })
|
||||
// }
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user