Files
kandev/apps/backend/internal/utility/store/sqlite.go
T
2026-03-02 01:55:39 +00:00

335 lines
11 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-code',
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`)
// 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
}