Files
kandev/apps/backend/internal/agent/settings/store/sqlite.go
T
2026-09-25 10:31:58 +01:00

1706 lines
61 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agent/settings/profileconfig"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/db/dialect"
)
type sqliteRepository struct {
db *sqlx.DB // writer
ro *sqlx.DB // reader
ownsDB bool
log *logger.Logger
migrate *db.MigrateLogger
}
var _ Repository = (*sqliteRepository)(nil)
func newSQLiteRepositoryWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*sqliteRepository, error) {
return newSQLiteRepository(writer, reader, log, false)
}
func newSQLiteRepository(writer, reader *sqlx.DB, log *logger.Logger, ownsDB bool) (*sqliteRepository, error) {
repo := &sqliteRepository{
db: writer,
ro: reader,
ownsDB: ownsDB,
log: log,
migrate: db.NewRequiredMigrateLogger(writer, log),
}
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 {
schema := `
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
workspace_id TEXT DEFAULT NULL,
supports_mcp INTEGER NOT NULL DEFAULT 0,
mcp_config_path TEXT DEFAULT '',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS agent_profiles (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
name TEXT NOT NULL,
agent_display_name TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
mode TEXT DEFAULT NULL,
migrated_from TEXT DEFAULT NULL,
auto_approve INTEGER NOT NULL DEFAULT 0,
dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0,
allow_indexing INTEGER NOT NULL DEFAULT 1,
cli_passthrough INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
user_modified INTEGER NOT NULL DEFAULT 0,
plan TEXT DEFAULT '',
cli_flags TEXT DEFAULT NULL,
env_vars TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP,
workspace_id TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT '',
reports_to TEXT NOT NULL DEFAULT '',
skill_ids TEXT NOT NULL DEFAULT '[]',
desired_skills TEXT NOT NULL DEFAULT '[]',
custom_prompt TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'idle'
CHECK (status IN ('idle','working','paused','stopped','pending_approval')),
pause_reason TEXT NOT NULL DEFAULT '',
working_run_id TEXT NOT NULL DEFAULT '',
last_run_finished_at TIMESTAMP,
max_concurrent_sessions INTEGER NOT NULL DEFAULT 1,
cooldown_sec INTEGER NOT NULL DEFAULT 0,
skip_idle_runs INTEGER NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
failure_threshold INTEGER NOT NULL DEFAULT 3,
executor_preference TEXT NOT NULL DEFAULT '',
budget_monthly_cents INTEGER NOT NULL DEFAULT 0,
settings TEXT NOT NULL DEFAULT '{}',
permissions TEXT NOT NULL DEFAULT '{}',
command_prefix TEXT NOT NULL DEFAULT '',
provider_kind TEXT NOT NULL DEFAULT '',
provider_base_url TEXT NOT NULL DEFAULT '',
provider_api_key_secret_id TEXT NOT NULL DEFAULT '',
cursor_mcp_auth_enabled INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS agent_profile_mcp_configs (
profile_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
servers_json TEXT NOT NULL DEFAULT '{}',
meta_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dynamic_agent_profiles (
profile_id TEXT PRIMARY KEY,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dynamic_agent_routes (
dynamic_profile_id TEXT NOT NULL,
position INTEGER NOT NULL,
execution_profile_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
rules_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (dynamic_profile_id, position),
FOREIGN KEY (dynamic_profile_id) REFERENCES dynamic_agent_profiles(profile_id) ON DELETE CASCADE,
FOREIGN KEY (execution_profile_id) REFERENCES agent_profiles(id) ON DELETE RESTRICT
);
DROP INDEX IF EXISTS idx_agents_name;
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_name ON agents(name);
CREATE INDEX IF NOT EXISTS idx_agent_profiles_agent_id ON agent_profiles(agent_id);
CREATE INDEX IF NOT EXISTS idx_dynamic_agent_routes_execution_profile
ON dynamic_agent_routes(execution_profile_id);
`
// Note: indexes on new office-enrichment columns (workspace_id, role,
// reports_to) are created later in migrateOfficeEnrichmentColumns —
// after the columns themselves are added — so legacy databases don't
// fail at CREATE INDEX before the ALTER TABLE statements have run.
_, err := r.db.Exec(schema)
if err != nil {
return err
}
r.migrate.Apply("agents.tui_config", `ALTER TABLE agents ADD COLUMN tui_config TEXT DEFAULT NULL`)
r.migrate.Apply("agent_profiles.mode", `ALTER TABLE agent_profiles ADD COLUMN mode TEXT DEFAULT NULL`)
r.migrate.Apply("agent_profiles.migrated_from", `ALTER TABLE agent_profiles ADD COLUMN migrated_from TEXT DEFAULT NULL`)
// Rows where cli_flags IS NULL are backfilled on first read - see scanAgentProfile.
r.migrate.Apply("agent_profiles.cli_flags", `ALTER TABLE agent_profiles ADD COLUMN cli_flags TEXT DEFAULT NULL`)
r.migrate.Apply("agent_profiles.env_vars", `ALTER TABLE agent_profiles ADD COLUMN env_vars TEXT NOT NULL DEFAULT '[]'`)
// Rows backfill to enabled=1 via the column default; disabling is an
// explicit user action through the settings UI.
r.migrate.Apply("agent_profiles.enabled", `ALTER TABLE agent_profiles ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`)
// Migration: drop CHECK(model != '') constraint from agent_profiles.
//
// The ACP-first model means models and modes are populated from the host
// utility probe cache at boot. An empty model is valid — it means "use the
// agent's default". SQLite does not support ALTER COLUMN or DROP CONSTRAINT,
// so we must recreate the table. This is idempotent: we check whether the
// old CHECK constraint still exists before doing anything.
if err := r.migrateDropModelCheckConstraint(); err != nil {
return fmt.Errorf("failed to migrate agent_profiles model constraint: %w", err)
}
// Migration: ADR 0005 Wave A — enrich agent_profiles with office columns.
// Duplicate-column errors are the only replay result tolerated by the
// required migration logger.
if err := r.migrateOfficeEnrichmentColumns(); err != nil {
return fmt.Errorf("failed to migrate agent profile office columns: %w", err)
}
// command_prefix is added after the table-recreation migration above so a
// legacy DB that recreates agent_profiles (to drop the model CHECK) still
// ends up with the column — the recreation copies only pre-existing
// columns, so an ADD COLUMN that ran before it would be lost.
r.migrate.Apply("agent_profiles.command_prefix", `ALTER TABLE agent_profiles ADD COLUMN command_prefix TEXT NOT NULL DEFAULT ''`)
// No-silent-model-fallback: added after the table-recreation migration
// above for the same reason as command_prefix — a legacy DB that
// recreates agent_profiles would otherwise lose columns added before it.
r.migrate.Apply("agent_profiles.fallback_model", `ALTER TABLE agent_profiles ADD COLUMN fallback_model TEXT NOT NULL DEFAULT ''`)
r.migrate.Apply("agent_profiles.auto_fallback", `ALTER TABLE agent_profiles ADD COLUMN auto_fallback INTEGER NOT NULL DEFAULT 0`)
// OpenAI-compatible providers: added after the table-recreation block for
// the same reason as command_prefix / fallback_model — a legacy DB that
// recreates agent_profiles copies only pre-existing columns.
_ = r.migrate.Apply("agent_profiles.provider_kind", `ALTER TABLE agent_profiles ADD COLUMN provider_kind TEXT NOT NULL DEFAULT ''`)
_ = r.migrate.Apply("agent_profiles.provider_base_url", `ALTER TABLE agent_profiles ADD COLUMN provider_base_url TEXT NOT NULL DEFAULT ''`)
_ = r.migrate.Apply("agent_profiles.provider_api_key_secret_id", `ALTER TABLE agent_profiles ADD COLUMN provider_api_key_secret_id TEXT NOT NULL DEFAULT ''`)
_ = r.migrate.Apply("agent_profiles.require_exact_model", `ALTER TABLE agent_profiles ADD COLUMN require_exact_model INTEGER NOT NULL DEFAULT 0`)
_ = r.migrate.Apply("agent_profiles.cursor_mcp_auth_enabled", `ALTER TABLE agent_profiles ADD COLUMN cursor_mcp_auth_enabled INTEGER NOT NULL DEFAULT 1`)
if err := r.migrate.Err(); err != nil {
return fmt.Errorf("required agent settings migration: %w", err)
}
return nil
}
// migrateOfficeEnrichmentColumns adds the office-enrichment columns introduced
// in ADR 0005 Wave A. Each ALTER is idempotent: only errors raised when the
// column already exists are tolerated.
func (r *sqliteRepository) migrateOfficeEnrichmentColumns() error {
type migration struct {
name string
stmt string
}
migrations := []migration{
{"agent_profiles.workspace_id", `ALTER TABLE agent_profiles ADD COLUMN workspace_id TEXT NOT NULL DEFAULT ''`},
{"agent_profiles.role", `ALTER TABLE agent_profiles ADD COLUMN role TEXT NOT NULL DEFAULT ''`},
{"agent_profiles.icon", `ALTER TABLE agent_profiles ADD COLUMN icon TEXT NOT NULL DEFAULT ''`},
{"agent_profiles.reports_to", `ALTER TABLE agent_profiles ADD COLUMN reports_to TEXT NOT NULL DEFAULT ''`},
{"agent_profiles.skill_ids", `ALTER TABLE agent_profiles ADD COLUMN skill_ids TEXT NOT NULL DEFAULT '[]'`},
{"agent_profiles.desired_skills", `ALTER TABLE agent_profiles ADD COLUMN desired_skills TEXT NOT NULL DEFAULT '[]'`},
{"agent_profiles.custom_prompt", `ALTER TABLE agent_profiles ADD COLUMN custom_prompt TEXT NOT NULL DEFAULT ''`},
{"agent_profiles.status", `ALTER TABLE agent_profiles ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'`},
{"agent_profiles.pause_reason", `ALTER TABLE agent_profiles ADD COLUMN pause_reason TEXT NOT NULL DEFAULT ''`},
{"agent_profiles.working_run_id", `ALTER TABLE agent_profiles ADD COLUMN working_run_id TEXT NOT NULL DEFAULT ''`},
{"agent_profiles.last_run_finished_at", `ALTER TABLE agent_profiles ADD COLUMN last_run_finished_at TIMESTAMP`},
{"agent_profiles.max_concurrent_sessions", `ALTER TABLE agent_profiles ADD COLUMN max_concurrent_sessions INTEGER NOT NULL DEFAULT 1`},
{"agent_profiles.cooldown_sec", `ALTER TABLE agent_profiles ADD COLUMN cooldown_sec INTEGER NOT NULL DEFAULT 0`},
{"agent_profiles.skip_idle_runs", `ALTER TABLE agent_profiles ADD COLUMN skip_idle_runs INTEGER NOT NULL DEFAULT 0`},
{"agent_profiles.consecutive_failures", `ALTER TABLE agent_profiles ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0`},
{"agent_profiles.failure_threshold", `ALTER TABLE agent_profiles ADD COLUMN failure_threshold INTEGER NOT NULL DEFAULT 3`},
{"agent_profiles.executor_preference", `ALTER TABLE agent_profiles ADD COLUMN executor_preference TEXT NOT NULL DEFAULT ''`},
{"agent_profiles.budget_monthly_cents", `ALTER TABLE agent_profiles ADD COLUMN budget_monthly_cents INTEGER NOT NULL DEFAULT 0`},
{"agent_profiles.settings", `ALTER TABLE agent_profiles ADD COLUMN settings TEXT NOT NULL DEFAULT '{}'`},
{"agent_profiles.permissions", `ALTER TABLE agent_profiles ADD COLUMN permissions TEXT NOT NULL DEFAULT '{}'`},
}
for _, m := range migrations {
if err := r.migrate.Apply(m.name, m.stmt); err != nil {
return err
}
}
// Indexes are idempotent via IF NOT EXISTS, but the partial-index variant
// required for role/reports_to needs the column to exist first - so create
// here after the ALTERs to handle databases upgraded from older schemas.
if err := r.migrate.Apply("idx_agent_profiles_workspace",
`CREATE INDEX IF NOT EXISTS idx_agent_profiles_workspace ON agent_profiles(workspace_id)`); err != nil {
return err
}
if err := r.migrate.Apply("idx_agent_profiles_role",
`CREATE INDEX IF NOT EXISTS idx_agent_profiles_role ON agent_profiles(workspace_id, role) WHERE role != ''`); err != nil {
return err
}
if err := r.migrate.Apply("idx_agent_profiles_reports_to",
`CREATE INDEX IF NOT EXISTS idx_agent_profiles_reports_to ON agent_profiles(reports_to) WHERE reports_to != ''`); err != nil {
return err
}
return nil
}
// migrateDropModelCheckConstraint recreates agent_profiles without the legacy
// non-empty-model CHECK constraint. Existing databases created before the
// ACP-first migration carry this constraint, which prevents empty model
// values. New databases (created by the CREATE TABLE IF NOT EXISTS above)
// never have it.
//
// The migration is idempotent: it inspects sqlite_master for the CHECK keyword
// and only proceeds when the constraint is present.
func (r *sqliteRepository) migrateDropModelCheckConstraint() error {
if dialect.IsPostgres(r.db.DriverName()) {
return nil
}
var tableDDL string
err := r.db.QueryRow(
`SELECT sql FROM sqlite_master WHERE type='table' AND name='agent_profiles'`,
).Scan(&tableDDL)
if errors.Is(err, sql.ErrNoRows) {
// Table doesn't exist yet (fresh DB, CREATE TABLE IF NOT EXISTS
// hasn't run or was a no-op) — nothing to migrate.
return nil
}
if err != nil {
return fmt.Errorf("query agent_profiles DDL: %w", err)
}
// Only migrate if the old model CHECK constraint is still present.
// Use a targeted match to avoid false-positives from unrelated future
// CHECK constraints on the same table.
if !strings.Contains(tableDDL, "CHECK(model") {
return nil
}
if err := r.recreateAgentProfilesWithoutModelCheck(); err != nil {
return err
}
if r.log != nil {
r.log.Info("migration applied", zap.String("name", "agent_profiles.recreate_drop_model_check"))
}
return nil
}
// recreateAgentProfilesWithoutModelCheck performs the actual SQLite table
// recreation: copy data into a new table without the CHECK constraint, drop
// the old table, rename the new one. Wrapped in a transaction so a crash
// mid-migration doesn't leave the DB without the agent_profiles table.
func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error {
// Disable FK enforcement during the recreation: the DB is opened with
// _foreign_keys=on, and agent_profile_mcp_configs references
// agent_profiles(id). This matches the pattern in task/repository.
if _, err := r.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
return fmt.Errorf("disable foreign keys for migration: %w", err)
}
defer func() { _, _ = r.db.Exec(`PRAGMA foreign_keys=ON`) }()
tx, err := r.db.Begin()
if err != nil {
return fmt.Errorf("begin migration tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
// The old table does not have cli_flags/env_vars yet (the ADD COLUMN ran earlier in
// initSchema but on the old table, which is about to be dropped). So we
// include it in the copy only when it exists on the source table.
srcHasCLIFlags := columnExists(tx, "agent_profiles", "cli_flags")
srcHasEnvVars := columnExists(tx, "agent_profiles", "env_vars")
srcHasEnabled := columnExists(tx, "agent_profiles", "enabled")
srcHasCommandPrefix := columnExists(tx, "agent_profiles", "command_prefix")
srcHasFallbackModel := columnExists(tx, "agent_profiles", "fallback_model")
srcHasAutoFallback := columnExists(tx, "agent_profiles", "auto_fallback")
srcHasRequireExactModel := columnExists(tx, "agent_profiles", "require_exact_model")
srcHasCursorMCPAuthEnabled := columnExists(tx, "agent_profiles", "cursor_mcp_auth_enabled")
srcCols := `id, agent_id, name, agent_display_name, model, mode, migrated_from,
auto_approve, dangerously_skip_permissions, allow_indexing,
cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at`
dstCols := srcCols
if srcHasCLIFlags {
srcCols += ", cli_flags"
dstCols += ", cli_flags"
}
if srcHasEnvVars {
srcCols += ", env_vars"
dstCols += ", env_vars"
}
if srcHasEnabled {
srcCols += ", enabled"
dstCols += ", enabled"
}
if srcHasCommandPrefix {
srcCols += ", command_prefix"
dstCols += ", command_prefix"
}
if srcHasFallbackModel {
srcCols += ", fallback_model"
dstCols += ", fallback_model"
}
if srcHasAutoFallback {
srcCols += ", auto_fallback"
dstCols += ", auto_fallback"
}
if srcHasRequireExactModel {
srcCols += ", require_exact_model"
dstCols += ", require_exact_model"
}
if srcHasCursorMCPAuthEnabled {
srcCols += ", cursor_mcp_auth_enabled"
dstCols += ", cursor_mcp_auth_enabled"
}
if _, err := tx.Exec(`CREATE TABLE agent_profiles_new (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
name TEXT NOT NULL,
agent_display_name TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
mode TEXT DEFAULT NULL,
migrated_from TEXT DEFAULT NULL,
auto_approve INTEGER NOT NULL DEFAULT 0,
dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0,
allow_indexing INTEGER NOT NULL DEFAULT 1,
cli_passthrough INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
user_modified INTEGER NOT NULL DEFAULT 0,
plan TEXT DEFAULT '',
cli_flags TEXT DEFAULT NULL,
env_vars TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP,
command_prefix TEXT NOT NULL DEFAULT '',
fallback_model TEXT NOT NULL DEFAULT '',
auto_fallback INTEGER NOT NULL DEFAULT 0,
require_exact_model INTEGER NOT NULL DEFAULT 0,
cursor_mcp_auth_enabled INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
)`); err != nil {
return fmt.Errorf("create new table: %w", err)
}
if _, err := tx.Exec(
`INSERT INTO agent_profiles_new (` + dstCols + `) SELECT ` + srcCols + ` FROM agent_profiles`,
); err != nil {
return fmt.Errorf("copy data: %w", err)
}
if _, err := tx.Exec(`DROP TABLE agent_profiles`); err != nil {
return fmt.Errorf("drop old table: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE agent_profiles_new RENAME TO agent_profiles`); err != nil {
return fmt.Errorf("rename new table: %w", err)
}
if _, err := tx.Exec(
`CREATE INDEX IF NOT EXISTS idx_agent_profiles_agent_id ON agent_profiles(agent_id)`,
); err != nil {
return fmt.Errorf("recreate index: %w", err)
}
return tx.Commit()
}
func (r *sqliteRepository) Close() error {
if !r.ownsDB {
return nil
}
return r.db.Close()
}
// marshalTUIConfig renders a TUI config for the nullable tui_config column.
// Shared by CreateAgent and UpdateAgent so the two writers cannot drift — the
// column being absent from one of them is exactly how an editable field was
// lost on save.
func marshalTUIConfig(cfg *models.TUIConfigJSON) (*string, error) {
if cfg == nil {
return nil, nil
}
data, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("failed to marshal tui_config: %w", err)
}
s := string(data)
return &s, nil
}
func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent) error {
if agent.ID == "" {
agent.ID = uuid.New().String()
}
now := time.Now().UTC()
agent.CreatedAt = now
agent.UpdatedAt = now
tuiConfigJSON, err := marshalTUIConfig(agent.TUIConfig)
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), agent.ID, agent.Name, agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, tuiConfigJSON, agent.CreatedAt, agent.UpdatedAt)
return err
}
func (r *sqliteRepository) GetAgent(ctx context.Context, id string) (*models.Agent, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at
FROM agents WHERE id = ?
`), id)
return scanAgent(row)
}
func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*models.Agent, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at
FROM agents WHERE name = ?
`), name)
return scanAgent(row)
}
func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) error {
agent.UpdatedAt = time.Now().UTC()
// tui_config is written here as well as in CreateAgent: a custom TUI agent's
// MCP strategy is editable after creation, and omitting the column silently
// discarded that edit — the row kept supports_mcp while the strategy it was
// derived from reverted on the next boot.
tuiConfigJSON, err := marshalTUIConfig(agent.TUIConfig)
if err != nil {
return err
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, tui_config = ?, updated_at = ?
WHERE id = ?
`), agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, tuiConfigJSON, agent.UpdatedAt, agent.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent not found: %s", agent.ID)
}
return nil
}
func (r *sqliteRepository) DeleteAgent(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM agents WHERE id = ?`), id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent not found: %s", id)
}
return nil
}
func (r *sqliteRepository) ListAgents(ctx context.Context) ([]*models.Agent, error) {
return r.listAgentsWhere(ctx, "1=1")
}
func (r *sqliteRepository) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*models.AgentProfileMcpConfig, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT profile_id, enabled, servers_json, meta_json, created_at, updated_at
FROM agent_profile_mcp_configs
WHERE profile_id = ?
`), profileID)
return scanAgentProfileMcpConfig(row)
}
func scanAgentProfileMcpConfig(scanner interface {
Scan(dest ...any) error
}) (*models.AgentProfileMcpConfig, error) {
var config models.AgentProfileMcpConfig
var enabled int
var serversJSON string
var metaJSON string
if err := scanner.Scan(&config.ProfileID, &enabled, &serversJSON, &metaJSON, &config.CreatedAt, &config.UpdatedAt); err != nil {
return nil, err
}
config.Enabled = enabled == 1
if err := json.Unmarshal([]byte(serversJSON), &config.Servers); err != nil {
return nil, fmt.Errorf("failed to parse MCP servers JSON: %w", err)
}
if err := json.Unmarshal([]byte(metaJSON), &config.Meta); err != nil {
return nil, fmt.Errorf("failed to parse MCP meta JSON: %w", err)
}
return &config, nil
}
// UpdateAgentProfileMcpConfigPatch updates only the requested MCP columns in
// one database statement. The upsert keeps the other document columns intact,
// including when two callers update different fields concurrently.
//
//nolint:cyclop // The transaction validates ownership, builds the partial SQL update, and returns the row.
func (r *sqliteRepository) UpdateAgentProfileMcpConfigPatch(
ctx context.Context,
profileID string,
enabled *bool,
servers *map[string]interface{},
) (*models.AgentProfileMcpConfig, error) {
if profileID == "" {
return nil, fmt.Errorf("profile ID is required")
}
if enabled == nil && servers == nil {
return nil, fmt.Errorf("MCP config patch is empty")
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
var supportsMCP int
if err := tx.QueryRowContext(ctx, tx.Rebind(`
SELECT a.supports_mcp
FROM agent_profiles p
JOIN agents a ON a.id = p.agent_id
WHERE p.id = ? AND p.deleted_at IS NULL
`), profileID).Scan(&supportsMCP); err != nil {
return nil, err
}
if supportsMCP != 1 {
return nil, fmt.Errorf("mcp not supported by agent")
}
const emptyJSON = "{}"
enabledValue := 0
if enabled != nil && *enabled {
enabledValue = 1
}
serversJSON := emptyJSON
if servers != nil {
encoded, marshalErr := json.Marshal(*servers)
if marshalErr != nil {
return nil, fmt.Errorf("failed to serialize MCP servers: %w", marshalErr)
}
serversJSON = string(encoded)
}
updates := make([]string, 0, 3)
if enabled != nil {
updates = append(updates, "enabled = excluded.enabled")
}
if servers != nil {
updates = append(updates, "servers_json = excluded.servers_json")
}
updates = append(updates, "updated_at = excluded.updated_at")
now := time.Now().UTC()
_, err = tx.ExecContext(ctx, tx.Rebind(`
INSERT INTO agent_profile_mcp_configs (profile_id, enabled, servers_json, meta_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(profile_id) DO UPDATE SET
`+strings.Join(updates, ",\n\t\t\t")+
`
`), profileID, enabledValue, serversJSON, emptyJSON, now, now)
if err != nil {
return nil, err
}
config, err := scanAgentProfileMcpConfig(tx.QueryRowxContext(ctx, tx.Rebind(`
SELECT profile_id, enabled, servers_json, meta_json, created_at, updated_at
FROM agent_profile_mcp_configs
WHERE profile_id = ?
`), profileID))
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return config, nil
}
func (r *sqliteRepository) UpsertAgentProfileMcpConfig(ctx context.Context, config *models.AgentProfileMcpConfig) error {
if config.ProfileID == "" {
return fmt.Errorf("profile ID is required")
}
return r.upsertAgentProfileMcpConfig(ctx, r.db, config)
}
// upsertAgentProfileMcpConfig inserts or replaces the MCP config row for a
// profile, running against the caller-supplied execer so the duplicate can
// commit it in the same transaction as the profile row. Defaults nil maps and
// fills timestamps on the passed config in place.
func (r *sqliteRepository) upsertAgentProfileMcpConfig(ctx context.Context, execer profileExecer, config *models.AgentProfileMcpConfig) error {
if config.Servers == nil {
config.Servers = map[string]interface{}{}
}
if config.Meta == nil {
config.Meta = map[string]interface{}{}
}
now := time.Now().UTC()
if config.CreatedAt.IsZero() {
config.CreatedAt = now
}
config.UpdatedAt = now
serversJSON, err := json.Marshal(config.Servers)
if err != nil {
return fmt.Errorf("failed to serialize MCP servers: %w", err)
}
metaJSON, err := json.Marshal(config.Meta)
if err != nil {
return fmt.Errorf("failed to serialize MCP meta: %w", err)
}
_, err = execer.ExecContext(ctx, execer.Rebind(`
INSERT INTO agent_profile_mcp_configs (profile_id, enabled, servers_json, meta_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(profile_id) DO UPDATE SET
enabled = excluded.enabled,
servers_json = excluded.servers_json,
meta_json = excluded.meta_json,
updated_at = excluded.updated_at
`), config.ProfileID, dialect.BoolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt)
return err
}
func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error {
if profile.ID == "" {
profile.ID = uuid.New().String()
}
now := time.Now().UTC()
profile.CreatedAt = now
profile.UpdatedAt = now
// New profiles are created enabled — the DB column default is 1 and
// nothing creates a profile pre-disabled. Setting the field here keeps
// callers' in-memory copy consistent with the row.
profile.Enabled = true
return r.insertAgentProfile(ctx, r.db, profile)
}
var _ DynamicProfileRepository = (*sqliteRepository)(nil)
// CreateDynamicAgentProfile writes the dynamic document and its ordered route
// rows in one transaction. The parent agent_profiles row is created by the
// controller first; this transaction is the optimistic-routing document's
// atomic unit.
func (r *sqliteRepository) CreateDynamicAgentProfile(
ctx context.Context,
profile *models.DynamicAgentProfile,
routes []models.DynamicAgentRoute,
) error {
if profile == nil || profile.ProfileID == "" {
return fmt.Errorf("dynamic profile ID is required")
}
if profile.Version <= 0 {
profile.Version = 1
}
now := time.Now().UTC()
if profile.CreatedAt.IsZero() {
profile.CreatedAt = now
}
profile.UpdatedAt = now
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, tx.Rebind(`
INSERT INTO dynamic_agent_profiles (profile_id, version, created_at, updated_at)
VALUES (?, ?, ?, ?)
`), profile.ProfileID, profile.Version, profile.CreatedAt, profile.UpdatedAt); err != nil {
return err
}
if err := insertDynamicRoutes(ctx, tx, profile.ProfileID, routes); err != nil {
return err
}
return tx.Commit()
}
func insertDynamicRoutes(ctx context.Context, execer profileExecer, profileID string, routes []models.DynamicAgentRoute) error {
for _, route := range routes {
if _, err := execer.ExecContext(ctx, execer.Rebind(`
INSERT INTO dynamic_agent_routes
(dynamic_profile_id, position, execution_profile_id, enabled, rules_json)
VALUES (?, ?, ?, ?, ?)
`), profileID, route.Position, route.ExecutionProfileID,
dialect.BoolToInt(route.Enabled), route.RulesJSON); err != nil {
return err
}
}
return nil
}
func (r *sqliteRepository) GetDynamicAgentProfile(
ctx context.Context,
profileID string,
) (*models.DynamicAgentProfile, []models.DynamicAgentRoute, error) {
profile := &models.DynamicAgentProfile{}
if err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT profile_id, version, created_at, updated_at
FROM dynamic_agent_profiles WHERE profile_id = ?
`), profileID).Scan(&profile.ProfileID, &profile.Version, &profile.CreatedAt, &profile.UpdatedAt); err != nil {
return nil, nil, err
}
rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(`
SELECT dynamic_profile_id, position, execution_profile_id, enabled, rules_json
FROM dynamic_agent_routes WHERE dynamic_profile_id = ? ORDER BY position ASC
`), profileID)
if err != nil {
return nil, nil, err
}
defer func() { _ = rows.Close() }()
routes := make([]models.DynamicAgentRoute, 0)
for rows.Next() {
var route models.DynamicAgentRoute
var enabled int
if err := rows.Scan(&route.DynamicProfileID, &route.Position, &route.ExecutionProfileID, &enabled, &route.RulesJSON); err != nil {
return nil, nil, err
}
route.Enabled = enabled != 0
routes = append(routes, route)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
return profile, routes, nil
}
// UpdateDynamicAgentProfile replaces the complete route document only when
// expectedVersion still owns the row. The new version and route replacement
// commit together, so readers never observe a partially edited candidate list.
func (r *sqliteRepository) UpdateDynamicAgentProfile(
ctx context.Context,
profile *models.DynamicAgentProfile,
expectedVersion int64,
routes []models.DynamicAgentRoute,
) error {
if profile == nil || profile.ProfileID == "" {
return fmt.Errorf("dynamic profile ID is required")
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.updateDynamicAgentProfileTx(ctx, tx, profile, expectedVersion, routes); err != nil {
return err
}
return tx.Commit()
}
func (r *sqliteRepository) updateDynamicAgentProfileTx(
ctx context.Context,
execer profileExecer,
profile *models.DynamicAgentProfile,
expectedVersion int64,
routes []models.DynamicAgentRoute,
) error {
now := time.Now().UTC()
result, err := execer.ExecContext(ctx, execer.Rebind(`
UPDATE dynamic_agent_profiles
SET version = version + 1, updated_at = ?
WHERE profile_id = ? AND version = ?
`), now, profile.ProfileID, expectedVersion)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrDynamicProfileVersionConflict
}
if _, err := execer.ExecContext(ctx, execer.Rebind(
`DELETE FROM dynamic_agent_routes WHERE dynamic_profile_id = ?`), profile.ProfileID); err != nil {
return err
}
if err := insertDynamicRoutes(ctx, execer, profile.ProfileID, routes); err != nil {
return err
}
profile.Version = expectedVersion + 1
profile.UpdatedAt = now
return nil
}
// UpdateAgentProfileWithDynamic commits the ordinary profile fields and the
// versioned dynamic route document together. A version conflict rolls back
// both writes, so a stale editor cannot persist a partial base-profile change.
func (r *sqliteRepository) UpdateAgentProfileWithDynamic(
ctx context.Context,
profile *models.AgentProfile,
dynamic *models.DynamicAgentProfile,
expectedVersion int64,
routes []models.DynamicAgentRoute,
) error {
if profile == nil || dynamic == nil || profile.ID == "" || dynamic.ProfileID != profile.ID {
return fmt.Errorf("profile and dynamic profile IDs are required")
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.updateAgentProfile(ctx, tx, profile); err != nil {
return err
}
if err := r.updateDynamicAgentProfileTx(ctx, tx, dynamic, expectedVersion, routes); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (r *sqliteRepository) ListDynamicProfileReferencesByExecutionProfile(
ctx context.Context,
profileID string,
) ([]models.DynamicProfileReference, error) {
rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(`
SELECT p.id, p.name, p.deleted_at
FROM dynamic_agent_routes r
JOIN agent_profiles p ON p.id = r.dynamic_profile_id
WHERE r.execution_profile_id = ?
ORDER BY p.name, p.id
`), profileID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
refs := make([]models.DynamicProfileReference, 0)
seen := make(map[string]struct{})
for rows.Next() {
var ref models.DynamicProfileReference
if err := rows.Scan(&ref.ProfileID, &ref.Name, &ref.DeletedAt); err != nil {
return nil, err
}
if _, ok := seen[ref.ProfileID]; ok {
continue
}
seen[ref.ProfileID] = struct{}{}
refs = append(refs, ref)
}
return refs, rows.Err()
}
// DuplicateAgentProfile creates an independent copy of a profile in a single
// transaction: the row is inserted with the caller-provided Enabled state (a
// duplicate of a disabled profile must not become briefly selectable) and the
// MCP config row is upserted when non-nil. A failure rolls back and leaves no
// partial copy, so retrying after an error cannot create a duplicate row.
//
// The source rows are re-read inside the transaction and must still carry the
// revisions the copy was built from; otherwise ErrProfileChanged is returned
// and nothing is created. Combined with WAL snapshot isolation (a concurrent
// commit after this transaction's first read aborts its write with a busy
// error), the copy always reflects one consistent snapshot of the source.
func (r *sqliteRepository) DuplicateAgentProfile(ctx context.Context, input DuplicateAgentProfileInput) error {
if input.Profile.ID == "" {
input.Profile.ID = uuid.New().String()
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.verifySourceSnapshot(ctx, tx, input); err != nil {
return err
}
now := time.Now().UTC()
input.Profile.CreatedAt = now
input.Profile.UpdatedAt = now
if err := r.insertAgentProfile(ctx, tx, input.Profile); err != nil {
return err
}
if input.McpConfig != nil {
input.McpConfig.ProfileID = input.Profile.ID
if err := r.upsertAgentProfileMcpConfig(ctx, tx, input.McpConfig); err != nil {
return err
}
}
return tx.Commit()
}
// verifySourceSnapshot checks, inside the duplicate transaction, that the
// source rows still match the revisions the caller built the copy from. A
// mismatch means a concurrent writer changed the source between the caller's
// read and this transaction — duplicating anyway would produce a copy mixing
// two points in time.
func (r *sqliteRepository) verifySourceSnapshot(ctx context.Context, tx *sqlx.Tx, input DuplicateAgentProfileInput) error {
var sourceUpdated time.Time
err := tx.QueryRowContext(ctx, tx.Rebind(
`SELECT updated_at FROM agent_profiles WHERE id = ? AND deleted_at IS NULL`),
input.Source.ID).Scan(&sourceUpdated)
if errors.Is(err, sql.ErrNoRows) {
return ErrSourceProfileNotFound
}
if err != nil {
return err
}
if !sourceUpdated.Equal(input.Source.UpdatedAt) {
return ErrProfileChanged
}
if input.SourceMcp == nil {
// The caller's snapshot had no MCP row: verify one was not created in
// the meantime, otherwise the copy would silently drop it. Missing is
// the unchanged case; an existing row means the snapshot is stale.
var exists int
err = tx.QueryRowContext(ctx, tx.Rebind(
`SELECT 1 FROM agent_profile_mcp_configs WHERE profile_id = ?`),
input.Source.ID).Scan(&exists)
if err == nil {
return ErrProfileChanged
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
return nil
}
var mcpUpdated time.Time
err = tx.QueryRowContext(ctx, tx.Rebind(
`SELECT updated_at FROM agent_profile_mcp_configs WHERE profile_id = ?`),
input.Source.ID).Scan(&mcpUpdated)
if errors.Is(err, sql.ErrNoRows) {
return ErrProfileChanged
}
if err != nil {
return err
}
if !mcpUpdated.Equal(input.SourceMcp.UpdatedAt) {
return ErrProfileChanged
}
return nil
}
// profileExecer is the subset of *sqlx.DB / *sqlx.Tx the shared insert and
// upsert helpers need, so one code path serves both single-statement and
// transactional writes.
type profileExecer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
Rebind(query string) string
}
// insertAgentProfile writes the profile row through the caller-supplied
// execer, sharing one SQL statement between CreateAgentProfile and the
// transactional duplicate path. The profile's Enabled value is written as-is.
func (r *sqliteRepository) insertAgentProfile(ctx context.Context, execer profileExecer, profile *models.AgentProfile) error {
cliFlagsJSON, err := cliFlagsToJSON(profile.CLIFlags)
if err != nil {
return err
}
envVarsJSON, err := envVarsToJSON(profile.EnvVars)
if err != nil {
return err
}
enrich, err := enrichmentValues(profile)
if err != nil {
return err
}
_, err = execer.ExecContext(ctx, execer.Rebind(`
INSERT INTO agent_profiles (
id, agent_id, name, agent_display_name, model, mode, migrated_from,
auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough,
enabled, user_modified, plan, cli_flags, env_vars, created_at, updated_at, deleted_at,
workspace_id, role, icon, reports_to,
skill_ids, desired_skills, custom_prompt,
status, pause_reason, last_run_finished_at,
max_concurrent_sessions, cooldown_sec, skip_idle_runs,
consecutive_failures, failure_threshold,
executor_preference, budget_monthly_cents, settings, permissions,
command_prefix, fallback_model, auto_fallback,
provider_kind, provider_base_url, provider_api_key_secret_id, require_exact_model,
cursor_mcp_auth_enabled
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, '', ?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?,
?, ?, ?,
?, ?, ?,
?, ?,
?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?,
?
)
`),
profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model,
nullableString(profile.Mode), nullableString(profile.MigratedFrom),
dialect.BoolToInt(profile.AutoApprove),
dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), dialect.BoolToInt(profile.CLIPassthrough),
dialect.BoolToInt(profile.Enabled), dialect.BoolToInt(profile.UserModified), cliFlagsJSON, envVarsJSON, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt,
enrich.workspaceID, enrich.role, enrich.icon, enrich.reportsTo,
enrich.skillIDs, enrich.desiredSkills, enrich.customPrompt,
enrich.status, enrich.pauseReason, profile.LastRunFinishedAt,
enrich.maxConcurrentSessions, profile.CooldownSec, dialect.BoolToInt(profile.SkipIdleRuns),
profile.ConsecutiveFailures, enrich.failureThreshold,
enrich.executorPreference, profile.BudgetMonthlyCents, enrich.settings, enrich.permissions,
profile.CommandPrefix,
profile.FallbackModel,
dialect.BoolToInt(profile.AutoFallback),
profile.ProviderKind, profile.ProviderBaseURL, profile.ProviderAPIKeySecretID,
dialect.BoolToInt(profile.RequireExactModel),
dialect.BoolToInt(profile.CursorMCPAuthEnabled),
)
return err
}
// defaultAgentProfileStatus is the canonical "idle" status default used when
// a profile is created or read without an explicit status. Hoisted to a
// constant so the goconst linter has a single shared source of truth.
const defaultAgentProfileStatus = "idle"
// profileEnrichmentValues holds the marshalled / defaulted office-enrichment
// values that the Insert + Update statements share. Centralising this keeps
// the SQL-bind ordering in one place and avoids duplicated default-fill logic.
type profileEnrichmentValues struct {
workspaceID string
role string
icon string
reportsTo string
skillIDs string
desiredSkills string
customPrompt string
status string
pauseReason string
maxConcurrentSessions int
failureThreshold int
executorPreference string
settings string
permissions string
}
// enrichmentValues fills in defaults for the office-enrichment columns. The
// JSON-array fields (SkillIDs, DesiredSkills) are stored verbatim — empty
// values normalise to "[]" so the NOT NULL constraint is satisfied.
func enrichmentValues(profile *models.AgentProfile) (profileEnrichmentValues, error) {
status := string(profile.Status)
if status == "" {
status = defaultAgentProfileStatus
}
maxSessions := profile.MaxConcurrentSessions
if maxSessions <= 0 {
maxSessions = 1
}
settings := profile.Settings
if settings == "" {
settings = "{}"
}
settings, err := settingsWithConfigOptions(settings, profile.ConfigOptions)
if err != nil {
return profileEnrichmentValues{}, err
}
permissions := profile.Permissions
if permissions == "" {
permissions = "{}"
}
return profileEnrichmentValues{
workspaceID: profile.WorkspaceID,
role: string(profile.Role),
icon: profile.Icon,
reportsTo: profile.ReportsTo,
skillIDs: normalizeJSONArray(profile.SkillIDs),
desiredSkills: normalizeJSONArray(profile.DesiredSkills),
customPrompt: profile.CustomPrompt,
status: status,
pauseReason: profile.PauseReason,
maxConcurrentSessions: maxSessions,
failureThreshold: failureThresholdToColumn(profile.FailureThreshold),
executorPreference: profile.ExecutorPreference,
settings: settings,
permissions: permissions,
}, nil
}
const profileSettingsConfigOptionsKey = "config_options"
func settingsWithConfigOptions(raw string, options map[string]string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = "{}"
}
var settings map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &settings); err != nil {
return "", fmt.Errorf("parse profile settings: %w", err)
}
if settings == nil {
settings = map[string]json.RawMessage{}
}
clean := profileconfig.SanitizeConfigOptions(options)
if len(clean) == 0 {
delete(settings, profileSettingsConfigOptionsKey)
} else {
data, err := json.Marshal(clean)
if err != nil {
return "", fmt.Errorf("marshal profile config options: %w", err)
}
settings[profileSettingsConfigOptionsKey] = data
}
data, err := json.Marshal(settings)
if err != nil {
return "", fmt.Errorf("marshal profile settings: %w", err)
}
return string(data), nil
}
func configOptionsFromSettings(raw string) map[string]string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var payload struct {
ConfigOptions map[string]string `json:"config_options"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil
}
return profileconfig.SanitizeConfigOptions(payload.ConfigOptions)
}
// normalizeJSONArray returns "[]" for empty values; otherwise the input. Used
// to satisfy the NOT NULL DEFAULT '[]' constraint on JSON-array TEXT columns
// (skill_ids, desired_skills) without mandating callers always set a value.
func normalizeJSONArray(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "[]"
}
return s
}
// failureThresholdToColumn maps the *int (NULL = use workspace default) onto
// the merged column's INTEGER NOT NULL DEFAULT 3 semantics. We store 0 as
// "use workspace default" and round-trip it back to nil on read; non-zero
// values flow through verbatim.
func failureThresholdToColumn(p *int) int {
if p == nil {
return 0
}
return *p
}
// nullableString converts an empty string to sql.NullString zero-value so the
// column is written as NULL rather than "". Keeps nullable columns clean.
func nullableString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
// columnExists reports whether a column is present on a SQLite table. Used by
// the table-recreation migration to gracefully handle databases where ALTER
// TABLE ADD COLUMN ran but the recreation is running for the first time.
type sqlQueryer interface {
Query(query string, args ...any) (*sql.Rows, error)
}
func columnExists(q sqlQueryer, table, column string) bool {
rows, err := q.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
cid int
name string
ctype string
notnull int
dflt sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false
}
if name == column {
return true
}
}
return false
}
// cliFlagsToJSON marshals the profile's CLIFlags list into a JSON string
// suitable for the cli_flags TEXT column. Empty or nil slices serialise to
// "[]" so we can distinguish an empty-but-known list (skip backfill) from a
// never-written NULL (trigger backfill on read).
func cliFlagsToJSON(flags []models.CLIFlag) (string, error) {
if flags == nil {
flags = []models.CLIFlag{}
}
data, err := json.Marshal(flags)
if err != nil {
return "", fmt.Errorf("marshal cli_flags: %w", err)
}
return string(data), nil
}
func envVarsToJSON(envVars []models.ProfileEnvVar) (string, error) {
if envVars == nil {
envVars = []models.ProfileEnvVar{}
}
data, err := json.Marshal(envVars)
if err != nil {
return "", fmt.Errorf("marshal env_vars: %w", err)
}
return string(data), nil
}
func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error {
return r.updateAgentProfile(ctx, r.db, profile)
}
// UpdateAgentProfileModelIfEmpty adopts a probed model without replacing any
// other profile fields. The model predicate makes the read/check/write one
// atomic operation, so a concurrent profile edit wins over the background
// probe instead of being overwritten by a full-row update.
func (r *sqliteRepository) UpdateAgentProfileModelIfEmpty(
ctx context.Context,
profileID, model string,
) (bool, error) {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE agent_profiles
SET model = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL AND model = ''
`), model, time.Now().UTC(), profileID)
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
if err != nil {
return false, err
}
return rows == 1, nil
}
func (r *sqliteRepository) updateAgentProfile(ctx context.Context, execer profileExecer, profile *models.AgentProfile) error {
profile.UpdatedAt = time.Now().UTC()
cliFlagsJSON, err := cliFlagsToJSON(profile.CLIFlags)
if err != nil {
return err
}
envVarsJSON, err := envVarsToJSON(profile.EnvVars)
if err != nil {
return err
}
enrich, err := enrichmentValues(profile)
if err != nil {
return err
}
result, err := execer.ExecContext(ctx, execer.Rebind(`
UPDATE agent_profiles
SET agent_id = ?, name = ?, agent_display_name = ?, model = ?, mode = ?, migrated_from = ?,
auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?,
cli_passthrough = ?, enabled = ?, user_modified = ?, cli_flags = ?, env_vars = ?, updated_at = ?,
workspace_id = ?, role = ?, icon = ?, reports_to = ?,
skill_ids = ?, desired_skills = ?, custom_prompt = ?,
status = CASE
WHEN (status = 'working' AND working_run_id <> '') OR ? = 'working' THEN status
ELSE ?
END,
pause_reason = CASE
WHEN (status = 'working' AND working_run_id <> '') OR ? = 'working' THEN pause_reason
ELSE ?
END,
working_run_id = CASE
WHEN status = 'working' AND working_run_id <> '' THEN working_run_id
ELSE ''
END,
last_run_finished_at = ?,
max_concurrent_sessions = ?, cooldown_sec = ?, skip_idle_runs = ?,
consecutive_failures = ?, failure_threshold = ?,
executor_preference = ?,
budget_monthly_cents = ?, settings = ?, permissions = ?,
command_prefix = ?, fallback_model = ?, auto_fallback = ?,
provider_kind = ?, provider_base_url = ?, provider_api_key_secret_id = ?, require_exact_model = ?,
cursor_mcp_auth_enabled = ?
WHERE id = ? AND deleted_at IS NULL
`), profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model,
nullableString(profile.Mode), nullableString(profile.MigratedFrom),
dialect.BoolToInt(profile.AutoApprove),
dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing),
dialect.BoolToInt(profile.CLIPassthrough), dialect.BoolToInt(profile.Enabled), dialect.BoolToInt(profile.UserModified), cliFlagsJSON, envVarsJSON, profile.UpdatedAt,
enrich.workspaceID, enrich.role, enrich.icon, enrich.reportsTo,
enrich.skillIDs, enrich.desiredSkills, enrich.customPrompt,
enrich.status, enrich.status, enrich.status, enrich.pauseReason, profile.LastRunFinishedAt,
enrich.maxConcurrentSessions, profile.CooldownSec, dialect.BoolToInt(profile.SkipIdleRuns),
profile.ConsecutiveFailures, enrich.failureThreshold,
enrich.executorPreference,
profile.BudgetMonthlyCents, enrich.settings, enrich.permissions,
profile.CommandPrefix,
profile.FallbackModel,
dialect.BoolToInt(profile.AutoFallback),
profile.ProviderKind, profile.ProviderBaseURL, profile.ProviderAPIKeySecretID,
dialect.BoolToInt(profile.RequireExactModel),
dialect.BoolToInt(profile.CursorMCPAuthEnabled),
profile.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent profile not found: %s", profile.ID)
}
return nil
}
// UpdateAgentProfileEnabled changes only the selection flag. Keeping this
// update narrow avoids a settings-page toggle overwriting fields that were
// read by another profile editor before its save completed.
func (r *sqliteRepository) UpdateAgentProfileEnabled(ctx context.Context, id string, enabled bool) (time.Time, error) {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE agent_profiles
SET enabled = ?, user_modified = 1, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`), dialect.BoolToInt(enabled), now, id)
if err != nil {
return time.Time{}, err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return time.Time{}, fmt.Errorf("agent profile not found: %s", id)
}
return now, nil
}
func (r *sqliteRepository) DeleteAgentProfile(ctx context.Context, id string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE agent_profiles SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL
`), now, now, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent profile not found: %s", id)
}
return nil
}
// agentProfileSelectColumns is the SELECT projection used by every
// AgentProfile read path. Extracted once so the next column added to
// agent_profiles only has to land here — duplicating the list across
// GetAgentProfile / GetAgentProfileIncludingDeleted / ListAgentProfiles
// risks the soft-delete path silently scanning zero values for a freshly
// added column, which is the same shape of bug this package fixes in
// another layer (orphaned watchers vs. stale projection drift).
const agentProfileSelectColumns = `
SELECT id, agent_id, name, agent_display_name, model, mode, migrated_from,
auto_approve, dangerously_skip_permissions, allow_indexing,
cli_passthrough, enabled, user_modified, plan, cli_flags,
COALESCE(env_vars, '[]'),
created_at, updated_at, deleted_at,
COALESCE(workspace_id, ''), COALESCE(role, ''), COALESCE(icon, ''),
COALESCE(reports_to, ''), COALESCE(skill_ids, '[]'),
COALESCE(desired_skills, '[]'), COALESCE(custom_prompt, ''),
COALESCE(status, 'idle'), COALESCE(pause_reason, ''),
last_run_finished_at,
COALESCE(max_concurrent_sessions, 1), COALESCE(cooldown_sec, 0),
COALESCE(skip_idle_runs, 0), COALESCE(consecutive_failures, 0),
COALESCE(failure_threshold, 3), COALESCE(executor_preference, ''),
COALESCE(budget_monthly_cents, 0),
COALESCE(settings, '{}'), COALESCE(permissions, '{}'),
COALESCE(command_prefix, ''),
COALESCE(fallback_model, ''), COALESCE(auto_fallback, 0),
COALESCE(provider_kind, ''), COALESCE(provider_base_url, ''),
COALESCE(provider_api_key_secret_id, ''), COALESCE(require_exact_model, 0),
COALESCE(cursor_mcp_auth_enabled, 1)
FROM agent_profiles`
func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) {
row := r.ro.QueryRowContext(ctx,
r.ro.Rebind(agentProfileSelectColumns+` WHERE id = ? AND deleted_at IS NULL`), id)
profile, err := scanAgentProfile(row)
if err != nil {
return nil, err
}
return r.applyLegacyBackfill(ctx, profile), nil
}
// GetAgentProfileIncludingDeleted returns the row even when soft-deleted.
// Resolver and watcher self-heal callers use this to disambiguate
// "row removed" (recoverable: orphan reference) from "row never existed".
func (r *sqliteRepository) GetAgentProfileIncludingDeleted(ctx context.Context, id string) (*models.AgentProfile, error) {
row := r.ro.QueryRowContext(ctx,
r.ro.Rebind(agentProfileSelectColumns+` WHERE id = ?`), id)
profile, err := scanAgentProfile(row)
if err != nil {
return nil, err
}
return r.applyLegacyBackfill(ctx, profile), nil
}
func (r *sqliteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) {
rows, err := r.ro.QueryContext(ctx,
r.ro.Rebind(agentProfileSelectColumns+` WHERE agent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC`),
agentID)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var result []*models.AgentProfile
for rows.Next() {
profile, err := scanAgentProfile(rows)
if err != nil {
return nil, err
}
result = append(result, r.applyLegacyBackfill(ctx, profile))
}
return result, rows.Err()
}
// HasDeletedAgentProfiles reports whether the agent has any soft-deleted
// profile rows (deleted_at IS NOT NULL). It is the "has been provisioned
// before" signal the boot-time seeders consult before recreating a default
// profile, so a profile the user deleted is not silently resurrected.
func (r *sqliteRepository) HasDeletedAgentProfiles(ctx context.Context, agentID string) (bool, error) {
var exists int
err := r.ro.QueryRowContext(ctx,
r.ro.Rebind(`SELECT 1 FROM agent_profiles WHERE agent_id = ? AND deleted_at IS NOT NULL LIMIT 1`),
agentID).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// applyLegacyBackfill returns the profile with CLIFlags populated from the
// legacy allow_indexing column for Auggie rows that predate the cli_flags
// column. The backfill is scoped to auggie only so a legacy Claude/Codex/
// Copilot row (possible via the column's DEFAULT 1) never gains an
// unsupported --allow-indexing flag. scanAgentProfile leaves CLIFlags nil
// when the stored JSON is absent; we resolve the agent name lazily via a
// small lookup here rather than JOINing in the main SELECT (keeps the
// read query unchanged and avoids cross-test flakiness).
func (r *sqliteRepository) applyLegacyBackfill(ctx context.Context, profile *models.AgentProfile) *models.AgentProfile {
if profile == nil || profile.CLIFlags != nil {
return profile
}
if !profile.AllowIndexing {
profile.CLIFlags = []models.CLIFlag{}
return profile
}
agent, err := r.GetAgent(ctx, profile.AgentID)
profile.CLIFlags = legacyCLIFlagsForAgent(agent, err)
return profile
}
// legacyCLIFlagsForAgent is applyLegacyBackfill/applyLegacyBackfillTx's
// shared decision: only a resolved "auggie" agent backfills the
// --allow-indexing flag; a lookup failure, a missing agent, or any other
// agent name backfills an empty list instead.
func legacyCLIFlagsForAgent(agent *models.Agent, err error) []models.CLIFlag {
if err != nil || agent == nil || agent.Name != "auggie" {
return []models.CLIFlag{}
}
return []models.CLIFlag{{
Description: "Allow workspace indexing without confirmation",
Flag: "--allow-indexing",
Enabled: true,
}}
}
func (r *sqliteRepository) ListTUIAgents(ctx context.Context) ([]*models.Agent, error) {
return r.listAgentsWhere(ctx, "tui_config IS NOT NULL")
}
func (r *sqliteRepository) listAgentsWhere(ctx context.Context, where string) ([]*models.Agent, error) {
rows, err := r.ro.QueryContext(ctx,
`SELECT id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at
FROM agents WHERE `+where+` ORDER BY created_at DESC`,
)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var result []*models.Agent
for rows.Next() {
agent, scanErr := scanAgent(rows)
if scanErr != nil {
return nil, scanErr
}
result = append(result, agent)
}
return result, rows.Err()
}
func scanAgent(scanner interface {
Scan(dest ...any) error
}) (*models.Agent, error) {
agent := &models.Agent{}
var supportsMCP int
var workspaceID sql.NullString
var tuiConfigRaw sql.NullString
if err := scanner.Scan(
&agent.ID,
&agent.Name,
&workspaceID,
&supportsMCP,
&agent.MCPConfigPath,
&tuiConfigRaw,
&agent.CreatedAt,
&agent.UpdatedAt,
); err != nil {
return nil, err
}
if workspaceID.Valid {
agent.WorkspaceID = &workspaceID.String
}
agent.SupportsMCP = supportsMCP == 1
if tuiConfigRaw.Valid {
var cfg models.TUIConfigJSON
if err := json.Unmarshal([]byte(tuiConfigRaw.String), &cfg); err != nil {
return nil, fmt.Errorf("failed to parse tui_config: %w", err)
}
agent.TUIConfig = &cfg
}
return agent, nil
}
func scanAgentProfile(scanner interface {
Scan(dest ...any) error
}) (*models.AgentProfile, error) {
profile := &models.AgentProfile{}
var mode sql.NullString
var migratedFrom sql.NullString
var autoApprove int
var skipPermissions int
var allowIndexing int
var cliPassthrough int
var enabled int
var userModified int
var plan string // unused, kept for backwards compatibility
var cliFlagsRaw sql.NullString
var envVarsRaw sql.NullString
var role, status string
var skipIdleRuns int
var failureThreshold int
var autoFallback int
var requireExactModel int
var cursorMCPAuthEnabled int
if err := scanner.Scan(
&profile.ID,
&profile.AgentID,
&profile.Name,
&profile.AgentDisplayName,
&profile.Model,
&mode,
&migratedFrom,
&autoApprove,
&skipPermissions,
&allowIndexing,
&cliPassthrough,
&enabled,
&userModified,
&plan,
&cliFlagsRaw,
&envVarsRaw,
&profile.CreatedAt,
&profile.UpdatedAt,
&profile.DeletedAt,
&profile.WorkspaceID,
&role,
&profile.Icon,
&profile.ReportsTo,
&profile.SkillIDs,
&profile.DesiredSkills,
&profile.CustomPrompt,
&status,
&profile.PauseReason,
&profile.LastRunFinishedAt,
&profile.MaxConcurrentSessions,
&profile.CooldownSec,
&skipIdleRuns,
&profile.ConsecutiveFailures,
&failureThreshold,
&profile.ExecutorPreference,
&profile.BudgetMonthlyCents,
&profile.Settings,
&profile.Permissions,
&profile.CommandPrefix,
&profile.FallbackModel,
&autoFallback,
&profile.ProviderKind,
&profile.ProviderBaseURL,
&profile.ProviderAPIKeySecretID,
&requireExactModel,
&cursorMCPAuthEnabled,
); err != nil {
return nil, err
}
if mode.Valid {
profile.Mode = mode.String
}
if migratedFrom.Valid {
profile.MigratedFrom = migratedFrom.String
}
profile.AutoApprove = autoApprove == 1
profile.DangerouslySkipPermissions = skipPermissions == 1
profile.AllowIndexing = allowIndexing == 1
profile.CLIPassthrough = cliPassthrough == 1
profile.Enabled = enabled == 1
profile.UserModified = userModified == 1
profile.SkipIdleRuns = skipIdleRuns == 1
profile.AutoFallback = autoFallback == 1
profile.RequireExactModel = requireExactModel == 1
profile.CursorMCPAuthEnabled = cursorMCPAuthEnabled == 1
profile.Role = models.AgentRole(role)
profile.Status = models.AgentStatus(status)
profile.ConfigOptions = configOptionsFromSettings(profile.Settings)
if failureThreshold > 0 {
ft := failureThreshold
profile.FailureThreshold = &ft
}
if cliFlagsRaw.Valid && cliFlagsRaw.String != "" {
if err := json.Unmarshal([]byte(cliFlagsRaw.String), &profile.CLIFlags); err != nil {
return nil, fmt.Errorf("failed to parse cli_flags for profile %s: %w", profile.ID, err)
}
}
if envVarsRaw.Valid && envVarsRaw.String != "" {
if err := json.Unmarshal([]byte(envVarsRaw.String), &profile.EnvVars); err != nil {
return nil, fmt.Errorf("failed to parse env_vars for profile %s: %w", profile.ID, err)
}
}
// When cli_flags is NULL the caller (GetAgentProfile / ListAgentProfiles)
// runs applyLegacyBackfill to seed the list scoped to the owning agent.
// Leaving CLIFlags as nil here is deliberate: non-nil vs nil discriminates
// "stored empty list" from "never written". applyLegacyBackfill rewrites
// nil → []CLIFlag{} so downstream code never sees a nil slice.
return profile, nil
}