diff --git a/internal/store/builtin_tool_store.go b/internal/store/builtin_tool_store.go index fa30ef8a4..e62f8c467 100644 --- a/internal/store/builtin_tool_store.go +++ b/internal/store/builtin_tool_store.go @@ -29,6 +29,9 @@ type BuiltinToolStore interface { Get(ctx context.Context, name string) (*BuiltinToolDef, error) Update(ctx context.Context, name string, updates map[string]any) error Seed(ctx context.Context, tools []BuiltinToolDef) error + // Upsert inserts/updates tools without reconcile — safe for additive fork-specific tools. + // Does NOT delete rows not in the provided list. + Upsert(ctx context.Context, tools []BuiltinToolDef) error ListEnabled(ctx context.Context) ([]BuiltinToolDef, error) GetSettings(ctx context.Context, name string) (json.RawMessage, error) } diff --git a/internal/store/pg/builtin_tools.go b/internal/store/pg/builtin_tools.go index 8e6a7aef3..553beb185 100644 --- a/internal/store/pg/builtin_tools.go +++ b/internal/store/pg/builtin_tools.go @@ -159,6 +159,57 @@ func (s *PGBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinTool return tx.Commit() } +// Upsert inserts or updates builtin tool definitions without reconcile DELETE. +// Safe for additive fork-specific tools -- does NOT delete rows not in the list. +func (s *PGBuiltinToolStore) Upsert(ctx context.Context, tools []store.BuiltinToolDef) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, + `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, + settings = CASE + WHEN builtin_tools.settings IS NULL OR builtin_tools.settings::text IN ('{}', 'null') + THEN EXCLUDED.settings + ELSE builtin_tools.settings + END, + updated_at = EXCLUDED.updated_at`) + if err != nil { + return fmt.Errorf("prepare upsert stmt: %w", err) + } + defer stmt.Close() + + now := time.Now() + for _, t := range tools { + settings := t.Settings + 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), []byte(metadata), now, + ) + if err != nil { + return fmt.Errorf("upsert tool %s: %w", t.Name, err) + } + } + + return tx.Commit() +} + func (s *PGBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, error) { var def store.BuiltinToolDef var settings []byte diff --git a/internal/store/sqlitestore/builtin-tools.go b/internal/store/sqlitestore/builtin-tools.go index 33e603e07..3cd541dfe 100644 --- a/internal/store/sqlitestore/builtin-tools.go +++ b/internal/store/sqlitestore/builtin-tools.go @@ -168,6 +168,57 @@ func (s *SQLiteBuiltinToolStore) Seed(ctx context.Context, tools []store.Builtin return tx.Commit() } +// Upsert inserts or updates builtin tool definitions without reconcile DELETE. +// Safe for additive fork-specific tools -- does NOT delete rows not in the list. +func (s *SQLiteBuiltinToolStore) Upsert(ctx context.Context, tools []store.BuiltinToolDef) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, + `INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + category = excluded.category, + requires = excluded.requires, + metadata = excluded.metadata, + settings = CASE + WHEN builtin_tools.settings IS NULL OR builtin_tools.settings IN ('{}', 'null') + THEN excluded.settings + ELSE builtin_tools.settings + END, + updated_at = excluded.updated_at`) + if err != nil { + return fmt.Errorf("prepare upsert stmt: %w", err) + } + defer stmt.Close() + + now := time.Now() + for _, t := range tools { + settings := t.Settings + 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), jsonStringArray(t.Requires), []byte(metadata), now, now, + ) + if err != nil { + return fmt.Errorf("upsert tool %s: %w", t.Name, err) + } + } + + return tx.Commit() +} + func (s *SQLiteBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, error) { var def store.BuiltinToolDef var settings []byte