1706 lines
61 KiB
Go
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, ¬null, &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
|
|
}
|