01e4917df1
* fix(utility): use claude-acp as default inference agent id The utility-agent SQLite schema, frontend dialog defaults, and edit fallback all pointed at the legacy "claude-code" identifier, but the only registered Claude inference agent is "claude-acp" (since the ACP migration in #566). Opening Settings > Utility Agents and picking Claude resolved to an unknown agent id, so the Model dropdown rendered "no model found" and the agent could not be configured. Switch the defaults to "claude-acp" and migrate existing rows so previously-seeded built-ins (which were saved with agent_id="" and later coerced to "claude-code" by the dialog fallback) now populate their model list correctly. Closes #1269 * fix(utility): cover empty agent_id seeds and add migration test Address PR review feedback on #1271: - Extend the agent_id backfill migration to also rewrite rows seeded with an empty string. seedBuiltinAgents() inserts built-in rows with ON CONFLICT(id) DO NOTHING, so older deployments that landed agent_id="" never got repaired by re-seeding. - Set AgentID = "claude-acp" explicitly in getBuiltinAgents() so future first-run seeds match the schema default without depending on the dialog fallback or the backfill migration. - Add sqlite_migration_test.go covering both the legacy "claude-code" and empty-string backfill paths, plus a seed-contract assertion that every built-in row references "claude-acp" on first run. * fix(utility): clarify migration comment and check rows.Err in test Address PR review feedback (round 2) on #1271: - Reword the agent_id backfill comment as a legacy-data backfill note and point at builtinSeedAgentID as the current seed path, so a future reader doesn't chase the old "seeds insert agent_id=''" behavior that this PR already fixed. - Add rowsxDB.Err() check after the seed iteration loop, before the count guard, so a mid-scan error fails the test instead of silently shrinking the row count. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
362 lines
12 KiB
Go
362 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/utility/models"
|
|
)
|
|
|
|
type sqliteRepository struct {
|
|
db *sqlx.DB // writer
|
|
ro *sqlx.DB // reader
|
|
ownsDB bool
|
|
}
|
|
|
|
var _ Repository = (*sqliteRepository)(nil)
|
|
|
|
func newSQLiteRepositoryWithDB(writer, reader *sqlx.DB) (*sqliteRepository, error) {
|
|
return newSQLiteRepository(writer, reader, false)
|
|
}
|
|
|
|
func newSQLiteRepository(writer, reader *sqlx.DB, ownsDB bool) (*sqliteRepository, error) {
|
|
repo := &sqliteRepository{db: writer, ro: reader, ownsDB: ownsDB}
|
|
if err := repo.initSchema(); err != nil {
|
|
if ownsDB {
|
|
if closeErr := writer.Close(); closeErr != nil {
|
|
return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
func (r *sqliteRepository) initSchema() error {
|
|
// New simplified schema for utility agents
|
|
schema := `
|
|
CREATE TABLE IF NOT EXISTS utility_agents (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
prompt TEXT NOT NULL,
|
|
agent_id TEXT NOT NULL DEFAULT 'claude-acp',
|
|
model TEXT NOT NULL DEFAULT '',
|
|
builtin INTEGER NOT NULL DEFAULT 0,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS utility_agent_calls (
|
|
id TEXT PRIMARY KEY,
|
|
utility_id TEXT NOT NULL,
|
|
session_id TEXT NOT NULL DEFAULT '',
|
|
resolved_prompt TEXT NOT NULL DEFAULT '',
|
|
response TEXT NOT NULL DEFAULT '',
|
|
model TEXT NOT NULL DEFAULT '',
|
|
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
response_tokens INTEGER NOT NULL DEFAULT 0,
|
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
error_message TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP NOT NULL,
|
|
completed_at TIMESTAMP,
|
|
FOREIGN KEY (utility_id) REFERENCES utility_agents(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_utility_calls_utility_id ON utility_agent_calls(utility_id);
|
|
CREATE INDEX IF NOT EXISTS idx_utility_calls_session_id ON utility_agent_calls(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_utility_calls_created_at ON utility_agent_calls(created_at);
|
|
`
|
|
if _, err := r.db.Exec(schema); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add enabled column if it doesn't exist (migration for existing DBs)
|
|
_, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`)
|
|
|
|
// Heal pre-existing custom agents that were created with enabled=0 due
|
|
// to a bug in CreateAgent (the Enabled field on the model defaulted to
|
|
// the zero value, so the INSERT wrote 0 even though the DB schema's
|
|
// own DEFAULT was 1). Built-ins are skipped — they have their own
|
|
// "enabled=false means fall back to user defaults" semantics.
|
|
//
|
|
// Restricted to rows whose updated_at == created_at — i.e. agents that
|
|
// have never been modified since INSERT. UpdateAgent always bumps
|
|
// updated_at, so a user who deliberately disables an agent post-fix
|
|
// has updated_at > created_at and is left alone. Without this guard
|
|
// every restart would silently re-enable any user-disabled custom agent.
|
|
_, _ = r.db.Exec(`UPDATE utility_agents SET enabled = 1
|
|
WHERE builtin = 0 AND enabled = 0
|
|
AND agent_id <> '' AND model <> ''
|
|
AND updated_at = created_at`)
|
|
|
|
// Legacy-data backfill: rewrite the old "claude-code" identifier and any
|
|
// pre-fix builtin rows that were persisted with an empty agent_id to the
|
|
// registered inference agent ID "claude-acp". The Claude agent was
|
|
// renamed during the ACP migration (#566), and seedBuiltinAgents()
|
|
// historically inserted rows with agent_id = "" — combined with its
|
|
// ON CONFLICT(id) DO NOTHING contract, those stale rows survived
|
|
// untouched on every restart. Current seeds now use builtinSeedAgentID
|
|
// (see builtins.go), so this migration only repairs existing deployments.
|
|
_, _ = r.db.Exec(`UPDATE utility_agents SET agent_id = 'claude-acp'
|
|
WHERE agent_id IN ('claude-code', '')`)
|
|
|
|
// Seed built-in agents
|
|
if err := r.seedBuiltinAgents(); err != nil {
|
|
return fmt.Errorf("failed to seed built-in agents: %w", err)
|
|
}
|
|
|
|
// Migration: fix template placeholders from {{.Key}} to {{Key}} format
|
|
if err := r.migrateTemplatePlaceholders(); err != nil {
|
|
return fmt.Errorf("failed to migrate template placeholders: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateTemplatePlaceholders fixes old {{.Key}} format to {{Key}} format in builtin prompts.
|
|
func (r *sqliteRepository) migrateTemplatePlaceholders() error {
|
|
placeholders := []string{
|
|
"GitDiff", "CommitLog", "ChangedFiles", "DiffSummary", "BranchName", "BaseBranch",
|
|
"TaskTitle", "TaskDescription", "SessionID", "WorkspacePath", "UserPrompt",
|
|
}
|
|
for _, p := range placeholders {
|
|
old := "{{." + p + "}}"
|
|
new := "{{" + p + "}}"
|
|
_, _ = r.db.Exec(r.db.Rebind(`UPDATE utility_agents SET prompt = REPLACE(prompt, ?, ?) WHERE builtin = 1`), old, new)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *sqliteRepository) Close() error {
|
|
if !r.ownsDB {
|
|
return nil
|
|
}
|
|
var errs []error
|
|
if err := r.db.Close(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
if r.ro != r.db {
|
|
if err := r.ro.Close(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
if len(errs) > 0 {
|
|
return errs[0]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *sqliteRepository) ListAgents(ctx context.Context) ([]*models.UtilityAgent, error) {
|
|
rows, err := r.ro.QueryContext(ctx, `
|
|
SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at
|
|
FROM utility_agents
|
|
ORDER BY builtin DESC, name ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
return r.scanAgentRows(rows)
|
|
}
|
|
|
|
func (r *sqliteRepository) scanAgentRows(rows *sql.Rows) ([]*models.UtilityAgent, error) {
|
|
var agents []*models.UtilityAgent
|
|
for rows.Next() {
|
|
agent := &models.UtilityAgent{}
|
|
var builtinInt, enabledInt int
|
|
if err := rows.Scan(
|
|
&agent.ID, &agent.Name, &agent.Description, &agent.Prompt,
|
|
&agent.AgentID, &agent.Model, &builtinInt, &enabledInt,
|
|
&agent.CreatedAt, &agent.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
agent.Builtin = builtinInt == 1
|
|
agent.Enabled = enabledInt == 1
|
|
agents = append(agents, agent)
|
|
}
|
|
return agents, rows.Err()
|
|
}
|
|
|
|
func (r *sqliteRepository) GetAgentByID(ctx context.Context, id string) (*models.UtilityAgent, error) {
|
|
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
|
SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at
|
|
FROM utility_agents WHERE id = ?
|
|
`), id)
|
|
return r.scanAgentRow(row)
|
|
}
|
|
|
|
func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*models.UtilityAgent, error) {
|
|
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
|
SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at
|
|
FROM utility_agents WHERE name = ?
|
|
`), name)
|
|
return r.scanAgentRow(row)
|
|
}
|
|
|
|
func (r *sqliteRepository) scanAgentRow(row *sql.Row) (*models.UtilityAgent, error) {
|
|
agent := &models.UtilityAgent{}
|
|
var builtinInt, enabledInt int
|
|
if err := row.Scan(
|
|
&agent.ID, &agent.Name, &agent.Description, &agent.Prompt,
|
|
&agent.AgentID, &agent.Model, &builtinInt, &enabledInt,
|
|
&agent.CreatedAt, &agent.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
agent.Builtin = builtinInt == 1
|
|
agent.Enabled = enabledInt == 1
|
|
return agent, nil
|
|
}
|
|
|
|
func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.UtilityAgent) error {
|
|
if agent.ID == "" {
|
|
agent.ID = uuid.New().String()
|
|
}
|
|
agent.Name = strings.TrimSpace(agent.Name)
|
|
agent.Description = strings.TrimSpace(agent.Description)
|
|
agent.Prompt = strings.TrimSpace(agent.Prompt)
|
|
if agent.CreatedAt.IsZero() {
|
|
agent.CreatedAt = time.Now().UTC()
|
|
}
|
|
agent.UpdatedAt = time.Now().UTC()
|
|
|
|
builtinInt := 0
|
|
if agent.Builtin {
|
|
builtinInt = 1
|
|
}
|
|
enabledInt := 0
|
|
if agent.Enabled {
|
|
enabledInt = 1
|
|
}
|
|
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO utility_agents (id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), agent.ID, agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model,
|
|
builtinInt, enabledInt, agent.CreatedAt, agent.UpdatedAt)
|
|
return err
|
|
}
|
|
|
|
func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.UtilityAgent) error {
|
|
if agent == nil {
|
|
return errors.New("agent is nil")
|
|
}
|
|
agent.Name = strings.TrimSpace(agent.Name)
|
|
agent.Description = strings.TrimSpace(agent.Description)
|
|
agent.Prompt = strings.TrimSpace(agent.Prompt)
|
|
agent.UpdatedAt = time.Now().UTC()
|
|
|
|
enabledInt := 0
|
|
if agent.Enabled {
|
|
enabledInt = 1
|
|
}
|
|
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE utility_agents
|
|
SET name = ?, description = ?, prompt = ?, agent_id = ?, model = ?, enabled = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`), agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model, enabledInt, agent.UpdatedAt, agent.ID)
|
|
return err
|
|
}
|
|
|
|
func (r *sqliteRepository) DeleteAgent(ctx context.Context, id string) error {
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM utility_agents WHERE id = ? AND builtin = 0`), id)
|
|
return err
|
|
}
|
|
|
|
func (r *sqliteRepository) ListCalls(ctx context.Context, utilityID string, limit int) ([]*models.UtilityAgentCall, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
|
|
SELECT id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at
|
|
FROM utility_agent_calls
|
|
WHERE utility_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
`), utilityID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
return r.scanCallRows(rows)
|
|
}
|
|
|
|
func (r *sqliteRepository) scanCallRows(rows *sql.Rows) ([]*models.UtilityAgentCall, error) {
|
|
var calls []*models.UtilityAgentCall
|
|
for rows.Next() {
|
|
call := &models.UtilityAgentCall{}
|
|
if err := rows.Scan(
|
|
&call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response,
|
|
&call.Model, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs,
|
|
&call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
calls = append(calls, call)
|
|
}
|
|
return calls, rows.Err()
|
|
}
|
|
|
|
func (r *sqliteRepository) GetCallByID(ctx context.Context, id string) (*models.UtilityAgentCall, error) {
|
|
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
|
SELECT id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at
|
|
FROM utility_agent_calls WHERE id = ?
|
|
`), id)
|
|
call := &models.UtilityAgentCall{}
|
|
if err := row.Scan(
|
|
&call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response,
|
|
&call.Model, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs,
|
|
&call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return call, nil
|
|
}
|
|
|
|
func (r *sqliteRepository) CreateCall(ctx context.Context, call *models.UtilityAgentCall) error {
|
|
if call.ID == "" {
|
|
call.ID = uuid.New().String()
|
|
}
|
|
if call.CreatedAt.IsZero() {
|
|
call.CreatedAt = time.Now().UTC()
|
|
}
|
|
if call.Status == "" {
|
|
call.Status = "pending"
|
|
}
|
|
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO utility_agent_calls (id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), call.ID, call.UtilityID, call.SessionID, call.ResolvedPrompt, call.Response,
|
|
call.Model, call.PromptTokens, call.ResponseTokens, call.DurationMs,
|
|
call.Status, call.ErrorMessage, call.CreatedAt, call.CompletedAt)
|
|
return err
|
|
}
|
|
|
|
func (r *sqliteRepository) UpdateCall(ctx context.Context, call *models.UtilityAgentCall) error {
|
|
if call == nil {
|
|
return errors.New("call is nil")
|
|
}
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE utility_agent_calls
|
|
SET response = ?, model = ?, prompt_tokens = ?, response_tokens = ?, duration_ms = ?, status = ?, error_message = ?, completed_at = ?
|
|
WHERE id = ?
|
|
`), call.Response, call.Model, call.PromptTokens, call.ResponseTokens, call.DurationMs,
|
|
call.Status, call.ErrorMessage, call.CompletedAt, call.ID)
|
|
return err
|
|
}
|