feat: improve db abstraction (#238)

This commit is contained in:
Carlos Florêncio
2026-02-17 17:37:55 +00:00
committed by GitHub
parent 90c9b0e74a
commit 9baa985bbf
58 changed files with 1270 additions and 1162 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
package main
import (
"database/sql"
"github.com/jmoiron/sqlx"
analyticsrepository "github.com/kandev/kandev/internal/analytics/repository"
"github.com/kandev/kandev/internal/common/config"
@@ -17,7 +17,7 @@ import (
userstore "github.com/kandev/kandev/internal/user/store"
)
func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repositories, []func() error, error) {
func provideRepositories(cfg *config.Config, log *logger.Logger) (*sqlx.DB, *Repositories, []func() error, error) {
cleanups := make([]func() error, 0, 6)
dbConn, cleanup, err := persistence.Provide(cfg, log)
if err != nil {
+3 -2
View File
@@ -2,7 +2,8 @@ package main
import (
"context"
"database/sql"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/common/config"
@@ -57,7 +58,7 @@ func (a *bootMsgAdapter) UpdateMessage(ctx context.Context, message *models.Mess
return a.svc.UpdateMessage(ctx, message)
}
func provideWorktreeManager(dbConn *sql.DB, cfg *config.Config, log *logger.Logger, lifecycleMgr *lifecycle.Manager, taskSvc *taskservice.Service) (*worktree.Manager, *worktree.Recreator, func() error, error) {
func provideWorktreeManager(dbConn *sqlx.DB, cfg *config.Config, log *logger.Logger, lifecycleMgr *lifecycle.Manager, taskSvc *taskservice.Service) (*worktree.Manager, *worktree.Recreator, func() error, error) {
manager, cleanup, err := worktree.Provide(dbConn, cfg, log)
if err != nil {
return nil, nil, nil, err
+6
View File
@@ -12,6 +12,8 @@ require (
github.com/github/copilot-sdk/go v0.1.18
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.8.0
github.com/jmoiron/sqlx v1.4.0
github.com/mark3labs/mcp-go v0.43.2
github.com/mattn/go-sqlite3 v1.14.33
github.com/nats-io/nats.go v1.31.0
@@ -47,6 +49,9 @@ require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
@@ -88,6 +93,7 @@ require (
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.44.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
+19
View File
@@ -36,6 +36,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -121,6 +123,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -196,6 +200,16 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@@ -219,6 +233,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
@@ -227,6 +243,7 @@ github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
@@ -441,6 +458,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1,9 +1,9 @@
package store
import "database/sql"
import "github.com/jmoiron/sqlx"
// Provide creates the SQLite agent settings store using the shared database connection.
func Provide(db *sql.DB) (*sqliteRepository, func() error, error) {
func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) {
repo, err := newSQLiteRepositoryWithDB(db)
if err != nil {
return nil, nil, err
@@ -8,23 +8,24 @@ import (
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/db/dialect"
)
type sqliteRepository struct {
db *sql.DB
db *sqlx.DB
ownsDB bool
}
var _ Repository = (*sqliteRepository)(nil)
func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) {
func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) {
return newSQLiteRepository(dbConn, false)
}
func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) {
func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) {
repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB}
if err := repo.initSchema(); err != nil {
if ownsDB {
@@ -45,8 +46,8 @@ func (r *sqliteRepository) initSchema() error {
workspace_id TEXT DEFAULT NULL,
supports_mcp INTEGER NOT NULL DEFAULT 0,
mcp_config_path TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS agent_profiles (
@@ -61,9 +62,9 @@ func (r *sqliteRepository) initSchema() error {
cli_passthrough INTEGER NOT NULL DEFAULT 0,
user_modified INTEGER NOT NULL DEFAULT 0,
plan TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
@@ -72,8 +73,8 @@ func (r *sqliteRepository) initSchema() error {
enabled INTEGER NOT NULL DEFAULT 0,
servers_json TEXT NOT NULL DEFAULT '{}',
meta_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE
);
@@ -86,10 +87,6 @@ func (r *sqliteRepository) initSchema() error {
return err
}
// Migration: add user_modified column to existing databases.
// Error is intentionally ignored: ALTER TABLE fails with "duplicate column" if it already exists.
_, _ = r.db.Exec(`ALTER TABLE agent_profiles ADD COLUMN user_modified INTEGER NOT NULL DEFAULT 0`)
return nil
}
@@ -107,35 +104,35 @@ func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent)
now := time.Now().UTC()
agent.CreatedAt = now
agent.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, agent.ID, agent.Name, agent.WorkspaceID, sqlite.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt)
`), agent.ID, agent.Name, agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt)
return err
}
func (r *sqliteRepository) GetAgent(ctx context.Context, id string) (*models.Agent, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at
FROM agents WHERE id = ?
`, id)
`), id)
return scanAgent(row)
}
func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*models.Agent, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at
FROM agents WHERE name = ?
`, name)
`), name)
return scanAgent(row)
}
func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) error {
agent.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ?
WHERE id = ?
`, agent.WorkspaceID, sqlite.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
`), agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
if err != nil {
return err
}
@@ -147,7 +144,7 @@ func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent)
}
func (r *sqliteRepository) DeleteAgent(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM agents WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM agents WHERE id = ?`), id)
if err != nil {
return err
}
@@ -182,11 +179,11 @@ func (r *sqliteRepository) ListAgents(ctx context.Context) ([]*models.Agent, err
}
func (r *sqliteRepository) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*models.AgentProfileMcpConfig, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT profile_id, enabled, servers_json, meta_json, created_at, updated_at
FROM agent_profile_mcp_configs
WHERE profile_id = ?
`, profileID)
`), profileID)
var config models.AgentProfileMcpConfig
var enabled int
@@ -230,7 +227,7 @@ func (r *sqliteRepository) UpsertAgentProfileMcpConfig(ctx context.Context, conf
return fmt.Errorf("failed to serialize MCP meta: %w", err)
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.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
@@ -238,7 +235,7 @@ func (r *sqliteRepository) UpsertAgentProfileMcpConfig(ctx context.Context, conf
servers_json = excluded.servers_json,
meta_json = excluded.meta_json,
updated_at = excluded.updated_at
`, config.ProfileID, sqlite.BoolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt)
`), config.ProfileID, dialect.BoolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt)
return err
}
@@ -249,22 +246,22 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode
now := time.Now().UTC()
profile.CreatedAt = now
profile.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?)
`, profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove),
sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), sqlite.BoolToInt(profile.UserModified), profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt)
`), profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, dialect.BoolToInt(profile.AutoApprove),
dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), dialect.BoolToInt(profile.CLIPassthrough), dialect.BoolToInt(profile.UserModified), profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt)
return err
}
func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error {
profile.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE agent_profiles
SET name = ?, agent_display_name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, user_modified = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove),
sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), sqlite.BoolToInt(profile.UserModified), profile.UpdatedAt, profile.ID)
`), profile.Name, profile.AgentDisplayName, profile.Model, dialect.BoolToInt(profile.AutoApprove),
dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), dialect.BoolToInt(profile.CLIPassthrough), dialect.BoolToInt(profile.UserModified), profile.UpdatedAt, profile.ID)
if err != nil {
return err
}
@@ -277,9 +274,9 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
func (r *sqliteRepository) DeleteAgentProfile(ctx context.Context, id string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
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)
`), now, now, id)
if err != nil {
return err
}
@@ -291,18 +288,18 @@ func (r *sqliteRepository) DeleteAgentProfile(ctx context.Context, id string) er
}
func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at
FROM agent_profiles WHERE id = ? AND deleted_at IS NULL
`, id)
`), id)
return scanAgentProfile(row)
}
func (r *sqliteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at
FROM agent_profiles WHERE agent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC
`, agentID)
`), agentID)
if err != nil {
return nil, err
}
@@ -1,13 +1,13 @@
package repository
import (
"database/sql"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/analytics/repository/sqlite"
)
// Provide creates the SQLite analytics repository using the shared database connection.
func Provide(db *sql.DB) (Repository, func() error, error) {
func Provide(db *sqlx.DB) (Repository, func() error, error) {
repo, err := sqlite.NewWithDB(db)
if err != nil {
return nil, nil, err
@@ -1,16 +1,16 @@
// Package sqlite provides SQLite-based analytics repository implementations.
package sqlite
import "database/sql"
import "github.com/jmoiron/sqlx"
// Repository provides SQLite-based analytics operations.
type Repository struct {
db *sql.DB
db *sqlx.DB
}
// NewWithDB creates a new analytics repository with an existing database connection.
// It automatically creates performance indexes for stats queries.
func NewWithDB(dbConn *sql.DB) (*Repository, error) {
func NewWithDB(dbConn *sqlx.DB) (*Repository, error) {
repo := &Repository{db: dbConn}
if err := repo.ensureStatsIndexes(); err != nil {
return nil, err
@@ -79,7 +79,6 @@ func (r *Repository) ensureStatsIndexes() error {
`CREATE INDEX IF NOT EXISTS idx_repos_workspace
ON repositories(workspace_id)
WHERE deleted_at IS NULL`,
}
for _, idx := range indexes {
@@ -90,4 +89,3 @@ func (r *Repository) ensureStatsIndexes() error {
return nil
}
@@ -2,38 +2,39 @@ package sqlite
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
)
// createTestDB sets up a SQLite database with all required schemas for analytics queries.
func createTestDB(t *testing.T) *sql.DB {
func createTestDB(t *testing.T) *sqlx.DB {
t.Helper()
tmpDir := t.TempDir()
dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("failed to open sqlite db: %v", err)
}
t.Cleanup(func() { _ = dbConn.Close() })
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
t.Cleanup(func() { _ = sqlxDB.Close() })
// Create tables that analytics queries depend on
schema := `
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS workflow_steps (
id TEXT PRIMARY KEY,
@@ -45,8 +46,8 @@ func createTestDB(t *testing.T) *sql.DB {
events TEXT,
allow_manual_move INTEGER DEFAULT 1,
auto_archive_after_hours INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
@@ -55,25 +56,25 @@ func createTestDB(t *testing.T) *sql.DB {
workflow_step_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
state TEXT DEFAULT 'TODO',
archived_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
archived_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS repositories (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
source_type TEXT NOT NULL DEFAULT 'local',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS task_repositories (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE(task_id, repository_id)
);
CREATE TABLE IF NOT EXISTS task_sessions (
@@ -83,18 +84,18 @@ func createTestDB(t *testing.T) *sql.DB {
agent_profile_snapshot TEXT DEFAULT '{}',
repository_id TEXT DEFAULT '',
state TEXT NOT NULL DEFAULT 'CREATED',
started_at DATETIME NOT NULL,
completed_at DATETIME,
updated_at DATETIME NOT NULL
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS task_session_turns (
id TEXT PRIMARY KEY,
task_session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
started_at DATETIME NOT NULL,
completed_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS task_session_messages (
id TEXT PRIMARY KEY,
@@ -103,26 +104,26 @@ func createTestDB(t *testing.T) *sql.DB {
author_type TEXT NOT NULL DEFAULT 'user',
type TEXT NOT NULL DEFAULT 'message',
content TEXT NOT NULL,
created_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS task_session_commits (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
commit_sha TEXT NOT NULL,
committed_at DATETIME NOT NULL,
committed_at TIMESTAMP NOT NULL,
files_changed INTEGER DEFAULT 0,
insertions INTEGER DEFAULT 0,
deletions INTEGER DEFAULT 0,
created_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL
);
`
if _, err := dbConn.Exec(schema); err != nil {
if _, err := sqlxDB.Exec(schema); err != nil {
t.Fatalf("failed to create schema: %v", err)
}
return dbConn
return sqlxDB
}
func execOrFatal(t *testing.T, dbConn *sql.DB, query string, args ...any) {
func execOrFatal(t *testing.T, dbConn *sqlx.DB, query string, args ...any) {
t.Helper()
if _, err := dbConn.Exec(query, args...); err != nil {
t.Fatalf("exec failed: %v", err)
@@ -3,9 +3,11 @@ package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/kandev/kandev/internal/analytics/models"
"github.com/kandev/kandev/internal/db/dialect"
)
// parseTimeString parses time strings in various SQLite formats
@@ -33,36 +35,32 @@ func parseTimeString(s string) time.Time {
// GetTaskStats retrieves aggregated statistics for all tasks in a workspace
func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.TaskStats, error) {
var startArg interface{}
var startArg any
if start != nil {
startArg = start.UTC().Format(time.RFC3339)
}
// Use separate subqueries for counts and duration to avoid row multiplication
query := `
drv := r.db.DriverName()
dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at")
query := fmt.Sprintf(`
SELECT
t.id,
t.title,
t.workspace_id,
t.workflow_id,
t.state,
t.id, t.title, t.workspace_id, t.workflow_id, t.state,
COALESCE(session_stats.session_count, 0) as session_count,
COALESCE(session_stats.turn_count, 0) as turn_count,
COALESCE(session_stats.message_count, 0) as message_count,
COALESCE(session_stats.user_message_count, 0) as user_message_count,
COALESCE(session_stats.tool_call_count, 0) as tool_call_count,
COALESCE(turn_stats.total_duration_ms, 0) as total_duration_ms,
t.created_at,
session_stats.last_completed_at
t.created_at, session_stats.last_completed_at
FROM tasks t
LEFT JOIN (
SELECT
s.task_id,
SELECT s.task_id,
COUNT(DISTINCT s.id) as session_count,
COUNT(DISTINCT turn.id) as turn_count,
COUNT(DISTINCT msg.id) as message_count,
COUNT(DISTINCT CASE WHEN msg.author_type = 'user' THEN msg.id END) as user_message_count,
COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%' THEN msg.id END) as tool_call_count,
COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%%' THEN msg.id END) as tool_call_count,
MAX(s.completed_at) as last_completed_at
FROM task_sessions s
LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id
@@ -71,13 +69,8 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
GROUP BY s.task_id
) session_stats ON session_stats.task_id = t.id
LEFT JOIN (
SELECT
s.task_id,
SUM(CASE
WHEN turn.completed_at IS NOT NULL
THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000
ELSE 0
END) as total_duration_ms
SELECT s.task_id,
SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END) as total_duration_ms
FROM task_sessions s
LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id
WHERE (? IS NULL OR s.started_at >= ?)
@@ -85,13 +78,10 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
) turn_stats ON turn_stats.task_id = t.id
WHERE t.workspace_id = ? AND (? IS NULL OR t.created_at >= ?)
ORDER BY t.updated_at DESC
`
`, dur)
rows, err := r.db.QueryContext(
ctx,
query,
startArg, startArg,
startArg, startArg,
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query),
startArg, startArg, startArg, startArg,
workspaceID, startArg, startArg,
)
if err != nil {
@@ -99,34 +89,27 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
}
defer func() { _ = rows.Close() }()
return r.scanTaskStats(rows)
}
func (r *Repository) scanTaskStats(rows *sql.Rows) ([]*models.TaskStats, error) {
var results []*models.TaskStats
for rows.Next() {
var stat models.TaskStats
var completedAtStr sql.NullString // Use NullString to handle legacy string dates
var createdAtStr string // Handle legacy string dates
var totalDurationMs float64 // SQLite returns float from julianday math
var completedAtStr sql.NullString
var createdAtStr string
var totalDurationMs float64
err := rows.Scan(
&stat.TaskID,
&stat.TaskTitle,
&stat.WorkspaceID,
&stat.WorkflowID,
&stat.State,
&stat.SessionCount,
&stat.TurnCount,
&stat.MessageCount,
&stat.UserMessageCount,
&stat.ToolCallCount,
&totalDurationMs,
&createdAtStr,
&completedAtStr,
&stat.TaskID, &stat.TaskTitle, &stat.WorkspaceID, &stat.WorkflowID, &stat.State,
&stat.SessionCount, &stat.TurnCount, &stat.MessageCount,
&stat.UserMessageCount, &stat.ToolCallCount, &totalDurationMs,
&createdAtStr, &completedAtStr,
)
if err != nil {
return nil, err
}
stat.TotalDurationMs = int64(totalDurationMs)
// Parse created_at from string
stat.CreatedAt = parseTimeString(createdAtStr)
// Parse completed_at from string if valid
if completedAtStr.Valid && completedAtStr.String != "" {
parsedTime := parseTimeString(completedAtStr.String)
if !parsedTime.IsZero() {
@@ -135,20 +118,20 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
}
results = append(results, &stat)
}
return results, rows.Err()
}
// GetGlobalStats retrieves workspace-wide aggregated statistics
func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GlobalStats, error) {
var startArg interface{}
var startArg any
if start != nil {
startArg = start.UTC().Format(time.RFC3339)
}
// Use separate subqueries to avoid row multiplication from JOINs
// Count tasks in the last workflow step (highest position) as completed
query := `
drv := r.db.DriverName()
dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at")
query := fmt.Sprintf(`
SELECT
(SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND (? IS NULL OR created_at >= ?)) as total_tasks,
(SELECT COUNT(*) FROM tasks t
@@ -174,20 +157,18 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta
(SELECT COUNT(*) FROM task_session_messages msg
JOIN task_sessions s ON s.id = msg.task_session_id
JOIN tasks t ON t.id = s.task_id
WHERE t.workspace_id = ? AND msg.type LIKE 'tool_%' AND (? IS NULL OR s.started_at >= ?)) as total_tool_calls,
WHERE t.workspace_id = ? AND msg.type LIKE 'tool_%%' AND (? IS NULL OR s.started_at >= ?)) as total_tool_calls,
(SELECT COALESCE(SUM(
CASE WHEN turn.completed_at IS NOT NULL
THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000
ELSE 0 END
CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END
), 0) FROM task_session_turns turn
JOIN task_sessions s ON s.id = turn.task_session_id
JOIN tasks t ON t.id = s.task_id
WHERE t.workspace_id = ? AND (? IS NULL OR s.started_at >= ?)) as total_duration_ms
`
`, dur)
var stats models.GlobalStats
var totalDurationMs float64 // SQLite returns float from julianday math
err := r.db.QueryRowContext(ctx, query,
var totalDurationMs float64
err := r.db.QueryRowContext(ctx, r.db.Rebind(query),
workspaceID, startArg, startArg,
workspaceID, startArg, startArg,
workspaceID, startArg, startArg,
@@ -198,22 +179,15 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta
workspaceID, startArg, startArg,
workspaceID, startArg, startArg,
).Scan(
&stats.TotalTasks,
&stats.CompletedTasks,
&stats.InProgressTasks,
&stats.TotalSessions,
&stats.TotalTurns,
&stats.TotalMessages,
&stats.TotalUserMessages,
&stats.TotalToolCalls,
&totalDurationMs,
&stats.TotalTasks, &stats.CompletedTasks, &stats.InProgressTasks,
&stats.TotalSessions, &stats.TotalTurns, &stats.TotalMessages,
&stats.TotalUserMessages, &stats.TotalToolCalls, &totalDurationMs,
)
if err != nil {
return nil, err
}
stats.TotalDurationMs = int64(totalDurationMs)
// Calculate averages
if stats.TotalTasks > 0 {
stats.AvgTurnsPerTask = float64(stats.TotalTurns) / float64(stats.TotalTasks)
stats.AvgMessagesPerTask = float64(stats.TotalMessages) / float64(stats.TotalTasks)
@@ -225,13 +199,18 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta
// GetDailyActivity retrieves daily activity statistics for the last N days
func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, days int) ([]*models.DailyActivity, error) {
query := `
drv := r.db.DriverName()
dateStart := dialect.DateNowMinusDays(drv, "?")
datePlus := dialect.DatePlusOneDay(drv, "date")
curDate := dialect.CurrentDate(drv)
dateOfTurn := dialect.DateOf(drv, "turn.started_at")
dateOfMsg := dialect.DateOf(drv, "msg.created_at")
query := fmt.Sprintf(`
WITH RECURSIVE dates(date) AS (
SELECT date('now', '-' || ? || ' days')
SELECT %s
UNION ALL
SELECT date(date, '+1 day')
FROM dates
WHERE date < date('now')
SELECT %s FROM dates WHERE date < %s
)
SELECT
d.date,
@@ -241,7 +220,7 @@ func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, d
FROM dates d
LEFT JOIN (
SELECT
date(turn.started_at) as activity_date,
%s as activity_date,
COUNT(DISTINCT turn.id) as turn_count,
COUNT(DISTINCT msg.id) as message_count,
COUNT(DISTINCT t.id) as task_count
@@ -249,14 +228,14 @@ func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, d
JOIN task_sessions s ON s.id = turn.task_session_id
JOIN tasks t ON t.id = s.task_id
LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id
AND date(msg.created_at) = date(turn.started_at)
AND %s = %s
WHERE t.workspace_id = ?
GROUP BY date(turn.started_at)
GROUP BY %s
) activity ON activity.activity_date = d.date
ORDER BY d.date ASC
`
`, dateStart, datePlus, curDate, dateOfTurn, dateOfMsg, dateOfTurn, dateOfTurn)
rows, err := r.db.QueryContext(ctx, query, days-1, workspaceID)
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), days-1, workspaceID)
if err != nil {
return nil, err
}
@@ -265,13 +244,7 @@ func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, d
var results []*models.DailyActivity
for rows.Next() {
var activity models.DailyActivity
err := rows.Scan(
&activity.Date,
&activity.TurnCount,
&activity.MessageCount,
&activity.TaskCount,
)
if err != nil {
if err := rows.Scan(&activity.Date, &activity.TurnCount, &activity.MessageCount, &activity.TaskCount); err != nil {
return nil, err
}
results = append(results, &activity)
@@ -282,39 +255,37 @@ func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, d
// GetCompletedTaskActivity retrieves completed task counts for the last N days
func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID string, days int) ([]*models.CompletedTaskActivity, error) {
query := `
drv := r.db.DriverName()
dateStart := dialect.DateNowMinusDays(drv, "?")
datePlus := dialect.DatePlusOneDay(drv, "date")
curDate := dialect.CurrentDate(drv)
dateOfCompleted := dialect.DateOf(drv, "ts.completed_at")
query := fmt.Sprintf(`
WITH RECURSIVE dates(date) AS (
SELECT date('now', '-' || ? || ' days')
SELECT %s
UNION ALL
SELECT date(date, '+1 day')
FROM dates
WHERE date < date('now')
SELECT %s FROM dates WHERE date < %s
)
SELECT
d.date,
COALESCE(activity.completed_tasks, 0) as completed_tasks
SELECT d.date, COALESCE(activity.completed_tasks, 0) as completed_tasks
FROM dates d
LEFT JOIN (
SELECT
date(ts.completed_at) as activity_date,
COUNT(DISTINCT t.id) as completed_tasks
SELECT %s as activity_date, COUNT(DISTINCT t.id) as completed_tasks
FROM tasks t
LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id
JOIN (
SELECT task_id, MAX(completed_at) as completed_at
FROM task_sessions
WHERE completed_at IS NOT NULL
GROUP BY task_id
FROM task_sessions WHERE completed_at IS NOT NULL GROUP BY task_id
) ts ON ts.task_id = t.id
WHERE t.workspace_id = ?
AND (t.archived_at IS NOT NULL
OR ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id))
GROUP BY date(ts.completed_at)
GROUP BY %s
) activity ON activity.activity_date = d.date
ORDER BY d.date ASC
`
`, dateStart, datePlus, curDate, dateOfCompleted, dateOfCompleted)
rows, err := r.db.QueryContext(ctx, query, days-1, workspaceID)
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), days-1, workspaceID)
if err != nil {
return nil, err
}
@@ -323,11 +294,7 @@ func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID s
var results []*models.CompletedTaskActivity
for rows.Next() {
var activity models.CompletedTaskActivity
err := rows.Scan(
&activity.Date,
&activity.CompletedTasks,
)
if err != nil {
if err := rows.Scan(&activity.Date, &activity.CompletedTasks); err != nil {
return nil, err
}
results = append(results, &activity)
@@ -338,15 +305,17 @@ func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID s
// GetRepositoryStats retrieves aggregated statistics for repositories in a workspace
func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.RepositoryStats, error) {
var startArg interface{}
var startArg any
if start != nil {
startArg = start.UTC().Format(time.RFC3339)
}
query := `
drv := r.db.DriverName()
dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at")
query := fmt.Sprintf(`
SELECT
r.id,
r.name,
r.id, r.name,
COALESCE(task_stats.total_tasks, 0) as total_tasks,
COALESCE(task_stats.completed_tasks, 0) as completed_tasks,
COALESCE(task_stats.in_progress_tasks, 0) as in_progress_tasks,
@@ -362,8 +331,7 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string,
COALESCE(git_stats.total_deletions, 0) as total_deletions
FROM repositories r
LEFT JOIN (
SELECT
tr.repository_id,
SELECT tr.repository_id,
COUNT(DISTINCT t.id) as total_tasks,
COUNT(DISTINCT CASE WHEN ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) THEN t.id END) as completed_tasks,
COUNT(DISTINCT CASE WHEN t.state = 'IN_PROGRESS' THEN t.id END) as in_progress_tasks
@@ -374,13 +342,12 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string,
GROUP BY tr.repository_id
) task_stats ON task_stats.repository_id = r.id
LEFT JOIN (
SELECT
tr.repository_id,
SELECT tr.repository_id,
COUNT(DISTINCT s.id) as session_count,
COUNT(DISTINCT turn.id) as turn_count,
COUNT(DISTINCT msg.id) as message_count,
COUNT(DISTINCT CASE WHEN msg.author_type = 'user' THEN msg.id END) as user_message_count,
COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%' THEN msg.id END) as tool_call_count
COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%%' THEN msg.id END) as tool_call_count
FROM task_repositories tr
JOIN task_sessions s ON s.task_id = tr.task_id
LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id
@@ -389,13 +356,8 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string,
GROUP BY tr.repository_id
) session_stats ON session_stats.repository_id = r.id
LEFT JOIN (
SELECT
tr.repository_id,
COALESCE(SUM(CASE
WHEN turn.completed_at IS NOT NULL
THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000
ELSE 0
END), 0) as total_duration_ms
SELECT tr.repository_id,
COALESCE(SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END), 0) as total_duration_ms
FROM task_repositories tr
JOIN task_sessions s ON s.task_id = tr.task_id
LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id
@@ -403,8 +365,7 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string,
GROUP BY tr.repository_id
) duration_stats ON duration_stats.repository_id = r.id
LEFT JOIN (
SELECT
s.repository_id,
SELECT s.repository_id,
COUNT(DISTINCT c.id) as total_commits,
COALESCE(SUM(c.files_changed), 0) as total_files_changed,
COALESCE(SUM(c.insertions), 0) as total_insertions,
@@ -414,18 +375,13 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string,
WHERE s.repository_id != '' AND (? IS NULL OR c.committed_at >= ?)
GROUP BY s.repository_id
) git_stats ON git_stats.repository_id = r.id
WHERE r.workspace_id = ?
AND r.deleted_at IS NULL
WHERE r.workspace_id = ? AND r.deleted_at IS NULL
ORDER BY total_duration_ms DESC, total_tasks DESC, r.name ASC
`
`, dur)
rows, err := r.db.QueryContext(
ctx,
query,
startArg, startArg,
startArg, startArg,
startArg, startArg,
startArg, startArg,
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query),
startArg, startArg, startArg, startArg,
startArg, startArg, startArg, startArg,
workspaceID,
)
if err != nil {
@@ -438,21 +394,12 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string,
var stats models.RepositoryStats
var totalDurationMs float64
err := rows.Scan(
&stats.RepositoryID,
&stats.RepositoryName,
&stats.TotalTasks,
&stats.CompletedTasks,
&stats.InProgressTasks,
&stats.SessionCount,
&stats.TurnCount,
&stats.MessageCount,
&stats.UserMessageCount,
&stats.ToolCallCount,
&totalDurationMs,
&stats.TotalCommits,
&stats.TotalFilesChanged,
&stats.TotalInsertions,
&stats.TotalDeletions,
&stats.RepositoryID, &stats.RepositoryName,
&stats.TotalTasks, &stats.CompletedTasks, &stats.InProgressTasks,
&stats.SessionCount, &stats.TurnCount, &stats.MessageCount,
&stats.UserMessageCount, &stats.ToolCallCount, &totalDurationMs,
&stats.TotalCommits, &stats.TotalFilesChanged,
&stats.TotalInsertions, &stats.TotalDeletions,
)
if err != nil {
return nil, err
@@ -466,32 +413,27 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string,
// GetAgentUsage retrieves usage statistics per agent profile
func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limit int, start *time.Time) ([]*models.AgentUsage, error) {
var startArg interface{}
var startArg any
if start != nil {
startArg = start.UTC().Format(time.RFC3339)
}
query := `
drv := r.db.DriverName()
dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at")
jeName := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "name")
jeDisplay := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "agent_display_name")
jeModel := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "model")
jeModelName := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "model_name")
jeLLM := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "llm")
query := fmt.Sprintf(`
SELECT
s.agent_profile_id,
COALESCE(
json_extract(s.agent_profile_snapshot, '$.name'),
json_extract(s.agent_profile_snapshot, '$.agent_display_name'),
s.agent_profile_id
) as agent_profile_name,
COALESCE(
json_extract(s.agent_profile_snapshot, '$.model'),
json_extract(s.agent_profile_snapshot, '$.model_name'),
json_extract(s.agent_profile_snapshot, '$.llm'),
''
) as agent_model,
COALESCE(%s, %s, s.agent_profile_id) as agent_profile_name,
COALESCE(%s, %s, %s, '') as agent_model,
COUNT(DISTINCT s.id) as session_count,
COUNT(DISTINCT turn.id) as turn_count,
COALESCE(SUM(CASE
WHEN turn.completed_at IS NOT NULL
THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000
ELSE 0
END), 0) as total_duration_ms
COALESCE(SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END), 0) as total_duration_ms
FROM task_sessions s
JOIN tasks t ON t.id = s.task_id
LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id
@@ -499,9 +441,9 @@ func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limi
GROUP BY s.agent_profile_id
ORDER BY session_count DESC
LIMIT ?
`
`, jeName, jeDisplay, jeModel, jeModelName, jeLLM, dur)
rows, err := r.db.QueryContext(ctx, query, workspaceID, startArg, startArg, limit)
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), workspaceID, startArg, startArg, limit)
if err != nil {
return nil, err
}
@@ -512,12 +454,8 @@ func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limi
var usage models.AgentUsage
var totalDurationMs float64
err := rows.Scan(
&usage.AgentProfileID,
&usage.AgentProfileName,
&usage.AgentModel,
&usage.SessionCount,
&usage.TurnCount,
&totalDurationMs,
&usage.AgentProfileID, &usage.AgentProfileName, &usage.AgentModel,
&usage.SessionCount, &usage.TurnCount, &totalDurationMs,
)
if err != nil {
return nil, err
@@ -531,7 +469,7 @@ func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limi
// GetGitStats retrieves aggregated git statistics for a workspace
func (r *Repository) GetGitStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GitStats, error) {
var startArg interface{}
var startArg any
if start != nil {
startArg = start.UTC().Format(time.RFC3339)
}
@@ -549,11 +487,9 @@ func (r *Repository) GetGitStats(ctx context.Context, workspaceID string, start
`
var stats models.GitStats
err := r.db.QueryRowContext(ctx, query, workspaceID, startArg, startArg).Scan(
&stats.TotalCommits,
&stats.TotalFilesChanged,
&stats.TotalInsertions,
&stats.TotalDeletions,
err := r.db.QueryRowContext(ctx, r.db.Rebind(query), workspaceID, startArg, startArg).Scan(
&stats.TotalCommits, &stats.TotalFilesChanged,
&stats.TotalInsertions, &stats.TotalDeletions,
)
if err != nil {
return nil, err
@@ -36,6 +36,8 @@ type ServerConfig struct {
// DatabaseConfig holds database connection configuration.
type DatabaseConfig struct {
Driver string `mapstructure:"driver"`
Path string `mapstructure:"path"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
@@ -161,6 +163,8 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("server.writeTimeout", 30)
// Database defaults
v.SetDefault("database.driver", "sqlite")
v.SetDefault("database.path", "./kandev.db")
v.SetDefault("database.host", "localhost")
v.SetDefault("database.port", 5432)
v.SetDefault("database.user", "kandev")
@@ -302,16 +306,16 @@ func validate(cfg *Config) error {
errs = append(errs, "server.port must be between 1 and 65535")
}
// Database validation - only if host is set (optional for in-memory mode)
if cfg.Database.Host != "" {
// Database validation
if cfg.Database.Driver == "postgres" {
if cfg.Database.Port <= 0 || cfg.Database.Port > 65535 {
errs = append(errs, "database.port must be between 1 and 65535")
}
if cfg.Database.User == "" {
errs = append(errs, "database.user is required when database.host is set")
errs = append(errs, "database.user is required for postgres driver")
}
if cfg.Database.DBName == "" {
errs = append(errs, "database.dbName is required when database.host is set")
errs = append(errs, "database.dbName is required for postgres driver")
}
}
@@ -1,56 +0,0 @@
// Package sqlite provides common SQLite utility functions.
package sqlite
import (
"database/sql"
"fmt"
)
// BoolToInt converts a boolean to an integer (for SQLite).
func BoolToInt(value bool) int {
if value {
return 1
}
return 0
}
// EnsureColumn adds a column to a table if it doesn't exist.
func EnsureColumn(db *sql.DB, table, column, definition string) error {
exists, err := ColumnExists(db, table, column)
if err != nil {
return err
}
if exists {
return nil
}
query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)
_, err = db.Exec(query)
return err
}
// ColumnExists checks if a column exists in a table.
func ColumnExists(db *sql.DB, table, column string) (bool, error) {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer func() {
_ = rows.Close()
}()
for rows.Next() {
var cid int
var name, colType string
var notNull int
var defaultValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &colType, &notNull, &defaultValue, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
@@ -0,0 +1,20 @@
// Package dialect provides SQL fragment helpers for SQLite/PostgreSQL portability.
package dialect
const (
SQLite3 = "sqlite3"
PGX = "pgx"
)
// IsPostgres returns true if the driver is PostgreSQL (pgx).
func IsPostgres(driver string) bool {
return driver == PGX
}
// BoolToInt converts a boolean to an integer for SQL storage.
func BoolToInt(value bool) int {
if value {
return 1
}
return 0
}
@@ -0,0 +1,174 @@
package dialect
import (
"context"
"path/filepath"
"testing"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
)
func TestIsPostgres(t *testing.T) {
if !IsPostgres(PGX) {
t.Error("expected pgx to be postgres")
}
if IsPostgres(SQLite3) {
t.Error("expected sqlite3 to not be postgres")
}
}
func TestBoolToInt(t *testing.T) {
if BoolToInt(true) != 1 {
t.Error("expected 1 for true")
}
if BoolToInt(false) != 0 {
t.Error("expected 0 for false")
}
}
func TestJSONExtract(t *testing.T) {
got := JSONExtract(SQLite3, "metadata", "status")
if got != "json_extract(metadata, '$.status')" {
t.Errorf("sqlite: got %q", got)
}
got = JSONExtract(PGX, "metadata", "status")
if got != "metadata::jsonb->>'status'" {
t.Errorf("pgx: got %q", got)
}
}
func TestJSONExtractIsNotNull(t *testing.T) {
got := JSONExtractIsNotNull(SQLite3, "m", "id")
if got != "json_extract(m, '$.id') IS NOT NULL" {
t.Errorf("sqlite: got %q", got)
}
got = JSONExtractIsNotNull(PGX, "m", "id")
if got != "m::jsonb->>'id' IS NOT NULL" {
t.Errorf("pgx: got %q", got)
}
}
func TestJSONSet(t *testing.T) {
got := JSONSet(SQLite3, "metadata", "status", "complete")
if got != "json_set(metadata, '$.status', 'complete')" {
t.Errorf("sqlite: got %q", got)
}
got = JSONSet(PGX, "metadata", "status", "complete")
if got != `jsonb_set(metadata::jsonb, '{status}', '"complete"')::text` {
t.Errorf("pgx: got %q", got)
}
}
func TestDurationMs(t *testing.T) {
got := DurationMs(SQLite3, "completed_at", "started_at")
if got != "(julianday(completed_at) - julianday(started_at)) * 86400000" {
t.Errorf("sqlite: got %q", got)
}
got = DurationMs(PGX, "completed_at", "started_at")
if got != "EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000" {
t.Errorf("pgx: got %q", got)
}
}
func TestDateOf(t *testing.T) {
got := DateOf(SQLite3, "created_at")
if got != "date(created_at)" {
t.Errorf("sqlite: got %q", got)
}
got = DateOf(PGX, "created_at")
if got != "(created_at)::date" {
t.Errorf("pgx: got %q", got)
}
}
func TestNow(t *testing.T) {
if Now(SQLite3) != "datetime('now')" {
t.Errorf("sqlite: got %q", Now(SQLite3))
}
if Now(PGX) != "NOW()" {
t.Errorf("pgx: got %q", Now(PGX))
}
}
func TestNowMinusHours(t *testing.T) {
got := NowMinusHours(SQLite3, "ws.hours")
if got != "datetime('now', '-' || ws.hours || ' hours')" {
t.Errorf("sqlite: got %q", got)
}
got = NowMinusHours(PGX, "ws.hours")
if got != "NOW() - (ws.hours || ' hours')::interval" {
t.Errorf("pgx: got %q", got)
}
}
func TestCurrentDate(t *testing.T) {
if CurrentDate(SQLite3) != "date('now')" {
t.Errorf("sqlite: got %q", CurrentDate(SQLite3))
}
if CurrentDate(PGX) != "CURRENT_DATE" {
t.Errorf("pgx: got %q", CurrentDate(PGX))
}
}
func TestDateNowMinusDays(t *testing.T) {
got := DateNowMinusDays(SQLite3, "?")
if got != "date('now', '-' || ? || ' days')" {
t.Errorf("sqlite: got %q", got)
}
got = DateNowMinusDays(PGX, "?")
if got != "CURRENT_DATE - (? || ' days')::interval" {
t.Errorf("pgx: got %q", got)
}
}
func TestDatePlusOneDay(t *testing.T) {
got := DatePlusOneDay(SQLite3, "date")
if got != "date(date, '+1 day')" {
t.Errorf("sqlite: got %q", got)
}
got = DatePlusOneDay(PGX, "date")
if got != "(date)::date + INTERVAL '1 day'" {
t.Errorf("pgx: got %q", got)
}
}
func TestLike(t *testing.T) {
if Like(SQLite3) != "LIKE" {
t.Errorf("sqlite: got %q", Like(SQLite3))
}
if Like(PGX) != "ILIKE" {
t.Errorf("pgx: got %q", Like(PGX))
}
}
func TestInsertReturningID_SQLite(t *testing.T) {
tmpDir := t.TempDir()
rawDB, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
sqlxDB := sqlx.NewDb(rawDB, SQLite3)
t.Cleanup(func() { _ = sqlxDB.Close() })
_, err = sqlxDB.Exec(`CREATE TABLE test_insert (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`)
if err != nil {
t.Fatalf("create table: %v", err)
}
id, err := InsertReturningID(context.Background(), sqlxDB, `INSERT INTO test_insert (name) VALUES (?)`, "hello")
if err != nil {
t.Fatalf("insert: %v", err)
}
if id != 1 {
t.Errorf("expected id 1, got %d", id)
}
id, err = InsertReturningID(context.Background(), sqlxDB, `INSERT INTO test_insert (name) VALUES (?)`, "world")
if err != nil {
t.Fatalf("insert: %v", err)
}
if id != 2 {
t.Errorf("expected id 2, got %d", id)
}
}
@@ -0,0 +1,29 @@
package dialect
import (
"context"
"fmt"
"github.com/jmoiron/sqlx"
)
// InsertReturningID executes an INSERT and returns the auto-generated ID.
//
// Postgres: appends RETURNING id and scans the result.
// SQLite: uses LastInsertId() from the exec result.
func InsertReturningID(ctx context.Context, db *sqlx.DB, query string, args ...any) (int64, error) {
if IsPostgres(db.DriverName()) {
var id int64
err := db.QueryRowContext(ctx, db.Rebind(query+" RETURNING id"), args...).Scan(&id)
if err != nil {
return 0, fmt.Errorf("insert returning id: %w", err)
}
return id, nil
}
result, err := db.ExecContext(ctx, db.Rebind(query), args...)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
+33
View File
@@ -0,0 +1,33 @@
package dialect
import "fmt"
// JSONExtract returns the SQL fragment to extract a JSON value.
//
// SQLite: json_extract(col, '$.path')
// Postgres: col::jsonb->>'path'
func JSONExtract(driver, col, path string) string {
if IsPostgres(driver) {
return fmt.Sprintf("%s::jsonb->>'%s'", col, path)
}
return fmt.Sprintf("json_extract(%s, '$.%s')", col, path)
}
// JSONExtractIsNotNull returns the SQL fragment to check that a JSON path is not null.
//
// SQLite: json_extract(col, '$.path') IS NOT NULL
// Postgres: col::jsonb->>'path' IS NOT NULL
func JSONExtractIsNotNull(driver, col, path string) string {
return JSONExtract(driver, col, path) + " IS NOT NULL"
}
// JSONSet returns the SQL fragment to set a JSON value.
//
// SQLite: json_set(col, '$.path', 'value')
// Postgres: jsonb_set(col::jsonb, '{path}', '"value"')::text
func JSONSet(driver, col, path, value string) string {
if IsPostgres(driver) {
return fmt.Sprintf("jsonb_set(%s::jsonb, '{%s}', '\"%s\"')::text", col, path, value)
}
return fmt.Sprintf("json_set(%s, '$.%s', '%s')", col, path, value)
}
@@ -0,0 +1,12 @@
package dialect
// Like returns the SQL LIKE operator appropriate for the driver.
//
// SQLite: LIKE (case-insensitive for ASCII by default)
// Postgres: ILIKE (case-insensitive)
func Like(driver string) string {
if IsPostgres(driver) {
return "ILIKE"
}
return "LIKE"
}
+82
View File
@@ -0,0 +1,82 @@
package dialect
import "fmt"
// DurationMs returns the SQL expression for the difference between two timestamps in milliseconds.
//
// SQLite: (julianday(end) - julianday(start)) * 86400000
// Postgres: EXTRACT(EPOCH FROM (end - start)) * 1000
func DurationMs(driver, end, start string) string {
if IsPostgres(driver) {
return fmt.Sprintf("EXTRACT(EPOCH FROM (%s - %s)) * 1000", end, start)
}
return fmt.Sprintf("(julianday(%s) - julianday(%s)) * 86400000", end, start)
}
// DateOf returns the SQL expression to extract the date portion from a timestamp.
//
// SQLite: date(expr)
// Postgres: (expr)::date
func DateOf(driver, expr string) string {
if IsPostgres(driver) {
return fmt.Sprintf("(%s)::date", expr)
}
return fmt.Sprintf("date(%s)", expr)
}
// Now returns the SQL expression for the current timestamp.
//
// SQLite: datetime('now')
// Postgres: NOW()
func Now(driver string) string {
if IsPostgres(driver) {
return "NOW()"
}
return "datetime('now')"
}
// NowMinusHours returns the SQL expression for "current time minus N hours",
// where hoursExpr is a column or expression producing the number of hours.
//
// SQLite: datetime('now', '-' || hoursExpr || ' hours')
// Postgres: NOW() - (hoursExpr || ' hours')::interval
func NowMinusHours(driver, hoursExpr string) string {
if IsPostgres(driver) {
return fmt.Sprintf("NOW() - (%s || ' hours')::interval", hoursExpr)
}
return fmt.Sprintf("datetime('now', '-' || %s || ' hours')", hoursExpr)
}
// CurrentDate returns the SQL expression for the current date (no time component).
//
// SQLite: date('now')
// Postgres: CURRENT_DATE
func CurrentDate(driver string) string {
if IsPostgres(driver) {
return "CURRENT_DATE"
}
return "date('now')"
}
// DateNowMinusDays returns the SQL expression for "current date minus N days",
// where daysExpr is a parameter placeholder (e.g., "?") for the number of days.
//
// SQLite: date('now', '-' || ? || ' days')
// Postgres: CURRENT_DATE - (? || ' days')::interval
func DateNowMinusDays(driver, daysExpr string) string {
if IsPostgres(driver) {
return fmt.Sprintf("CURRENT_DATE - (%s || ' days')::interval", daysExpr)
}
return fmt.Sprintf("date('now', '-' || %s || ' days')", daysExpr)
}
// DatePlusOneDay returns the SQL expression to add one day to a date expression.
//
// SQLite: date(expr, '+1 day')
// Postgres: (expr)::date + INTERVAL '1 day'
func DatePlusOneDay(driver, expr string) string {
if IsPostgres(driver) {
return fmt.Sprintf("(%s)::date + INTERVAL '1 day'", expr)
}
return fmt.Sprintf("date(%s, '+1 day')", expr)
}
+34
View File
@@ -0,0 +1,34 @@
package db
import (
"database/sql"
"fmt"
_ "github.com/jackc/pgx/v5/stdlib"
)
// OpenPostgres opens a PostgreSQL database connection using pgx.
// If maxConns or minConns are 0, they default to 25 and 5 respectively.
func OpenPostgres(dsn string, maxConns, minConns int) (*sql.DB, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open postgres database: %w", err)
}
if maxConns <= 0 {
maxConns = 25
}
if minConns <= 0 {
minConns = 5
}
db.SetMaxOpenConns(maxConns)
db.SetMaxIdleConns(minConns)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("failed to ping postgres database: %w", err)
}
return db, nil
}
@@ -1,9 +1,9 @@
package store
import "database/sql"
import "github.com/jmoiron/sqlx"
// Provide creates the SQLite editors store using the shared database connection.
func Provide(db *sql.DB) (*sqliteRepository, func() error, error) {
func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) {
repo, err := newSQLiteRepositoryWithDB(db)
if err != nil {
return nil, nil, err
+21 -20
View File
@@ -10,24 +10,25 @@ import (
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/editors/discovery"
"github.com/kandev/kandev/internal/editors/models"
)
type sqliteRepository struct {
db *sql.DB
db *sqlx.DB
ownsDB bool
}
var _ Repository = (*sqliteRepository)(nil)
func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) {
func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) {
return newSQLiteRepository(dbConn, false)
}
func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) {
func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) {
repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB}
if err := repo.initSchema(); err != nil {
if ownsDB {
@@ -67,8 +68,8 @@ func (r *sqliteRepository) initSchema() error {
config TEXT,
installed INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
`
_, err := r.db.Exec(schema)
@@ -146,20 +147,20 @@ func (r *sqliteRepository) ListEditors(ctx context.Context) ([]*models.Editor, e
}
func (r *sqliteRepository) GetEditorByType(ctx context.Context, editorType string) (*models.Editor, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at
FROM editors
WHERE type = ?
`, editorType)
`), editorType)
return scanEditor(row)
}
func (r *sqliteRepository) GetEditorByID(ctx context.Context, editorID string) (*models.Editor, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at
FROM editors
WHERE id = ?
`, editorID)
`), editorID)
return scanEditor(row)
}
@@ -174,7 +175,7 @@ func (r *sqliteRepository) UpsertEditors(ctx context.Context, editors []*models.
}
}()
stmt, err := tx.PrepareContext(ctx, `
stmt, err := tx.PrepareContext(ctx, r.db.Rebind(`
INSERT INTO editors (id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(type) DO UPDATE SET
@@ -186,7 +187,7 @@ func (r *sqliteRepository) UpsertEditors(ctx context.Context, editors []*models.
installed = excluded.installed,
enabled = excluded.enabled,
updated_at = excluded.updated_at
`)
`))
if err != nil {
_ = tx.Rollback()
return err
@@ -219,8 +220,8 @@ func (r *sqliteRepository) UpsertEditors(ctx context.Context, editors []*models.
editor.Command,
editor.Scheme,
configValue,
sqlite.BoolToInt(editor.Installed),
sqlite.BoolToInt(editor.Enabled),
dialect.BoolToInt(editor.Installed),
dialect.BoolToInt(editor.Enabled),
editor.CreatedAt,
editor.UpdatedAt,
)
@@ -248,10 +249,10 @@ func (r *sqliteRepository) CreateEditor(ctx context.Context, editor *models.Edit
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO editors (id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, editor.ID, editor.Type, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, sqlite.BoolToInt(editor.Installed), sqlite.BoolToInt(editor.Enabled), editor.CreatedAt, editor.UpdatedAt)
`), editor.ID, editor.Type, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, dialect.BoolToInt(editor.Installed), dialect.BoolToInt(editor.Enabled), editor.CreatedAt, editor.UpdatedAt)
return err
}
@@ -264,16 +265,16 @@ func (r *sqliteRepository) UpdateEditor(ctx context.Context, editor *models.Edit
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE editors
SET name = ?, kind = ?, command = ?, scheme = ?, config = ?, installed = ?, enabled = ?, updated_at = ?
WHERE id = ?
`, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, sqlite.BoolToInt(editor.Installed), sqlite.BoolToInt(editor.Enabled), editor.UpdatedAt, editor.ID)
`), editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, dialect.BoolToInt(editor.Installed), dialect.BoolToInt(editor.Enabled), editor.UpdatedAt, editor.ID)
return err
}
func (r *sqliteRepository) DeleteEditor(ctx context.Context, editorID string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM editors WHERE id = ?`, editorID)
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM editors WHERE id = ?`), editorID)
return err
}
@@ -329,6 +330,6 @@ func (r *sqliteRepository) deleteBuiltinsNotIn(ctx context.Context, types map[st
`DELETE FROM editors WHERE kind = 'built_in' AND type NOT IN (%s)`,
strings.Join(placeholders, ","),
)
_, err := r.db.ExecContext(ctx, query, args...)
_, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...)
return err
}
@@ -16,6 +16,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -65,11 +66,12 @@ func NewTestServer(t *testing.T) *TestServer {
tmpDir := t.TempDir()
dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
require.NoError(t, err)
taskRepoImpl, cleanup, err := repository.Provide(dbConn)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
taskRepoImpl, cleanup, err := repository.Provide(sqlxDB)
require.NoError(t, err)
taskRepo := repository.Repository(taskRepoImpl)
t.Cleanup(func() {
if err := dbConn.Close(); err != nil {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if cleanup != nil {
@@ -78,12 +80,12 @@ func NewTestServer(t *testing.T) *TestServer {
}
}
})
if _, err := worktree.NewSQLiteStore(dbConn); err != nil {
if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil {
t.Fatalf("failed to init worktree store: %v", err)
}
// Initialize workflow service
_, workflowSvc, _, err := workflow.Provide(dbConn, log)
_, workflowSvc, _, err := workflow.Provide(sqlxDB, log)
require.NoError(t, err)
// Initialize task service and wire workflow step creator
@@ -18,6 +18,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
@@ -637,11 +638,12 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
tmpDir := t.TempDir()
dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
require.NoError(t, err)
taskRepoImpl, cleanup, err := repository.Provide(dbConn)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
taskRepoImpl, cleanup, err := repository.Provide(sqlxDB)
require.NoError(t, err)
taskRepo := repository.Repository(taskRepoImpl)
t.Cleanup(func() {
if err := dbConn.Close(); err != nil {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if cleanup != nil {
@@ -650,12 +652,12 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
}
}
})
if _, err := worktree.NewSQLiteStore(dbConn); err != nil {
if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil {
t.Fatalf("failed to init worktree store: %v", err)
}
// Initialize workflow service
_, workflowSvc, _, err := workflow.Provide(dbConn, log)
_, workflowSvc, _, err := workflow.Provide(sqlxDB, log)
require.NoError(t, err)
// Initialize task service and wire workflow step creator
@@ -1,9 +1,9 @@
package store
import "database/sql"
import "github.com/jmoiron/sqlx"
// Provide creates the SQLite notifications store using the shared database connection.
func Provide(db *sql.DB) (*sqliteRepository, func() error, error) {
func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) {
repo, err := newSQLiteRepositoryWithDB(db)
if err != nil {
return nil, nil, err
@@ -2,29 +2,29 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/notifications/models"
)
type sqliteRepository struct {
db *sql.DB
db *sqlx.DB
ownsDB bool
}
var _ Repository = (*sqliteRepository)(nil)
func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) {
func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) {
return newSQLiteRepository(dbConn, false)
}
func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) {
func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) {
repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB}
if err := repo.initSchema(); err != nil {
if ownsDB {
@@ -53,8 +53,8 @@ func (r *sqliteRepository) initSchema() error {
type TEXT NOT NULL,
config TEXT DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS notification_subscriptions (
@@ -63,8 +63,8 @@ func (r *sqliteRepository) initSchema() error {
provider_id TEXT NOT NULL,
event_type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE(provider_id, event_type),
FOREIGN KEY (provider_id) REFERENCES notification_providers(id) ON DELETE CASCADE
);
@@ -75,7 +75,7 @@ func (r *sqliteRepository) initSchema() error {
provider_id TEXT NOT NULL,
event_type TEXT NOT NULL,
task_session_id TEXT NOT NULL,
created_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE(provider_id, event_type, task_session_id)
);
@@ -101,10 +101,10 @@ func (r *sqliteRepository) CreateProvider(ctx context.Context, provider *models.
if err != nil {
return fmt.Errorf("failed to serialize provider config: %w", err)
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), sqlite.BoolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt)
`), provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), dialect.BoolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt)
return err
}
@@ -117,30 +117,30 @@ func (r *sqliteRepository) UpdateProvider(ctx context.Context, provider *models.
if err != nil {
return fmt.Errorf("failed to serialize provider config: %w", err)
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE notification_providers
SET name = ?, type = ?, config = ?, enabled = ?, updated_at = ?
WHERE id = ?
`, provider.Name, provider.Type, string(configJSON), sqlite.BoolToInt(provider.Enabled), provider.UpdatedAt, provider.ID)
`), provider.Name, provider.Type, string(configJSON), dialect.BoolToInt(provider.Enabled), provider.UpdatedAt, provider.ID)
return err
}
func (r *sqliteRepository) GetProvider(ctx context.Context, id string) (*models.Provider, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, user_id, name, type, config, enabled, created_at, updated_at
FROM notification_providers
WHERE id = ?
`, id)
`), id)
return scanProvider(row)
}
func (r *sqliteRepository) ListProvidersByUser(ctx context.Context, userID string) ([]*models.Provider, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, user_id, name, type, config, enabled, created_at, updated_at
FROM notification_providers
WHERE user_id = ?
ORDER BY created_at ASC
`, userID)
`), userID)
if err != nil {
return nil, err
}
@@ -163,17 +163,17 @@ func (r *sqliteRepository) ListProvidersByUser(ctx context.Context, userID strin
}
func (r *sqliteRepository) DeleteProvider(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM notification_providers WHERE id = ?`, id)
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM notification_providers WHERE id = ?`), id)
return err
}
func (r *sqliteRepository) ListSubscriptionsByProvider(ctx context.Context, providerID string) ([]*models.Subscription, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, user_id, provider_id, event_type, enabled, created_at, updated_at
FROM notification_subscriptions
WHERE provider_id = ?
ORDER BY created_at ASC
`, providerID)
`), providerID)
if err != nil {
return nil, err
}
@@ -201,17 +201,17 @@ func (r *sqliteRepository) ReplaceSubscriptions(ctx context.Context, providerID,
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM notification_subscriptions WHERE provider_id = ?`, providerID); err != nil {
if _, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM notification_subscriptions WHERE provider_id = ?`), providerID); err != nil {
_ = tx.Rollback()
return err
}
now := time.Now().UTC()
for _, eventType := range events {
subID := uuid.New().String()
if _, err := tx.ExecContext(ctx, `
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
INSERT INTO notification_subscriptions (id, user_id, provider_id, event_type, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, subID, userID, providerID, eventType, sqlite.BoolToInt(true), now, now); err != nil {
`), subID, userID, providerID, eventType, dialect.BoolToInt(true), now, now); err != nil {
_ = tx.Rollback()
return err
}
@@ -225,10 +225,11 @@ func (r *sqliteRepository) ReplaceSubscriptions(ctx context.Context, providerID,
func (r *sqliteRepository) InsertDelivery(ctx context.Context, delivery *models.Delivery) (bool, error) {
delivery.ID = uuid.New().String()
delivery.CreatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
INSERT OR IGNORE INTO notification_deliveries (id, user_id, provider_id, event_type, task_session_id, created_at)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO notification_deliveries (id, user_id, provider_id, event_type, task_session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`, delivery.ID, delivery.UserID, delivery.ProviderID, delivery.EventType, delivery.TaskSessionID, delivery.CreatedAt)
ON CONFLICT DO NOTHING
`), delivery.ID, delivery.UserID, delivery.ProviderID, delivery.EventType, delivery.TaskSessionID, delivery.CreatedAt)
if err != nil {
return false, err
}
@@ -240,10 +241,10 @@ func (r *sqliteRepository) InsertDelivery(ctx context.Context, delivery *models.
}
func (r *sqliteRepository) DeleteDelivery(ctx context.Context, providerID, eventType, taskSessionID string) error {
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
DELETE FROM notification_deliveries
WHERE provider_id = ? AND event_type = ? AND task_session_id = ?
`, providerID, eventType, taskSessionID)
`), providerID, eventType, taskSessionID)
return err
}
@@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/db"
@@ -132,9 +133,10 @@ func setupTestRepo(t *testing.T) repository.Repository {
if err != nil {
t.Fatalf("failed to open test database: %v", err)
}
t.Cleanup(func() { _ = dbConn.Close() })
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
t.Cleanup(func() { _ = sqlxDB.Close() })
repo, cleanup, err := repository.Provide(dbConn)
repo, cleanup, err := repository.Provide(sqlxDB)
if err != nil {
t.Fatalf("failed to create test repository: %v", err)
}
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/orchestrator/executor"
@@ -139,13 +140,14 @@ func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Lo
if err != nil {
t.Fatalf("failed to open test database: %v", err)
}
repoImpl, cleanup, err := repository.Provide(dbConn)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repoImpl, cleanup, err := repository.Provide(sqlxDB)
if err != nil {
t.Fatalf("failed to create test repository: %v", err)
}
repo := repository.Repository(repoImpl)
t.Cleanup(func() {
if err := dbConn.Close(); err != nil {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if err := cleanup(); err != nil {
+32 -14
View File
@@ -1,10 +1,9 @@
package persistence
import (
"database/sql"
"fmt"
"os"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/config"
@@ -13,25 +12,26 @@ import (
)
// Provide creates the database connection used by repositories.
func Provide(cfg *config.Config, log *logger.Logger) (*sql.DB, func() error, error) {
_ = cfg
driver := os.Getenv("KANDEV_DB_DRIVER")
func Provide(cfg *config.Config, log *logger.Logger) (*sqlx.DB, func() error, error) {
driver := cfg.Database.Driver
if driver == "" {
driver = "sqlite"
}
dbPath := os.Getenv("KANDEV_DB_PATH")
if dbPath == "" {
dbPath = "./kandev.db"
}
switch driver {
case "sqlite":
dbPath := cfg.Database.Path
if dbPath == "" {
dbPath = "./kandev.db"
}
dbConn, err := db.OpenSQLite(dbPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
if log != nil {
log.Info("Database initialized", zap.String("db_path", dbPath), zap.String("db_driver", driver))
}
@@ -39,10 +39,28 @@ func Provide(cfg *config.Config, log *logger.Logger) (*sql.DB, func() error, err
// Run PRAGMA optimize before closing to update query planner
// statistics for tables that need it. This is the SQLite-recommended
// way to maintain stats — lightweight and safe to call on every close.
_, _ = dbConn.Exec("PRAGMA optimize")
return dbConn.Close()
_, _ = sqlxDB.Exec("PRAGMA optimize")
return sqlxDB.Close()
}
return dbConn, cleanup, nil
return sqlxDB, cleanup, nil
case "postgres":
dsn := cfg.Database.DSN()
dbConn, err := db.OpenPostgres(dsn, cfg.Database.MaxConns, cfg.Database.MinConns)
if err != nil {
return nil, nil, fmt.Errorf("failed to open postgres database: %w", err)
}
sqlxDB := sqlx.NewDb(dbConn, "pgx")
if log != nil {
log.Info("Database initialized", zap.String("db_driver", driver))
}
cleanup := func() error {
return sqlxDB.Close()
}
return sqlxDB, cleanup, nil
default:
return nil, nil, fmt.Errorf("unsupported database driver: %s", driver)
}
@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
promptstore "github.com/kandev/kandev/internal/prompts/store"
)
@@ -16,12 +17,13 @@ func createService(t *testing.T) (*Service, func()) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
repoImpl, repoCleanup, err := promptstore.Provide(dbConn)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repoImpl, repoCleanup, err := promptstore.Provide(sqlxDB)
if err != nil {
t.Fatalf("create repo: %v", err)
}
cleanup := func() {
if err := dbConn.Close(); err != nil {
if err := sqlxDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
if err := repoCleanup(); err != nil {
@@ -1,9 +1,9 @@
package store
import "database/sql"
import "github.com/jmoiron/sqlx"
// Provide creates the SQLite prompts store using the shared database connection.
func Provide(db *sql.DB) (*sqliteRepository, func() error, error) {
func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) {
repo, err := newSQLiteRepositoryWithDB(db)
if err != nil {
return nil, nil, err
+18 -17
View File
@@ -9,20 +9,21 @@ import (
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/prompts/models"
)
type sqliteRepository struct {
db *sql.DB
db *sqlx.DB
ownsDB bool
}
func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) {
func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) {
return newSQLiteRepository(dbConn, false)
}
func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) {
func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) {
repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB}
if err := repo.initSchema(); err != nil {
if ownsDB {
@@ -42,8 +43,8 @@ func (r *sqliteRepository) initSchema() error {
name TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
builtin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
`
if _, err := r.db.Exec(schema); err != nil {
@@ -95,11 +96,11 @@ func (r *sqliteRepository) ListPrompts(ctx context.Context) ([]*models.Prompt, e
}
func (r *sqliteRepository) GetPromptByID(ctx context.Context, id string) (*models.Prompt, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, name, content, builtin, created_at, updated_at
FROM custom_prompts
WHERE id = ?
`, id)
`), id)
prompt := &models.Prompt{}
var builtinInt int
if err := row.Scan(&prompt.ID, &prompt.Name, &prompt.Content, &builtinInt, &prompt.CreatedAt, &prompt.UpdatedAt); err != nil {
@@ -110,11 +111,11 @@ func (r *sqliteRepository) GetPromptByID(ctx context.Context, id string) (*model
}
func (r *sqliteRepository) GetPromptByName(ctx context.Context, name string) (*models.Prompt, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, name, content, builtin, created_at, updated_at
FROM custom_prompts
WHERE name = ?
`, name)
`), name)
prompt := &models.Prompt{}
var builtinInt int
if err := row.Scan(&prompt.ID, &prompt.Name, &prompt.Content, &builtinInt, &prompt.CreatedAt, &prompt.UpdatedAt); err != nil {
@@ -140,10 +141,10 @@ func (r *sqliteRepository) CreatePrompt(ctx context.Context, prompt *models.Prom
builtinInt = 1
}
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`, prompt.ID, prompt.Name, prompt.Content, builtinInt, prompt.CreatedAt, prompt.UpdatedAt)
`), prompt.ID, prompt.Name, prompt.Content, builtinInt, prompt.CreatedAt, prompt.UpdatedAt)
return err
}
@@ -154,16 +155,16 @@ func (r *sqliteRepository) UpdatePrompt(ctx context.Context, prompt *models.Prom
prompt.Name = strings.TrimSpace(prompt.Name)
prompt.Content = strings.TrimSpace(prompt.Content)
prompt.UpdatedAt = time.Now().UTC()
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE custom_prompts
SET name = ?, content = ?, updated_at = ?
WHERE id = ?
`, prompt.Name, prompt.Content, prompt.UpdatedAt, prompt.ID)
`), prompt.Name, prompt.Content, prompt.UpdatedAt, prompt.ID)
return err
}
func (r *sqliteRepository) DeletePrompt(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM custom_prompts WHERE id = ?`, id)
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM custom_prompts WHERE id = ?`), id)
return err
}
@@ -174,7 +175,7 @@ func (r *sqliteRepository) seedBuiltinPrompts() error {
for _, prompt := range builtinPrompts {
// Check if prompt already exists
var exists bool
err := r.db.QueryRow("SELECT 1 FROM custom_prompts WHERE id = ?", prompt.ID).Scan(&exists)
err := r.db.QueryRow(r.db.Rebind("SELECT 1 FROM custom_prompts WHERE id = ?"), prompt.ID).Scan(&exists)
if err != nil && err != sql.ErrNoRows {
return err
}
@@ -183,10 +184,10 @@ func (r *sqliteRepository) seedBuiltinPrompts() error {
}
// Insert built-in prompt
_, err = r.db.Exec(`
_, err = r.db.Exec(r.db.Rebind(`
INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`, prompt.ID, prompt.Name, prompt.Content, 1, prompt.CreatedAt, prompt.UpdatedAt)
`), prompt.ID, prompt.Name, prompt.Content, 1, prompt.CreatedAt, prompt.UpdatedAt)
if err != nil {
return fmt.Errorf("failed to insert built-in prompt %s: %w", prompt.ID, err)
}
@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/prompts/models"
)
@@ -16,12 +17,13 @@ func createTestRepo(t *testing.T) (*sqliteRepository, func()) {
if err != nil {
t.Fatalf("failed to open sqlite db: %v", err)
}
repo, err := newSQLiteRepositoryWithDB(dbConn)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repo, err := newSQLiteRepositoryWithDB(sqlxDB)
if err != nil {
t.Fatalf("failed to create repo: %v", err)
}
cleanup := func() {
if err := dbConn.Close(); err != nil {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if err := repo.Close(); err != nil {
@@ -1,13 +1,13 @@
package repository
import (
"database/sql"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/task/repository/sqlite"
)
// Provide creates the SQLite repository using the shared database connection.
func Provide(db *sql.DB) (Repository, func() error, error) {
func Provide(db *sqlx.DB) (Repository, func() error, error) {
repo, err := sqlite.NewWithDB(db)
if err != nil {
return nil, nil, err
@@ -4,20 +4,22 @@ package sqlite
import (
"database/sql"
"fmt"
"github.com/jmoiron/sqlx"
)
// Repository provides SQLite-based task storage operations.
type Repository struct {
db *sql.DB
db *sqlx.DB
ownsDB bool
}
// NewWithDB creates a new SQLite repository with an existing database connection (shared ownership).
func NewWithDB(dbConn *sql.DB) (*Repository, error) {
func NewWithDB(dbConn *sqlx.DB) (*Repository, error) {
return newRepository(dbConn, false)
}
func newRepository(dbConn *sql.DB, ownsDB bool) (*Repository, error) {
func newRepository(dbConn *sqlx.DB, ownsDB bool) (*Repository, error) {
repo := &Repository{db: dbConn, ownsDB: ownsDB}
if err := repo.initSchema(); err != nil {
if ownsDB {
@@ -40,7 +42,7 @@ func (r *Repository) Close() error {
// DB returns the underlying sql.DB instance for shared access
func (r *Repository) DB() *sql.DB {
return r.db
return r.db.DB
}
// ensureWorkspaceIndexes creates workspace-related indexes
@@ -65,8 +67,8 @@ func (r *Repository) initSchema() error {
default_executor_id TEXT DEFAULT '',
default_environment_id TEXT DEFAULT '',
default_agent_profile_id TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS executors (
@@ -77,9 +79,9 @@ func (r *Repository) initSchema() error {
is_system INTEGER NOT NULL DEFAULT 0,
resumable INTEGER NOT NULL DEFAULT 1,
config TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS executors_running (
@@ -99,10 +101,10 @@ func (r *Repository) initSchema() error {
worktree_id TEXT DEFAULT '',
worktree_path TEXT DEFAULT '',
worktree_branch TEXT DEFAULT '',
last_seen_at DATETIME,
last_seen_at TIMESTAMP,
error_message TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS environments (
@@ -114,9 +116,9 @@ func (r *Repository) initSchema() error {
image_tag TEXT DEFAULT '',
dockerfile TEXT DEFAULT '',
build_config TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS workflows (
@@ -125,8 +127,8 @@ func (r *Repository) initSchema() error {
workflow_template_id TEXT DEFAULT '',
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
@@ -140,8 +142,9 @@ func (r *Repository) initSchema() error {
priority INTEGER DEFAULT 0,
position INTEGER DEFAULT 0,
metadata TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
archived_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
);
@@ -152,8 +155,8 @@ func (r *Repository) initSchema() error {
base_branch TEXT DEFAULT '',
position INTEGER DEFAULT 0,
metadata TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
UNIQUE(task_id, repository_id)
@@ -175,9 +178,9 @@ func (r *Repository) initSchema() error {
setup_script TEXT DEFAULT '',
cleanup_script TEXT DEFAULT '',
dev_script TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP,
FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE
);
@@ -187,13 +190,14 @@ func (r *Repository) initSchema() error {
name TEXT NOT NULL,
command TEXT NOT NULL,
position INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id);
CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id);
CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at);
CREATE INDEX IF NOT EXISTS idx_task_repositories_task_id ON task_repositories(task_id);
CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id);
CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id);
@@ -212,8 +216,8 @@ func (r *Repository) initSchema() error {
title TEXT NOT NULL DEFAULT 'Plan',
content TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT 'agent',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_task_plans_task_id ON task_plans(task_id);
@@ -235,7 +239,7 @@ func (r *Repository) initSchema() error {
requests_input INTEGER DEFAULT 0,
type TEXT NOT NULL DEFAULT 'message',
metadata TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (turn_id) REFERENCES task_session_turns(id) ON DELETE CASCADE
);
@@ -249,11 +253,11 @@ func (r *Repository) initSchema() error {
id TEXT PRIMARY KEY,
task_session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
started_at DATETIME NOT NULL,
completed_at DATETIME,
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
metadata TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
);
@@ -278,11 +282,12 @@ func (r *Repository) initSchema() error {
state TEXT NOT NULL DEFAULT 'CREATED',
error_message TEXT DEFAULT '',
metadata TEXT DEFAULT '{}',
started_at DATETIME NOT NULL,
completed_at DATETIME,
updated_at DATETIME NOT NULL,
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL,
is_primary INTEGER DEFAULT 0,
workflow_step_id TEXT DEFAULT '',
is_passthrough INTEGER DEFAULT 0,
review_status TEXT DEFAULT '',
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
@@ -300,10 +305,10 @@ func (r *Repository) initSchema() error {
worktree_path TEXT DEFAULT '',
worktree_branch TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
merged_at DATETIME,
deleted_at DATETIME,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
merged_at TIMESTAMP,
deleted_at TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE,
UNIQUE(session_id, worktree_id)
);
@@ -333,7 +338,7 @@ func (r *Repository) initSchema() error {
files TEXT DEFAULT '{}',
triggered_by TEXT DEFAULT '',
metadata TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
);
@@ -348,13 +353,13 @@ func (r *Repository) initSchema() error {
author_name TEXT DEFAULT '',
author_email TEXT DEFAULT '',
commit_message TEXT DEFAULT '',
committed_at DATETIME NOT NULL,
committed_at TIMESTAMP NOT NULL,
pre_commit_snapshot_id TEXT DEFAULT '',
post_commit_snapshot_id TEXT DEFAULT '',
files_changed INTEGER DEFAULT 0,
insertions INTEGER DEFAULT 0,
deletions INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
);
@@ -373,9 +378,9 @@ func (r *Repository) initSchema() error {
file_path TEXT NOT NULL,
reviewed INTEGER NOT NULL DEFAULT 0,
diff_hash TEXT NOT NULL DEFAULT '',
reviewed_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
reviewed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE,
UNIQUE(session_id, file_path)
);
@@ -398,58 +403,6 @@ func (r *Repository) initSchema() error {
return err
}
if err := r.ensureArchivedAtColumn(); err != nil {
return err
}
if err := r.ensureIsPassthroughColumn(); err != nil {
return err
}
return nil
}
// ensureColumnExists checks if a column exists on a table and runs the provided
// SQL statements to add it if missing.
func (r *Repository) ensureColumnExists(table, column string, migrationSQL ...string) error {
rows, err := r.db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var cid int
var name, colType string
var notNull int
var dfltValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &colType, &notNull, &dfltValue, &pk); err != nil {
return err
}
if name == column {
return nil
}
}
if err := rows.Err(); err != nil {
return err
}
for _, stmt := range migrationSQL {
if _, err := r.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureIsPassthroughColumn() error {
return r.ensureColumnExists("task_sessions", "is_passthrough",
"ALTER TABLE task_sessions ADD COLUMN is_passthrough INTEGER DEFAULT 0")
}
func (r *Repository) ensureArchivedAtColumn() error {
return r.ensureColumnExists("tasks", "archived_at",
"ALTER TABLE tasks ADD COLUMN archived_at DATETIME",
"CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)")
}
@@ -9,7 +9,7 @@ import (
"github.com/google/uuid"
"github.com/kandev/kandev/internal/common/config"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
)
@@ -39,7 +39,7 @@ func (r *Repository) ensureDefaultWorkspace() error {
workspaceName = "Migrated Workspace"
workspaceDescription = ""
}
if _, err := r.db.ExecContext(ctx, `
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workspaces (
id,
name,
@@ -52,16 +52,16 @@ func (r *Repository) ensureDefaultWorkspace() error {
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, defaultID, workspaceName, workspaceDescription, "", nil, nil, nil, now, now); err != nil {
`), defaultID, workspaceName, workspaceDescription, "", nil, nil, nil, now, now); err != nil {
return err
}
if workflowCount == 0 && taskCount == 0 {
workflowID := uuid.New().String()
if _, err := r.db.ExecContext(ctx, `
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, workflowID, defaultID, "Development", "Default development workflow", "simple", now, now); err != nil {
`), workflowID, defaultID, "Development", "Default development workflow", "simple", now, now); err != nil {
return err
}
// Note: Workflow steps will be created by the workflow repository after it initializes
@@ -73,9 +73,9 @@ func (r *Repository) ensureDefaultWorkspace() error {
return err
}
if _, err := r.db.ExecContext(ctx, `
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workflows SET workspace_id = ? WHERE workspace_id = '' OR workspace_id IS NULL
`, defaultWorkspaceID); err != nil {
`), defaultWorkspaceID); err != nil {
return err
}
@@ -148,19 +148,19 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
if err != nil {
return fmt.Errorf("failed to serialize executor config: %w", err)
}
if _, err := r.db.ExecContext(ctx, `
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, executor.id, executor.name, executor.execType, executor.status, commonsqlite.BoolToInt(executor.isSystem), commonsqlite.BoolToInt(executor.resumable), string(configJSON), now, now); err != nil {
`), executor.id, executor.name, executor.execType, executor.status, dialect.BoolToInt(executor.isSystem), dialect.BoolToInt(executor.resumable), string(configJSON), now, now); err != nil {
return err
}
}
} else {
// Ensure system executors have is_system flag set
for _, systemID := range []string{models.ExecutorIDLocal, models.ExecutorIDWorktree} {
if _, err := r.db.ExecContext(ctx, `
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE executors SET is_system = 1 WHERE id = ?
`, systemID); err != nil {
`), systemID); err != nil {
return err
}
}
@@ -172,41 +172,41 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
}
if envCount == 0 {
now := time.Now().UTC()
if _, err := r.db.ExecContext(ctx, `
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, commonsqlite.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
`), models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, dialect.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
return err
}
} else {
var localCount int
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM environments WHERE id = ?", models.EnvironmentIDLocal).Scan(&localCount); err != nil {
if err := r.db.QueryRowContext(ctx, r.db.Rebind("SELECT COUNT(1) FROM environments WHERE id = ?"), models.EnvironmentIDLocal).Scan(&localCount); err != nil {
return err
}
if localCount == 0 {
now := time.Now().UTC()
if _, err := r.db.ExecContext(ctx, `
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, commonsqlite.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
`), models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, dialect.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
return err
}
}
if _, err := r.db.ExecContext(ctx, `
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE environments
SET is_system = 1,
image_tag = '',
dockerfile = '',
build_config = '{}'
WHERE id = ?
`, models.EnvironmentIDLocal); err != nil {
`), models.EnvironmentIDLocal); err != nil {
return err
}
if _, err := r.db.ExecContext(ctx, `
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE environments
SET worktree_root = ?
WHERE id = ? AND (worktree_root IS NULL OR worktree_root = '')
`, "~/kandev", models.EnvironmentIDLocal); err != nil {
`), "~/kandev", models.EnvironmentIDLocal); err != nil {
return err
}
}
@@ -9,7 +9,7 @@ import (
"github.com/google/uuid"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
)
@@ -27,10 +27,10 @@ func (r *Repository) CreateEnvironment(ctx context.Context, environment *models.
return fmt.Errorf("failed to serialize environment build config: %w", err)
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, environment.ID, environment.Name, environment.Kind, commonsqlite.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt, environment.DeletedAt)
`), environment.ID, environment.Name, environment.Kind, dialect.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt, environment.DeletedAt)
return err
}
@@ -40,10 +40,10 @@ func (r *Repository) GetEnvironment(ctx context.Context, id string) (*models.Env
var buildConfigJSON string
var isSystem int
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at
FROM environments WHERE id = ? AND deleted_at IS NULL
`, id).Scan(
`), id).Scan(
&environment.ID, &environment.Name, &environment.Kind, &isSystem, &environment.WorktreeRoot,
&environment.ImageTag, &environment.Dockerfile, &buildConfigJSON,
&environment.CreatedAt, &environment.UpdatedAt, &environment.DeletedAt,
@@ -72,10 +72,10 @@ func (r *Repository) UpdateEnvironment(ctx context.Context, environment *models.
return fmt.Errorf("failed to serialize environment build config: %w", err)
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE environments SET name = ?, kind = ?, is_system = ?, worktree_root = ?, image_tag = ?, dockerfile = ?, build_config = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`, environment.Name, environment.Kind, commonsqlite.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID)
`), environment.Name, environment.Kind, dialect.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID)
if err != nil {
return err
}
@@ -89,9 +89,9 @@ func (r *Repository) UpdateEnvironment(ctx context.Context, environment *models.
// DeleteEnvironment soft-deletes an environment by ID
func (r *Repository) DeleteEnvironment(ctx context.Context, id string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE environments SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL
`, now, now, id)
`), now, now, id)
if err != nil {
return err
}
@@ -9,7 +9,7 @@ import (
"github.com/google/uuid"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
)
@@ -28,10 +28,10 @@ func (r *Repository) CreateExecutor(ctx context.Context, executor *models.Execut
return fmt.Errorf("failed to serialize executor config: %w", err)
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, executor.ID, executor.Name, executor.Type, executor.Status, commonsqlite.BoolToInt(executor.IsSystem), commonsqlite.BoolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt)
`), executor.ID, executor.Name, executor.Type, executor.Status, dialect.BoolToInt(executor.IsSystem), dialect.BoolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt)
return err
}
@@ -41,10 +41,10 @@ func (r *Repository) GetExecutor(ctx context.Context, id string) (*models.Execut
var isSystem int
var resumable int
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at
FROM executors WHERE id = ? AND deleted_at IS NULL
`, id).Scan(
`), id).Scan(
&executor.ID, &executor.Name, &executor.Type, &executor.Status,
&isSystem, &resumable, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt,
)
@@ -73,10 +73,10 @@ func (r *Repository) UpdateExecutor(ctx context.Context, executor *models.Execut
return fmt.Errorf("failed to serialize executor config: %w", err)
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE executors SET name = ?, type = ?, status = ?, is_system = ?, resumable = ?, config = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`, executor.Name, executor.Type, executor.Status, commonsqlite.BoolToInt(executor.IsSystem), commonsqlite.BoolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID)
`), executor.Name, executor.Type, executor.Status, dialect.BoolToInt(executor.IsSystem), dialect.BoolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID)
if err != nil {
return err
}
@@ -89,9 +89,9 @@ func (r *Repository) UpdateExecutor(ctx context.Context, executor *models.Execut
func (r *Repository) DeleteExecutor(ctx context.Context, id string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE executors SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL
`, now, now, id)
`), now, now, id)
if err != nil {
return err
}
@@ -152,7 +152,7 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models.
}
running.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO executors_running (
id, session_id, task_id, executor_id, runtime, status, resumable, resume_token,
agent_execution_id, container_id, agentctl_url, agentctl_port, pid,
@@ -178,14 +178,14 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models.
last_seen_at = excluded.last_seen_at,
error_message = excluded.error_message,
updated_at = excluded.updated_at
`,
`),
running.ID,
running.SessionID,
running.TaskID,
running.ExecutorID,
running.Runtime,
running.Status,
commonsqlite.BoolToInt(running.Resumable),
dialect.BoolToInt(running.Resumable),
running.ResumeToken,
running.AgentExecutionID,
running.ContainerID,
@@ -256,14 +256,14 @@ func (r *Repository) GetExecutorRunningBySessionID(ctx context.Context, sessionI
var resumable int
var lastSeen sql.NullTime
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, session_id, task_id, executor_id, runtime, status, resumable, resume_token,
agent_execution_id, container_id, agentctl_url, agentctl_port, pid,
worktree_id, worktree_path, worktree_branch, last_seen_at, error_message,
created_at, updated_at
FROM executors_running
WHERE session_id = ?
`, sessionID).Scan(
`), sessionID).Scan(
&running.ID,
&running.SessionID,
&running.TaskID,
@@ -302,7 +302,7 @@ func (r *Repository) DeleteExecutorRunningBySessionID(ctx context.Context, sessi
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
result, err := r.db.ExecContext(ctx, `DELETE FROM executors_running WHERE session_id = ?`, sessionID)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM executors_running WHERE session_id = ?`), sessionID)
if err != nil {
return err
}
@@ -315,11 +315,11 @@ func (r *Repository) DeleteExecutorRunningBySessionID(ctx context.Context, sessi
func (r *Repository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) {
var exists int
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT 1 FROM task_sessions
WHERE executor_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
LIMIT 1
`, executorID).Scan(&exists)
`), executorID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
@@ -39,12 +39,12 @@ func (r *Repository) CreateGitSnapshot(ctx context.Context, snapshot *models.Git
metadataJSON = string(metadataBytes)
}
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_session_git_snapshots (
id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit,
ahead, behind, files, triggered_by, metadata, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, snapshot.ID, snapshot.SessionID, string(snapshot.SnapshotType), snapshot.Branch,
`), snapshot.ID, snapshot.SessionID, string(snapshot.SnapshotType), snapshot.Branch,
snapshot.RemoteBranch, snapshot.HeadCommit, snapshot.BaseCommit, snapshot.Ahead,
snapshot.Behind, filesJSON, snapshot.TriggeredBy, metadataJSON, snapshot.CreatedAt)
@@ -59,13 +59,13 @@ func (r *Repository) GetLatestGitSnapshot(ctx context.Context, sessionID string)
var filesJSON string
var metadataJSON string
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit,
ahead, behind, files, triggered_by, metadata, created_at
FROM task_session_git_snapshots
WHERE session_id = ?
ORDER BY created_at DESC LIMIT 1
`, sessionID).Scan(
`), sessionID).Scan(
&snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch,
&snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit,
&snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy,
@@ -98,13 +98,13 @@ func (r *Repository) GetFirstGitSnapshot(ctx context.Context, sessionID string)
var filesJSON string
var metadataJSON string
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit,
ahead, behind, files, triggered_by, metadata, created_at
FROM task_session_git_snapshots
WHERE session_id = ?
ORDER BY created_at ASC LIMIT 1
`, sessionID).Scan(
`), sessionID).Scan(
&snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch,
&snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit,
&snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy,
@@ -144,7 +144,7 @@ func (r *Repository) GetGitSnapshotsBySession(ctx context.Context, sessionID str
query += fmt.Sprintf(" LIMIT %d", limit)
}
rows, err := r.db.QueryContext(ctx, query, sessionID)
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), sessionID)
if err != nil {
return nil, err
}
@@ -197,13 +197,13 @@ func (r *Repository) CreateSessionCommit(ctx context.Context, commit *models.Ses
commit.CreatedAt = time.Now().UTC()
}
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_session_commits (
id, session_id, commit_sha, parent_sha, author_name, author_email,
commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id,
files_changed, insertions, deletions, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, commit.ID, commit.SessionID, commit.CommitSHA, commit.ParentSHA,
`), commit.ID, commit.SessionID, commit.CommitSHA, commit.ParentSHA,
commit.AuthorName, commit.AuthorEmail, commit.CommitMessage, commit.CommittedAt,
commit.PreCommitSnapshotID, commit.PostCommitSnapshotID, commit.FilesChanged,
commit.Insertions, commit.Deletions, commit.CreatedAt)
@@ -214,14 +214,14 @@ func (r *Repository) CreateSessionCommit(ctx context.Context, commit *models.Ses
// GetSessionCommits retrieves all commits for a session, ordered by committed_at descending.
// Returns an empty slice if no commits are found.
func (r *Repository) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, session_id, commit_sha, parent_sha, author_name, author_email,
commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id,
files_changed, insertions, deletions, created_at
FROM task_session_commits
WHERE session_id = ?
ORDER BY committed_at DESC
`, sessionID)
`), sessionID)
if err != nil {
return nil, err
}
@@ -253,14 +253,14 @@ func (r *Repository) GetSessionCommits(ctx context.Context, sessionID string) ([
func (r *Repository) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) {
commit := &models.SessionCommit{}
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, session_id, commit_sha, parent_sha, author_name, author_email,
commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id,
files_changed, insertions, deletions, created_at
FROM task_session_commits
WHERE session_id = ?
ORDER BY committed_at DESC LIMIT 1
`, sessionID).Scan(
`), sessionID).Scan(
&commit.ID, &commit.SessionID, &commit.CommitSHA, &commit.ParentSHA,
&commit.AuthorName, &commit.AuthorEmail, &commit.CommitMessage,
&commit.CommittedAt, &commit.PreCommitSnapshotID, &commit.PostCommitSnapshotID,
@@ -275,6 +275,6 @@ func (r *Repository) GetLatestSessionCommit(ctx context.Context, sessionID strin
// DeleteSessionCommit removes a commit record from the database.
func (r *Repository) DeleteSessionCommit(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM task_session_commits WHERE id = ?`, id)
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_commits WHERE id = ?`), id)
return err
}
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
)
@@ -45,10 +46,10 @@ func (r *Repository) CreateMessage(ctx context.Context, message *models.Message)
metadataJSON = string(metadataBytes)
}
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_session_messages (id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, message.ID, message.TaskSessionID, message.TaskID, message.TurnID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt)
`), message.ID, message.TaskSessionID, message.TaskID, message.TurnID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt)
return err
}
@@ -59,10 +60,10 @@ func (r *Repository) GetMessage(ctx context.Context, id string) (*models.Message
var requestsInput int
var messageType string
var metadataJSON string
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE id = ?
`, id).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
`), id).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
return nil, err
}
@@ -80,10 +81,10 @@ func (r *Repository) GetMessage(ctx context.Context, id string) (*models.Message
// ListMessages returns all messages for a session ordered by creation time.
func (r *Repository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE task_session_id = ? ORDER BY created_at ASC
`, sessionID)
`), sessionID)
if err != nil {
return nil, err
}
@@ -168,7 +169,7 @@ func (r *Repository) ListMessagesPaginated(ctx context.Context, sessionID string
args = append(args, limit+1)
}
rows, err := r.db.QueryContext(ctx, query, args...)
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...)
if err != nil {
return nil, false, err
}
@@ -208,7 +209,7 @@ func (r *Repository) ListMessagesPaginated(ctx context.Context, sessionID string
// DeleteMessage deletes a message by ID
func (r *Repository) DeleteMessage(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM task_session_messages WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_messages WHERE id = ?`), id)
if err != nil {
return err
}
@@ -228,11 +229,13 @@ func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, tool
var requestsInput int
var messageType string
var metadataJSON string
err := r.db.QueryRowContext(ctx, `
drv := r.db.DriverName()
query := fmt.Sprintf(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.tool_call_id') = ?
FROM task_session_messages WHERE task_session_id = ? AND %s = ?
ORDER BY created_at ASC LIMIT 1
`, sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID,
`, dialect.JSONExtract(drv, "metadata", "tool_call_id"))
err := r.db.QueryRowContext(ctx, r.db.Rebind(query), sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID,
&message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
return nil, err
@@ -251,10 +254,12 @@ func (r *Repository) GetMessageByPendingID(ctx context.Context, sessionID, pendi
var requestsInput int
var messageType string
var metadataJSON string
err := r.db.QueryRowContext(ctx, `
drv := r.db.DriverName()
query := fmt.Sprintf(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.pending_id') = ?
`, sessionID, pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID,
FROM task_session_messages WHERE task_session_id = ? AND %s = ?
`, dialect.JSONExtract(drv, "metadata", "pending_id"))
err := r.db.QueryRowContext(ctx, r.db.Rebind(query), sessionID, pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID,
&message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
return nil, err
@@ -274,11 +279,13 @@ func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID strin
var requestsInput int
var messageType string
var metadataJSON string
err := r.db.QueryRowContext(ctx, `
drv := r.db.DriverName()
query := fmt.Sprintf(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE json_extract(metadata, '$.pending_id') = ?
FROM task_session_messages WHERE %s = ?
ORDER BY created_at DESC LIMIT 1
`, pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID,
`, dialect.JSONExtract(drv, "metadata", "pending_id"))
err := r.db.QueryRowContext(ctx, r.db.Rebind(query), pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID,
&message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
return nil, err
@@ -294,13 +301,17 @@ func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID strin
// CompleteRunningToolCallsForTurn marks all "running" tool call messages for a turn as "complete".
// This is a safety net to ensure no tool calls remain stuck in "running" state after a turn completes.
func (r *Repository) CompleteRunningToolCallsForTurn(ctx context.Context, turnID string) (int64, error) {
result, err := r.db.ExecContext(ctx, `
drv := r.db.DriverName()
query := fmt.Sprintf(`
UPDATE task_session_messages
SET metadata = json_set(metadata, '$.status', 'complete')
SET metadata = %s
WHERE turn_id = ?
AND json_extract(metadata, '$.status') = 'running'
AND json_extract(metadata, '$.tool_call_id') IS NOT NULL
`, turnID)
AND %s = 'running'
AND %s
`, dialect.JSONSet(drv, "metadata", "status", "complete"),
dialect.JSONExtract(drv, "metadata", "status"),
dialect.JSONExtractIsNotNull(drv, "metadata", "tool_call_id"))
result, err := r.db.ExecContext(ctx, r.db.Rebind(query), turnID)
if err != nil {
return 0, fmt.Errorf("failed to complete running tool calls for turn %s: %w", turnID, err)
}
@@ -321,10 +332,10 @@ func (r *Repository) UpdateMessage(ctx context.Context, message *models.Message)
requestsInput = 1
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_session_messages SET content = ?, requests_input = ?, type = ?, metadata = ?
WHERE id = ?
`, message.Content, requestsInput, string(message.Type), string(metadataJSON), message.ID)
`), message.Content, requestsInput, string(message.Type), string(metadataJSON), message.ID)
if err != nil {
return err
}
@@ -26,20 +26,20 @@ func (r *Repository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan)
plan.CreatedBy = "agent"
}
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_plans (id, task_id, title, content, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, plan.ID, plan.TaskID, plan.Title, plan.Content, plan.CreatedBy, plan.CreatedAt, plan.UpdatedAt)
`), plan.ID, plan.TaskID, plan.Title, plan.Content, plan.CreatedBy, plan.CreatedAt, plan.UpdatedAt)
return err
}
// GetTaskPlan retrieves a task plan by task ID.
func (r *Repository) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) {
plan := &models.TaskPlan{}
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, task_id, title, content, created_by, created_at, updated_at
FROM task_plans WHERE task_id = ?
`, taskID).Scan(&plan.ID, &plan.TaskID, &plan.Title, &plan.Content, &plan.CreatedBy, &plan.CreatedAt, &plan.UpdatedAt)
`), taskID).Scan(&plan.ID, &plan.TaskID, &plan.Title, &plan.Content, &plan.CreatedBy, &plan.CreatedAt, &plan.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil // Return nil, nil when no plan exists
@@ -54,10 +54,10 @@ func (r *Repository) GetTaskPlan(ctx context.Context, taskID string) (*models.Ta
func (r *Repository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) error {
plan.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_plans SET title = ?, content = ?, created_by = ?, updated_at = ?
WHERE task_id = ?
`, plan.Title, plan.Content, plan.CreatedBy, plan.UpdatedAt, plan.TaskID)
`), plan.Title, plan.Content, plan.CreatedBy, plan.UpdatedAt, plan.TaskID)
if err != nil {
return fmt.Errorf("failed to update task plan: %w", err)
}
@@ -71,7 +71,7 @@ func (r *Repository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan)
// DeleteTaskPlan deletes a task plan by task ID.
func (r *Repository) DeleteTaskPlan(ctx context.Context, taskID string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM task_plans WHERE task_id = ?`, taskID)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_plans WHERE task_id = ?`), taskID)
if err != nil {
return fmt.Errorf("failed to delete task plan: %w", err)
}
@@ -8,7 +8,7 @@ import (
"github.com/google/uuid"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
)
@@ -21,14 +21,14 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re
repository.CreatedAt = now
repository.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO repositories (
id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider,
`), repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider,
repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix,
commonsqlite.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
return err
}
@@ -37,11 +37,11 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re
func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repository, error) {
repository := &models.Repository{}
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at
FROM repositories WHERE id = ? AND deleted_at IS NULL
`, id).Scan(
`), id).Scan(
&repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath,
&repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName,
&repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt,
@@ -57,13 +57,13 @@ func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repo
func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Repository) error {
repository.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE repositories SET
name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?,
provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID,
repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, commonsqlite.BoolToInt(repository.PullBeforeWorktree),
`), repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID,
repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, dialect.BoolToInt(repository.PullBeforeWorktree),
repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID)
if err != nil {
return err
@@ -79,9 +79,9 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re
// DeleteRepository soft-deletes a repository by ID
func (r *Repository) DeleteRepository(ctx context.Context, id string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE repositories SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL
`, now, now, id)
`), now, now, id)
if err != nil {
return err
}
@@ -94,11 +94,11 @@ func (r *Repository) DeleteRepository(ctx context.Context, id string) error {
// ListRepositories returns all repositories for a workspace
func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at
FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC
`, workspaceID)
`), workspaceID)
if err != nil {
return nil, err
}
@@ -129,10 +129,10 @@ func (r *Repository) CreateRepositoryScript(ctx context.Context, script *models.
script.CreatedAt = now
script.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO repository_scripts (id, repository_id, name, command, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, script.ID, script.RepositoryID, script.Name, script.Command, script.Position, script.CreatedAt, script.UpdatedAt)
`), script.ID, script.RepositoryID, script.Name, script.Command, script.Position, script.CreatedAt, script.UpdatedAt)
return err
}
@@ -140,10 +140,10 @@ func (r *Repository) CreateRepositoryScript(ctx context.Context, script *models.
// GetRepositoryScript retrieves a repository script by ID
func (r *Repository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) {
script := &models.RepositoryScript{}
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, repository_id, name, command, position, created_at, updated_at
FROM repository_scripts WHERE id = ?
`, id).Scan(&script.ID, &script.RepositoryID, &script.Name, &script.Command, &script.Position, &script.CreatedAt, &script.UpdatedAt)
`), id).Scan(&script.ID, &script.RepositoryID, &script.Name, &script.Command, &script.Position, &script.CreatedAt, &script.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("repository script not found: %s", id)
}
@@ -153,9 +153,9 @@ func (r *Repository) GetRepositoryScript(ctx context.Context, id string) (*model
// UpdateRepositoryScript updates an existing repository script
func (r *Repository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error {
script.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE repository_scripts SET name = ?, command = ?, position = ?, updated_at = ? WHERE id = ?
`, script.Name, script.Command, script.Position, script.UpdatedAt, script.ID)
`), script.Name, script.Command, script.Position, script.UpdatedAt, script.ID)
if err != nil {
return err
}
@@ -168,7 +168,7 @@ func (r *Repository) UpdateRepositoryScript(ctx context.Context, script *models.
// DeleteRepositoryScript deletes a repository script by ID
func (r *Repository) DeleteRepositoryScript(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM repository_scripts WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM repository_scripts WHERE id = ?`), id)
if err != nil {
return err
}
@@ -181,10 +181,10 @@ func (r *Repository) DeleteRepositoryScript(ctx context.Context, id string) erro
// ListRepositoryScripts returns all scripts for a repository
func (r *Repository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, repository_id, name, command, position, created_at, updated_at
FROM repository_scripts WHERE repository_id = ? ORDER BY position
`, repositoryID)
`), repositoryID)
if err != nil {
return nil, err
}
@@ -20,7 +20,7 @@ func (r *Repository) UpsertSessionFileReview(ctx context.Context, review *models
}
review.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO session_file_reviews (
id, session_id, file_path, reviewed, diff_hash, reviewed_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -29,7 +29,7 @@ func (r *Repository) UpsertSessionFileReview(ctx context.Context, review *models
diff_hash = excluded.diff_hash,
reviewed_at = excluded.reviewed_at,
updated_at = excluded.updated_at
`, review.ID, review.SessionID, review.FilePath, review.Reviewed,
`), review.ID, review.SessionID, review.FilePath, review.Reviewed,
review.DiffHash, review.ReviewedAt, review.CreatedAt, review.UpdatedAt)
return err
@@ -37,12 +37,12 @@ func (r *Repository) UpsertSessionFileReview(ctx context.Context, review *models
// GetSessionFileReviews retrieves all file reviews for a session.
func (r *Repository) GetSessionFileReviews(ctx context.Context, sessionID string) ([]*models.SessionFileReview, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, session_id, file_path, reviewed, diff_hash, reviewed_at, created_at, updated_at
FROM session_file_reviews
WHERE session_id = ?
ORDER BY file_path
`, sessionID)
`), sessionID)
if err != nil {
return nil, err
}
@@ -69,6 +69,6 @@ func (r *Repository) GetSessionFileReviews(ctx context.Context, sessionID string
// DeleteSessionFileReviews deletes all file reviews for a session.
func (r *Repository) DeleteSessionFileReviews(ctx context.Context, sessionID string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM session_file_reviews WHERE session_id = ?`, sessionID)
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM session_file_reviews WHERE session_id = ?`), sessionID)
return err
}
@@ -11,7 +11,7 @@ import (
"github.com/google/uuid"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
)
@@ -40,10 +40,10 @@ func (r *Repository) CreateTurn(ctx context.Context, turn *models.Turn) error {
metadataJSON = string(metadataBytes)
}
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, turn.ID, turn.TaskSessionID, turn.TaskID, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt)
`), turn.ID, turn.TaskSessionID, turn.TaskID, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt)
return err
}
@@ -53,10 +53,10 @@ func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, erro
turn := &models.Turn{}
var metadataJSON string
var completedAt sql.NullTime
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at
FROM task_session_turns WHERE id = ?
`, id).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt)
`), id).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt)
if err != nil {
return nil, err
}
@@ -76,12 +76,12 @@ func (r *Repository) GetActiveTurnBySessionID(ctx context.Context, sessionID str
turn := &models.Turn{}
var metadataJSON string
var completedAt sql.NullTime
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at
FROM task_session_turns
WHERE task_session_id = ? AND completed_at IS NULL
ORDER BY started_at DESC LIMIT 1
`, sessionID).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt)
`), sessionID).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt)
if err != nil {
return nil, err
}
@@ -109,11 +109,11 @@ func (r *Repository) UpdateTurn(ctx context.Context, turn *models.Turn) error {
metadataJSON = string(metadataBytes)
}
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_session_turns
SET completed_at = ?, metadata = ?, updated_at = ?
WHERE id = ?
`, turn.CompletedAt, metadataJSON, turn.UpdatedAt, turn.ID)
`), turn.CompletedAt, metadataJSON, turn.UpdatedAt, turn.ID)
return err
}
@@ -121,20 +121,20 @@ func (r *Repository) UpdateTurn(ctx context.Context, turn *models.Turn) error {
// CompleteTurn marks a turn as completed with the current time
func (r *Repository) CompleteTurn(ctx context.Context, id string) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_session_turns
SET completed_at = ?, updated_at = ?
WHERE id = ?
`, now, now, id)
`), now, now, id)
return err
}
// ListTurnsBySession returns all turns for a session ordered by start time
func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at
FROM task_session_turns WHERE task_session_id = ? ORDER BY started_at ASC
`, sessionID)
`), sessionID)
if err != nil {
return nil, err
}
@@ -199,7 +199,7 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task
if err != nil {
return fmt.Errorf("failed to serialize repository snapshot: %w", err)
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_sessions (
id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
@@ -207,13 +207,13 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status, is_passthrough
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID,
`), session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID,
session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch,
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
string(session.State), session.ErrorMessage, string(metadataJSON),
session.StartedAt, session.CompletedAt, session.UpdatedAt,
commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus,
commonsqlite.BoolToInt(session.IsPassthrough))
dialect.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus,
dialect.BoolToInt(session.IsPassthrough))
return err
}
@@ -233,14 +233,14 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
var workflowStepID sql.NullString
var reviewStatus sql.NullString
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status, is_passthrough
FROM task_sessions WHERE id = ?
`, id).Scan(
`), id).Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
&session.ExecutorID, &session.EnvironmentID,
&session.RepositoryID, &session.BaseBranch,
@@ -318,14 +318,14 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
var workflowStepID sql.NullString
var reviewStatus sql.NullString
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status, is_passthrough
FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1
`, taskID).Scan(
`), taskID).Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
&session.ExecutorID, &session.EnvironmentID,
&session.RepositoryID, &session.BaseBranch,
@@ -403,7 +403,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
var workflowStepID sql.NullString
var reviewStatus sql.NullString
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
@@ -412,7 +412,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
FROM task_sessions
WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
ORDER BY started_at DESC LIMIT 1
`, taskID).Scan(
`), taskID).Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
&session.ExecutorID, &session.EnvironmentID,
&session.RepositoryID, &session.BaseBranch,
@@ -500,7 +500,7 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task
return fmt.Errorf("failed to serialize repository snapshot: %w", err)
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET
agent_execution_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, environment_id = ?,
repository_id = ?, base_branch = ?,
@@ -508,12 +508,12 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task
state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?,
is_primary = ?, workflow_step_id = ?, review_status = ?, is_passthrough = ?
WHERE id = ?
`, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID,
`), session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID,
session.RepositoryID, session.BaseBranch,
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt,
commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus,
commonsqlite.BoolToInt(session.IsPassthrough),
dialect.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus,
dialect.BoolToInt(session.IsPassthrough),
session.ID)
if err != nil {
return err
@@ -535,9 +535,9 @@ func (r *Repository) UpdateTaskSessionState(ctx context.Context, id string, stat
completedAt = &now
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET state = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE id = ?
`, string(status), errorMessage, completedAt, now, id)
`), string(status), errorMessage, completedAt, now, id)
if err != nil {
return err
}
@@ -553,9 +553,9 @@ func (r *Repository) UpdateTaskSessionState(ctx context.Context, id string, stat
// This is used when a stale execution ID needs to be removed (e.g., after a failed resume on startup).
func (r *Repository) ClearSessionExecutionID(ctx context.Context, id string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET agent_execution_id = '', container_id = '', updated_at = ? WHERE id = ?
`, now, id)
`), now, id)
if err != nil {
return err
}
@@ -568,14 +568,14 @@ func (r *Repository) ClearSessionExecutionID(ctx context.Context, id string) err
// ListTaskSessions returns all agent sessions for a task
func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status, is_passthrough
FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC
`, taskID)
`), taskID)
if err != nil {
return nil, err
}
@@ -626,14 +626,14 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task
// ListActiveTaskSessionsByTaskID returns all active agent sessions for a specific task
func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status, is_passthrough
FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC
`, taskID)
`), taskID)
if err != nil {
return nil, err
}
@@ -655,11 +655,11 @@ func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID
func (r *Repository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) {
var exists int
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT 1 FROM task_sessions
WHERE agent_profile_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
LIMIT 1
`, agentProfileID).Scan(&exists)
`), agentProfileID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
@@ -668,11 +668,11 @@ func (r *Repository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, ag
func (r *Repository) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) {
var exists int
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT 1 FROM task_sessions
WHERE environment_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
LIMIT 1
`, environmentID).Scan(&exists)
`), environmentID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
@@ -681,14 +681,14 @@ func (r *Repository) HasActiveTaskSessionsByEnvironment(ctx context.Context, env
func (r *Repository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) {
var exists int
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT 1
FROM task_sessions s
INNER JOIN task_repositories tr ON tr.task_id = s.task_id
WHERE s.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
AND tr.repository_id = ?
LIMIT 1
`, repositoryID).Scan(&exists)
`), repositoryID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
@@ -769,7 +769,7 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m
// DeleteTaskSession deletes an agent session by ID
func (r *Repository) DeleteTaskSession(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM task_sessions WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_sessions WHERE id = ?`), id)
if err != nil {
return err
}
@@ -791,7 +791,7 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt
sessionWorktree.CreatedAt = now
updatedAt := now
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_session_worktrees (
id, session_id, worktree_id, repository_id, position,
worktree_path, worktree_branch, created_at, updated_at
@@ -802,7 +802,7 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt
worktree_path = excluded.worktree_path,
worktree_branch = excluded.worktree_branch,
updated_at = excluded.updated_at
`,
`),
sessionWorktree.ID,
sessionWorktree.SessionID,
sessionWorktree.WorktreeID,
@@ -817,14 +817,14 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt
}
func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT
tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position,
tsw.worktree_path, tsw.worktree_branch, tsw.created_at
FROM task_session_worktrees tsw
WHERE tsw.session_id = ?
ORDER BY tsw.position ASC, tsw.created_at ASC
`, sessionID)
`), sessionID)
if err != nil {
return nil, err
}
@@ -852,7 +852,7 @@ func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID str
}
func (r *Repository) DeleteTaskSessionWorktree(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_worktrees WHERE id = ?`), id)
if err != nil {
return err
}
@@ -865,7 +865,7 @@ func (r *Repository) DeleteTaskSessionWorktree(ctx context.Context, id string) e
}
func (r *Repository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE session_id = ?`, sessionID)
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_worktrees WHERE session_id = ?`), sessionID)
return err
}
@@ -884,14 +884,14 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin
var workflowStepID sql.NullString
var reviewStatus sql.NullString
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status, is_passthrough
FROM task_sessions WHERE task_id = ? AND is_primary = 1 LIMIT 1
`, taskID).Scan(
`), taskID).Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
&session.ExecutorID, &session.EnvironmentID,
&session.RepositoryID, &session.BaseBranch,
@@ -974,7 +974,7 @@ func (r *Repository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs
WHERE task_id IN (%s) AND is_primary = 1
`, strings.Join(placeholders, ","))
rows, err := r.db.QueryContext(ctx, query, args...)
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...)
if err != nil {
return nil, err
}
@@ -1011,7 +1011,7 @@ func (r *Repository) GetSessionCountsByTaskIDs(ctx context.Context, taskIDs []st
GROUP BY task_id
`, strings.Join(placeholders, ","))
rows, err := r.db.QueryContext(ctx, query, args...)
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...)
if err != nil {
return nil, err
}
@@ -1049,7 +1049,7 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs
WHERE task_id IN (%s) AND is_primary = 1
`, strings.Join(placeholders, ","))
rows, err := r.db.QueryContext(ctx, query, args...)
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...)
if err != nil {
return nil, err
}
@@ -1079,7 +1079,7 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er
// First, get the task_id for this session
var taskID string
err := r.db.QueryRowContext(ctx, `SELECT task_id FROM task_sessions WHERE id = ?`, sessionID).Scan(&taskID)
err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT task_id FROM task_sessions WHERE id = ?`), sessionID).Scan(&taskID)
if err == sql.ErrNoRows {
return fmt.Errorf("session not found: %s", sessionID)
}
@@ -1088,17 +1088,17 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er
}
// Clear primary flag on all sessions for this task
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET is_primary = 0, updated_at = ? WHERE task_id = ?
`, now, taskID)
`), now, taskID)
if err != nil {
return err
}
// Set primary flag on the specified session
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET is_primary = 1, updated_at = ? WHERE id = ?
`, now, sessionID)
`), now, sessionID)
if err != nil {
return err
}
@@ -1114,9 +1114,9 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er
func (r *Repository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET workflow_step_id = ?, updated_at = ? WHERE id = ?
`, stepID, now, sessionID)
`), stepID, now, sessionID)
if err != nil {
return err
}
@@ -1132,9 +1132,9 @@ func (r *Repository) UpdateSessionWorkflowStep(ctx context.Context, sessionID st
func (r *Repository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET review_status = ?, updated_at = ? WHERE id = ?
`, status, now, sessionID)
`), status, now, sessionID)
if err != nil {
return err
}
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -32,10 +33,10 @@ func (r *Repository) CreateTask(ctx context.Context, task *models.Task) error {
return err
}
_, err = tx.ExecContext(ctx, `
_, err = tx.ExecContext(ctx, r.db.Rebind(`
INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
`), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return fmt.Errorf("failed to rollback task insert: %w", rollbackErr)
@@ -51,10 +52,10 @@ func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, erro
task := &models.Task{}
var metadata string
var archivedAt sql.NullTime
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at
FROM tasks WHERE id = ?
`, id).Scan(&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &archivedAt, &task.CreatedAt, &task.UpdatedAt)
`), id).Scan(&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &archivedAt, &task.CreatedAt, &task.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("task not found: %s", id)
@@ -79,10 +80,10 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error {
metadata = []byte("{}")
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ?
WHERE id = ?
`, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID)
`), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID)
if err != nil {
return err
}
@@ -96,7 +97,7 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error {
// DeleteTask deletes a task by ID
func (r *Repository) DeleteTask(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM tasks WHERE id = ?`), id)
if err != nil {
return err
}
@@ -110,12 +111,12 @@ func (r *Repository) DeleteTask(ctx context.Context, id string) error {
// ListTasks returns all non-archived tasks for a workflow
func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at
FROM tasks
WHERE workflow_id = ? AND archived_at IS NULL
ORDER BY position
`, workflowID)
`), workflowID)
if err != nil {
return nil, err
}
@@ -127,7 +128,7 @@ func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*model
// CountTasksByWorkflow returns the number of non-archived tasks in a workflow
func (r *Repository) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_id = ? AND archived_at IS NULL`, workflowID).Scan(&count)
err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_id = ? AND archived_at IS NULL`), workflowID).Scan(&count)
if err != nil {
return 0, err
}
@@ -137,7 +138,7 @@ func (r *Repository) CountTasksByWorkflow(ctx context.Context, workflowID string
// CountTasksByWorkflowStep returns the number of non-archived tasks in a workflow step
func (r *Repository) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL`, stepID).Scan(&count)
err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL`), stepID).Scan(&count)
if err != nil {
return 0, err
}
@@ -146,11 +147,11 @@ func (r *Repository) CountTasksByWorkflowStep(ctx context.Context, stepID string
// ListTasksByWorkflowStep returns all non-archived tasks in a workflow step
func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at
FROM tasks
WHERE workflow_step_id = ? AND archived_at IS NULL ORDER BY position
`, workflowStepID)
`), workflowStepID)
if err != nil {
return nil, err
}
@@ -180,58 +181,61 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin
if query == "" {
// No search query - use simple query
err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`+archiveFilter, workspaceID).Scan(&total)
err = r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`+archiveFilter), workspaceID).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err = r.db.QueryContext(ctx, `
rows, err = r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at
FROM tasks
WHERE workspace_id = ?`+archiveFilter+`
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
`, workspaceID, pageSize, offset)
`), workspaceID, pageSize, offset)
} else {
// Search query - use JOIN with repositories
searchPattern := "%" + query + "%"
like := dialect.Like(r.db.DriverName())
tFilter := ""
if !includeArchived {
tFilter = " AND t.archived_at IS NULL"
}
err = r.db.QueryRowContext(ctx, `
countQuery := fmt.Sprintf(`
SELECT COUNT(DISTINCT t.id) FROM tasks t
LEFT JOIN task_repositories tr ON t.id = tr.task_id
LEFT JOIN repositories r ON tr.repository_id = r.id
WHERE t.workspace_id = ?`+tFilter+`
WHERE t.workspace_id = ?%s
AND (
t.title LIKE ? OR
t.description LIKE ? OR
r.name LIKE ? OR
r.local_path LIKE ?
t.title %s ? OR
t.description %s ? OR
r.name %s ? OR
r.local_path %s ?
)
`, workspaceID, searchPattern, searchPattern, searchPattern, searchPattern).Scan(&total)
`, tFilter, like, like, like, like)
err = r.db.QueryRowContext(ctx, r.db.Rebind(countQuery), workspaceID, searchPattern, searchPattern, searchPattern, searchPattern).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err = r.db.QueryContext(ctx, `
selectQuery := fmt.Sprintf(`
SELECT DISTINCT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.archived_at, t.created_at, t.updated_at
FROM tasks t
LEFT JOIN task_repositories tr ON t.id = tr.task_id
LEFT JOIN repositories r ON tr.repository_id = r.id
WHERE t.workspace_id = ?`+tFilter+`
WHERE t.workspace_id = ?%s
AND (
t.title LIKE ? OR
t.description LIKE ? OR
r.name LIKE ? OR
r.local_path LIKE ?
t.title %s ? OR
t.description %s ? OR
r.name %s ? OR
r.local_path %s ?
)
ORDER BY t.updated_at DESC
LIMIT ? OFFSET ?
`, workspaceID, searchPattern, searchPattern, searchPattern, searchPattern, pageSize, offset)
`, tFilter, like, like, like, like)
rows, err = r.db.QueryContext(ctx, r.db.Rebind(selectQuery), workspaceID, searchPattern, searchPattern, searchPattern, searchPattern, pageSize, offset)
}
if err != nil {
@@ -284,7 +288,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) {
// ArchiveTask sets the archived_at timestamp on a task
func (r *Repository) ArchiveTask(ctx context.Context, id string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `UPDATE tasks SET archived_at = ?, updated_at = ? WHERE id = ?`, now, now, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`UPDATE tasks SET archived_at = ?, updated_at = ? WHERE id = ?`), now, now, id)
if err != nil {
return err
}
@@ -297,14 +301,16 @@ func (r *Repository) ArchiveTask(ctx context.Context, id string) error {
// ListTasksForAutoArchive returns tasks eligible for auto-archiving based on workflow step settings
func (r *Repository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) {
rows, err := r.db.QueryContext(ctx, `
drv := r.db.DriverName()
query := fmt.Sprintf(`
SELECT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.archived_at, t.created_at, t.updated_at
FROM tasks t
JOIN workflow_steps ws ON ws.id = t.workflow_step_id
WHERE ws.auto_archive_after_hours > 0
AND t.archived_at IS NULL
AND t.updated_at <= datetime('now', '-' || ws.auto_archive_after_hours || ' hours')
`)
AND t.updated_at <= %s
`, dialect.NowMinusHours(drv, "ws.auto_archive_after_hours"))
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query))
if err != nil {
return nil, err
}
@@ -314,7 +320,7 @@ func (r *Repository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Tas
// UpdateTaskState updates the state of a task
func (r *Repository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error {
result, err := r.db.ExecContext(ctx, `UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`, state, time.Now().UTC(), id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`), state, time.Now().UTC(), id)
if err != nil {
return err
}
@@ -26,11 +26,11 @@ func (r *Repository) CreateTaskRepository(ctx context.Context, taskRepo *models.
metadataJSON = []byte("{}")
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_repositories (
id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, taskRepo.ID, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.CreatedAt, taskRepo.UpdatedAt)
`), taskRepo.ID, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.CreatedAt, taskRepo.UpdatedAt)
return err
}
@@ -39,10 +39,10 @@ func (r *Repository) GetTaskRepository(ctx context.Context, id string) (*models.
taskRepo := &models.TaskRepository{}
var metadataJSON string
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at
FROM task_repositories WHERE id = ?
`, id).Scan(
`), id).Scan(
&taskRepo.ID,
&taskRepo.TaskID,
&taskRepo.RepositoryID,
@@ -68,12 +68,12 @@ func (r *Repository) GetTaskRepository(ctx context.Context, id string) (*models.
// ListTaskRepositories returns all repository links for a task
func (r *Repository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at
FROM task_repositories
WHERE task_id = ?
ORDER BY position ASC, created_at ASC
`, taskID)
`), taskID)
if err != nil {
return nil, err
}
@@ -114,11 +114,11 @@ func (r *Repository) UpdateTaskRepository(ctx context.Context, taskRepo *models.
metadataJSON = []byte("{}")
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_repositories SET
task_id = ?, repository_id = ?, base_branch = ?, position = ?, metadata = ?, updated_at = ?
WHERE id = ?
`, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.UpdatedAt, taskRepo.ID)
`), taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.UpdatedAt, taskRepo.ID)
if err != nil {
return err
}
@@ -131,7 +131,7 @@ func (r *Repository) UpdateTaskRepository(ctx context.Context, taskRepo *models.
// DeleteTaskRepository deletes a task-repository link by ID
func (r *Repository) DeleteTaskRepository(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM task_repositories WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_repositories WHERE id = ?`), id)
if err != nil {
return err
}
@@ -144,7 +144,7 @@ func (r *Repository) DeleteTaskRepository(ctx context.Context, id string) error
// DeleteTaskRepositoriesByTask deletes all repository links for a task
func (r *Repository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM task_repositories WHERE task_id = ?`, taskID)
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_repositories WHERE task_id = ?`), taskID)
return err
}
@@ -13,17 +13,17 @@ import (
// AddTaskToWorkflow adds a task to a workflow with placement
func (r *Repository) AddTaskToWorkflow(ctx context.Context, taskID, workflowID, workflowStepID string, position int) error {
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE tasks SET workflow_id = ?, workflow_step_id = ?, position = ?, updated_at = ? WHERE id = ?
`, workflowID, workflowStepID, position, time.Now().UTC(), taskID)
`), workflowID, workflowStepID, position, time.Now().UTC(), taskID)
return err
}
// RemoveTaskFromWorkflow removes a task from a workflow
func (r *Repository) RemoveTaskFromWorkflow(ctx context.Context, taskID, workflowID string) error {
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE tasks SET workflow_id = '', workflow_step_id = '', position = 0, updated_at = ? WHERE id = ? AND workflow_id = ?
`, time.Now().UTC(), taskID, workflowID)
`), time.Now().UTC(), taskID, workflowID)
return err
}
@@ -38,10 +38,10 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl
workflow.CreatedAt = now
workflow.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.CreatedAt, workflow.UpdatedAt)
`), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.CreatedAt, workflow.UpdatedAt)
return err
}
@@ -51,10 +51,10 @@ func (r *Repository) GetWorkflow(ctx context.Context, id string) (*models.Workfl
workflow := &models.Workflow{}
var workflowTemplateID sql.NullString
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at
FROM workflows WHERE id = ?
`, id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.CreatedAt, &workflow.UpdatedAt)
`), id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.CreatedAt, &workflow.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workflow not found: %s", id)
@@ -73,9 +73,9 @@ func (r *Repository) GetWorkflow(ctx context.Context, id string) (*models.Workfl
func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workflow) error {
workflow.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workflows SET name = ?, description = ?, workflow_template_id = ?, updated_at = ? WHERE id = ?
`, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.UpdatedAt, workflow.ID)
`), workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.UpdatedAt, workflow.ID)
if err != nil {
return err
}
@@ -89,7 +89,7 @@ func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workfl
// DeleteWorkflow deletes a workflow by ID
func (r *Repository) DeleteWorkflow(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM workflows WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflows WHERE id = ?`), id)
if err != nil {
return err
}
@@ -113,7 +113,7 @@ func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]*
}
query += " ORDER BY created_at DESC"
rows, err := r.db.QueryContext(ctx, query, args...)
rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...)
if err != nil {
return nil, err
}
@@ -20,7 +20,7 @@ func (r *Repository) CreateWorkspace(ctx context.Context, workspace *models.Work
workspace.CreatedAt = now
workspace.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workspaces (
id,
name,
@@ -33,7 +33,7 @@ func (r *Repository) CreateWorkspace(ctx context.Context, workspace *models.Work
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.CreatedAt, workspace.UpdatedAt)
`), workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.CreatedAt, workspace.UpdatedAt)
return err
}
@@ -45,10 +45,10 @@ func (r *Repository) GetWorkspace(ctx context.Context, id string) (*models.Works
var defaultEnvironmentID sql.NullString
var defaultAgentProfileID sql.NullString
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, created_at, updated_at
FROM workspaces WHERE id = ?
`, id).Scan(
`), id).Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
@@ -79,7 +79,7 @@ func (r *Repository) GetWorkspace(ctx context.Context, id string) (*models.Works
func (r *Repository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error {
workspace.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workspaces
SET name = ?,
description = ?,
@@ -88,7 +88,7 @@ func (r *Repository) UpdateWorkspace(ctx context.Context, workspace *models.Work
default_agent_profile_id = ?,
updated_at = ?
WHERE id = ?
`, workspace.Name, workspace.Description, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.UpdatedAt, workspace.ID)
`), workspace.Name, workspace.Description, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.UpdatedAt, workspace.ID)
if err != nil {
return err
}
@@ -102,7 +102,7 @@ func (r *Repository) UpdateWorkspace(ctx context.Context, workspace *models.Work
// DeleteWorkspace deletes a workspace by ID
func (r *Repository) DeleteWorkspace(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workspaces WHERE id = ?`), id)
if err != nil {
return err
}
@@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/worktree"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/task/models"
@@ -22,16 +23,17 @@ func createTestSQLiteRepo(t *testing.T) (*sqlite.Repository, func()) {
if err != nil {
t.Fatalf("failed to open SQLite database: %v", err)
}
repo, err := sqlite.NewWithDB(dbConn)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repo, err := sqlite.NewWithDB(sqlxDB)
if err != nil {
t.Fatalf("failed to create SQLite repository: %v", err)
}
if _, err := worktree.NewSQLiteStore(repo.DB()); err != nil {
if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil {
t.Fatalf("failed to init worktree store: %v", err)
}
cleanup := func() {
if err := dbConn.Close(); err != nil {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if err := repo.Close(); err != nil {
@@ -488,7 +490,8 @@ func TestSQLiteRepository_Persistence(t *testing.T) {
if err != nil {
t.Fatalf("failed to open SQLite database: %v", err)
}
repo1, err := sqlite.NewWithDB(dbConn1)
sqlxDB1 := sqlx.NewDb(dbConn1, "sqlite3")
repo1, err := sqlite.NewWithDB(sqlxDB1)
if err != nil {
t.Fatalf("failed to create first repository: %v", err)
}
@@ -499,7 +502,7 @@ func TestSQLiteRepository_Persistence(t *testing.T) {
if err := repo1.Close(); err != nil {
t.Fatalf("failed to close repo: %v", err)
}
if err := dbConn1.Close(); err != nil {
if err := sqlxDB1.Close(); err != nil {
t.Fatalf("failed to close sqlite db: %v", err)
}
@@ -508,12 +511,13 @@ func TestSQLiteRepository_Persistence(t *testing.T) {
if err != nil {
t.Fatalf("failed to open SQLite database: %v", err)
}
repo2, err := sqlite.NewWithDB(dbConn2)
sqlxDB2 := sqlx.NewDb(dbConn2, "sqlite3")
repo2, err := sqlite.NewWithDB(sqlxDB2)
if err != nil {
t.Fatalf("failed to create second repository: %v", err)
}
defer func() {
if err := dbConn2.Close(); err != nil {
if err := sqlxDB2.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if err := repo2.Close(); err != nil {
@@ -1317,8 +1321,8 @@ func TestSQLiteRepository_ListTasksForAutoArchive(t *testing.T) {
allow_manual_move INTEGER DEFAULT 1,
is_start_step INTEGER DEFAULT 0,
auto_archive_after_hours INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`)
if err != nil {
@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
@@ -278,13 +279,14 @@ func newDiscoveryService(t *testing.T, root string) *Service {
if err != nil {
t.Fatalf("failed to open test database: %v", err)
}
repoImpl, cleanup, err := repository.Provide(dbConn)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repoImpl, cleanup, err := repository.Provide(sqlxDB)
if err != nil {
t.Fatalf("failed to create test repository: %v", err)
}
repo := repository.Repository(repoImpl)
t.Cleanup(func() {
if err := dbConn.Close(); err != nil {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if err := cleanup(); err != nil {
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
@@ -77,16 +78,17 @@ func createTestService(t *testing.T) (*Service, *MockEventBus, repository.Reposi
if err != nil {
t.Fatalf("failed to open test database: %v", err)
}
repoImpl, cleanup, err := repository.Provide(dbConn)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repoImpl, cleanup, err := repository.Provide(sqlxDB)
if err != nil {
t.Fatalf("failed to create test repository: %v", err)
}
repo := repository.Repository(repoImpl)
if _, err := worktree.NewSQLiteStore(dbConn); err != nil {
if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil {
t.Fatalf("failed to init worktree store: %v", err)
}
t.Cleanup(func() {
if err := dbConn.Close(); err != nil {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if err := cleanup(); err != nil {
+2 -2
View File
@@ -1,9 +1,9 @@
package store
import "database/sql"
import "github.com/jmoiron/sqlx"
// Provide creates the SQLite user store using the shared database connection.
func Provide(db *sql.DB) (*sqliteRepository, func() error, error) {
func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) {
repo, err := newSQLiteRepositoryWithDB(db)
if err != nil {
return nil, nil, err
+16 -15
View File
@@ -2,11 +2,12 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/user/models"
)
@@ -16,17 +17,17 @@ const (
)
type sqliteRepository struct {
db *sql.DB
db *sqlx.DB
ownsDB bool
}
var _ Repository = (*sqliteRepository)(nil)
func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) {
func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) {
return newSQLiteRepository(dbConn, false)
}
func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) {
func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) {
repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB}
if err := repo.initSchema(); err != nil {
if ownsDB {
@@ -45,8 +46,8 @@ func (r *sqliteRepository) initSchema() error {
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
`
if _, err := r.db.Exec(schema); err != nil {
@@ -59,15 +60,15 @@ func (r *sqliteRepository) initSchema() error {
func (r *sqliteRepository) ensureDefaultUser() error {
ctx := context.Background()
var count int
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM users WHERE id = ?", DefaultUserID).Scan(&count); err != nil {
if err := r.db.QueryRowContext(ctx, r.db.Rebind("SELECT COUNT(1) FROM users WHERE id = ?"), DefaultUserID).Scan(&count); err != nil {
return err
}
if count == 0 {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO users (id, email, settings, created_at, updated_at)
VALUES (?, ?, '{}', ?, ?)
`, DefaultUserID, DefaultUserEmail, now, now)
`), DefaultUserID, DefaultUserEmail, now, now)
if err != nil {
return err
}
@@ -83,10 +84,10 @@ func (r *sqliteRepository) Close() error {
}
func (r *sqliteRepository) GetUser(ctx context.Context, id string) (*models.User, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, email, created_at, updated_at
FROM users WHERE id = ?
`, id)
`), id)
return scanUser(row)
}
@@ -95,10 +96,10 @@ func (r *sqliteRepository) GetDefaultUser(ctx context.Context) (*models.User, er
}
func (r *sqliteRepository) GetUserSettings(ctx context.Context, userID string) (*models.UserSettings, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT settings, updated_at
FROM users WHERE id = ?
`, userID)
`), userID)
settings, err := scanUserSettings(row, userID)
if err != nil {
return nil, err
@@ -141,11 +142,11 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
if err != nil {
return err
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE users
SET settings = ?, updated_at = ?
WHERE id = ?
`, string(settingsPayload), settings.UpdatedAt, settings.UserID)
`), string(settingsPayload), settings.UpdatedAt, settings.UserID)
if err != nil {
return err
}
+2 -3
View File
@@ -2,7 +2,7 @@
package workflow
import (
"database/sql"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/workflow/repository"
@@ -10,7 +10,7 @@ import (
)
// Provide creates the workflow repository and service using the shared database connection.
func Provide(db *sql.DB, log *logger.Logger) (*repository.Repository, *service.Service, func() error, error) {
func Provide(db *sqlx.DB, log *logger.Logger) (*repository.Repository, *service.Service, func() error, error) {
repo, err := repository.NewWithDB(db)
if err != nil {
return nil, nil, nil, err
@@ -18,4 +18,3 @@ func Provide(db *sql.DB, log *logger.Logger) (*repository.Repository, *service.S
svc := service.NewService(repo, log)
return repo, svc, func() error { return nil }, nil
}
@@ -9,18 +9,19 @@ import (
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/workflow/models"
)
// Repository provides SQLite-based workflow storage operations.
type Repository struct {
db *sql.DB
db *sqlx.DB
}
// NewWithDB creates a new SQLite repository with an existing database connection.
func NewWithDB(db *sql.DB) (*Repository, error) {
func NewWithDB(db *sqlx.DB) (*Repository, error) {
repo := &Repository{db: db}
if err := repo.initSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize workflow schema: %w", err)
@@ -28,11 +29,6 @@ func NewWithDB(db *sql.DB) (*Repository, error) {
return repo, nil
}
// DB returns the underlying sql.DB instance for shared access.
func (r *Repository) DB() *sql.DB {
return r.db
}
// initSchema creates the database tables if they don't exist.
func (r *Repository) initSchema() error {
// Create workflow_templates table
@@ -43,8 +39,8 @@ func (r *Repository) initSchema() error {
description TEXT,
is_system INTEGER DEFAULT 0,
steps TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
`
if _, err := r.db.Exec(templatesSchema); err != nil {
@@ -62,8 +58,10 @@ func (r *Repository) initSchema() error {
prompt TEXT,
events TEXT,
allow_manual_move INTEGER DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
is_start_step INTEGER DEFAULT 0,
auto_archive_after_hours INTEGER DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_steps_workflow ON workflow_steps(workflow_id);
@@ -72,32 +70,23 @@ func (r *Repository) initSchema() error {
return fmt.Errorf("failed to create workflow_steps table: %w", err)
}
// Migrate old schema to new schema if needed
if err := r.migrateWorkflowSteps(); err != nil {
return fmt.Errorf("failed to migrate workflow_steps: %w", err)
// Create session_step_history table (id column differs between SQLite and PostgreSQL)
var idCol string
if r.db.DriverName() == "pgx" {
idCol = "id BIGSERIAL PRIMARY KEY"
} else {
idCol = "id INTEGER PRIMARY KEY AUTOINCREMENT"
}
// Add is_start_step column if not present
if err := r.addIsStartStepColumn(); err != nil {
return fmt.Errorf("failed to add is_start_step column: %w", err)
}
// Add auto_archive_after_hours column if not present
if err := r.addAutoArchiveColumn(); err != nil {
return fmt.Errorf("failed to add auto_archive_after_hours column: %w", err)
}
// Create session_step_history table
historySchema := `
CREATE TABLE IF NOT EXISTS session_step_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
` + idCol + `,
session_id TEXT NOT NULL,
from_step_id TEXT,
to_step_id TEXT NOT NULL,
trigger TEXT NOT NULL,
actor_id TEXT,
metadata TEXT,
created_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_session_step_history_session ON session_step_history(session_id);
@@ -119,261 +108,6 @@ func (r *Repository) initSchema() error {
return nil
}
// migrateWorkflowSteps detects old schema (has step_type column) and migrates data to new schema.
func (r *Repository) migrateWorkflowSteps() error {
// Check if old column exists
var hasStepType bool
rows, err := r.db.Query("PRAGMA table_info(workflow_steps)")
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var cid int
var name, colType string
var notNull int
var dfltValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &colType, &notNull, &dfltValue, &pk); err != nil {
return err
}
if name == "step_type" {
hasStepType = true
break
}
}
if err := rows.Err(); err != nil {
return err
}
if !hasStepType {
return nil // Already on new schema
}
// Migrate: read old data, recreate table with new schema, insert converted data
type oldStep struct {
ID string
WorkflowID string
Name string
Position int
Color sql.NullString
AutoStartAgent int
PlanMode int
RequireApproval int
PromptPrefix sql.NullString
PromptSuffix sql.NullString
OnCompleteStepID sql.NullString
OnApprovalStepID sql.NullString
AllowManualMove int
CreatedAt time.Time
UpdatedAt time.Time
}
oldRows, err := r.db.Query(`
SELECT id, workflow_id, name, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
allow_manual_move, created_at, updated_at
FROM workflow_steps
`)
if err != nil {
return fmt.Errorf("failed to read old workflow_steps: %w", err)
}
defer func() { _ = oldRows.Close() }()
var oldSteps []oldStep
for oldRows.Next() {
var s oldStep
if err := oldRows.Scan(&s.ID, &s.WorkflowID, &s.Name, &s.Position, &s.Color,
&s.AutoStartAgent, &s.PlanMode, &s.RequireApproval,
&s.PromptPrefix, &s.PromptSuffix, &s.OnCompleteStepID, &s.OnApprovalStepID,
&s.AllowManualMove, &s.CreatedAt, &s.UpdatedAt); err != nil {
return fmt.Errorf("failed to scan old workflow step: %w", err)
}
oldSteps = append(oldSteps, s)
}
if err := oldRows.Err(); err != nil {
return err
}
// Drop old table and recreate within a transaction for atomicity
tx, err := r.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin migration transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec("DROP TABLE workflow_steps"); err != nil {
return fmt.Errorf("failed to drop old workflow_steps table: %w", err)
}
newSchema := `
CREATE TABLE workflow_steps (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL,
color TEXT,
prompt TEXT,
events TEXT,
allow_manual_move INTEGER DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_steps_workflow ON workflow_steps(workflow_id);
`
if _, err := tx.Exec(newSchema); err != nil {
return fmt.Errorf("failed to recreate workflow_steps table: %w", err)
}
// Convert and insert old data
for _, s := range oldSteps {
events := convertOldStepToEvents(s.AutoStartAgent == 1, s.PlanMode == 1, s.RequireApproval == 1,
s.OnCompleteStepID, s.OnApprovalStepID)
eventsJSON, err := json.Marshal(events)
if err != nil {
return fmt.Errorf("failed to marshal events for step %s: %w", s.ID, err)
}
// Combine old prompt prefix/suffix into single prompt
prompt := ""
if s.PromptPrefix.Valid && s.PromptPrefix.String != "" {
prompt = s.PromptPrefix.String + "\n\n{{task_prompt}}"
if s.PromptSuffix.Valid && s.PromptSuffix.String != "" {
prompt += "\n\n" + s.PromptSuffix.String
}
} else if s.PromptSuffix.Valid && s.PromptSuffix.String != "" {
prompt = "{{task_prompt}}\n\n" + s.PromptSuffix.String
}
color := ""
if s.Color.Valid {
color = s.Color.String
}
if _, err := tx.Exec(`
INSERT INTO workflow_steps (id, workflow_id, name, position, color, prompt, events, allow_manual_move, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, s.ID, s.WorkflowID, s.Name, s.Position, color, prompt, string(eventsJSON), s.AllowManualMove, s.CreatedAt, s.UpdatedAt); err != nil {
return fmt.Errorf("failed to insert migrated step %s: %w", s.ID, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit migration transaction: %w", err)
}
return nil
}
// addIsStartStepColumn adds the is_start_step column to workflow_steps if it doesn't exist.
func (r *Repository) addIsStartStepColumn() error {
rows, err := r.db.Query("PRAGMA table_info(workflow_steps)")
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
var hasColumn bool
for rows.Next() {
var cid int
var name, colType string
var notNull int
var dfltValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &colType, &notNull, &dfltValue, &pk); err != nil {
return err
}
if name == "is_start_step" {
hasColumn = true
break
}
}
if err := rows.Err(); err != nil {
return err
}
if hasColumn {
return nil
}
_, err = r.db.Exec("ALTER TABLE workflow_steps ADD COLUMN is_start_step INTEGER DEFAULT 0")
return err
}
// addAutoArchiveColumn adds the auto_archive_after_hours column to workflow_steps if it doesn't exist.
func (r *Repository) addAutoArchiveColumn() error {
rows, err := r.db.Query("PRAGMA table_info(workflow_steps)")
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
var hasColumn bool
for rows.Next() {
var cid int
var name, colType string
var notNull int
var dfltValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &colType, &notNull, &dfltValue, &pk); err != nil {
return err
}
if name == "auto_archive_after_hours" {
hasColumn = true
break
}
}
if err := rows.Err(); err != nil {
return err
}
if hasColumn {
return nil
}
_, err = r.db.Exec("ALTER TABLE workflow_steps ADD COLUMN auto_archive_after_hours INTEGER DEFAULT 0")
return err
}
// convertOldStepToEvents converts old boolean/pointer fields to new event-driven model.
func convertOldStepToEvents(autoStart, planMode, requireApproval bool, onCompleteStepID, onApprovalStepID sql.NullString) models.StepEvents {
events := models.StepEvents{}
// On Enter actions
if planMode {
events.OnEnter = append(events.OnEnter, models.OnEnterAction{Type: models.OnEnterEnablePlanMode})
}
if autoStart {
events.OnEnter = append(events.OnEnter, models.OnEnterAction{Type: models.OnEnterAutoStartAgent})
}
// On Turn Complete actions
if planMode {
events.OnTurnComplete = append(events.OnTurnComplete, models.OnTurnCompleteAction{Type: models.OnTurnCompleteDisablePlanMode})
}
if !requireApproval {
// If not requiring approval and has on_complete, add move transition
if onCompleteStepID.Valid && onCompleteStepID.String != "" {
events.OnTurnComplete = append(events.OnTurnComplete, models.OnTurnCompleteAction{
Type: models.OnTurnCompleteMoveToStep,
Config: map[string]interface{}{"step_id": onCompleteStepID.String},
})
}
} else if onApprovalStepID.Valid && onApprovalStepID.String != "" {
// Require approval before transitioning to the approval step
events.OnTurnComplete = append(events.OnTurnComplete, models.OnTurnCompleteAction{
Type: models.OnTurnCompleteMoveToStep,
Config: map[string]interface{}{
"step_id": onApprovalStepID.String,
"requires_approval": true,
},
})
}
return events
}
// seedDefaultWorkflowSteps creates default workflow steps for workflows that don't have any.
// Uses the simple template as the default workflow.
@@ -419,15 +153,15 @@ func (r *Repository) seedDefaultWorkflowSteps() error {
return fmt.Errorf("failed to marshal events: %w", err)
}
if _, err := r.db.Exec(`
if _, err := r.db.Exec(r.db.Rebind(`
INSERT INTO workflow_steps (
id, workflow_id, name, position, color,
prompt, events, allow_manual_move, is_start_step, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
`),
idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color,
stepDef.Prompt, string(eventsJSON), sqlite.BoolToInt(stepDef.AllowManualMove),
sqlite.BoolToInt(stepDef.IsStartStep), now, now,
stepDef.Prompt, string(eventsJSON), dialect.BoolToInt(stepDef.AllowManualMove),
dialect.BoolToInt(stepDef.IsStartStep), now, now,
); err != nil {
return err
}
@@ -448,7 +182,7 @@ func (r *Repository) seedSystemTemplates() error {
return fmt.Errorf("failed to marshal steps for template %s: %w", tmpl.ID, err)
}
_, err = r.db.Exec(`
_, err = r.db.Exec(r.db.Rebind(`
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
@@ -456,7 +190,7 @@ func (r *Repository) seedSystemTemplates() error {
description = excluded.description,
steps = excluded.steps,
updated_at = excluded.updated_at
`, tmpl.ID, tmpl.Name, tmpl.Description, sqlite.BoolToInt(tmpl.IsSystem), string(stepsJSON), tmpl.CreatedAt, tmpl.UpdatedAt)
`), tmpl.ID, tmpl.Name, tmpl.Description, dialect.BoolToInt(tmpl.IsSystem), string(stepsJSON), tmpl.CreatedAt, tmpl.UpdatedAt)
if err != nil {
return fmt.Errorf("failed to upsert template %s: %w", tmpl.ID, err)
}
@@ -745,10 +479,10 @@ func (r *Repository) CreateTemplate(ctx context.Context, template *models.Workfl
return fmt.Errorf("failed to marshal steps: %w", err)
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, template.ID, template.Name, template.Description, sqlite.BoolToInt(template.IsSystem), string(stepsJSON), template.CreatedAt, template.UpdatedAt)
`), template.ID, template.Name, template.Description, dialect.BoolToInt(template.IsSystem), string(stepsJSON), template.CreatedAt, template.UpdatedAt)
return err
}
@@ -759,10 +493,10 @@ func (r *Repository) GetTemplate(ctx context.Context, id string) (*models.Workfl
var stepsJSON string
var isSystem int
err := r.db.QueryRowContext(ctx, `
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT id, name, description, is_system, steps, created_at, updated_at
FROM workflow_templates WHERE id = ?
`, id).Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
`), id).Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workflow template not found: %s", id)
@@ -788,10 +522,10 @@ func (r *Repository) UpdateTemplate(ctx context.Context, template *models.Workfl
return fmt.Errorf("failed to marshal steps: %w", err)
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workflow_templates SET name = ?, description = ?, steps = ?, updated_at = ?
WHERE id = ? AND is_system = 0
`, template.Name, template.Description, string(stepsJSON), template.UpdatedAt, template.ID)
`), template.Name, template.Description, string(stepsJSON), template.UpdatedAt, template.ID)
if err != nil {
return err
}
@@ -805,7 +539,7 @@ func (r *Repository) UpdateTemplate(ctx context.Context, template *models.Workfl
// DeleteTemplate deletes a workflow template by ID.
func (r *Repository) DeleteTemplate(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_templates WHERE id = ? AND is_system = 0`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflow_templates WHERE id = ? AND is_system = 0`), id)
if err != nil {
return err
}
@@ -907,14 +641,14 @@ func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep)
return fmt.Errorf("failed to marshal events: %w", err)
}
_, err = r.db.ExecContext(ctx, `
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workflow_steps (
id, workflow_id, name, position, color,
prompt, events, allow_manual_move, is_start_step, auto_archive_after_hours, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, step.ID, step.WorkflowID, step.Name, step.Position, step.Color,
step.Prompt, string(eventsJSON), sqlite.BoolToInt(step.AllowManualMove),
sqlite.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.CreatedAt, step.UpdatedAt)
`), step.ID, step.WorkflowID, step.Name, step.Position, step.Color,
step.Prompt, string(eventsJSON), dialect.BoolToInt(step.AllowManualMove),
dialect.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.CreatedAt, step.UpdatedAt)
return err
}
@@ -959,10 +693,10 @@ const stepSelectColumns = `id, workflow_id, name, position, color, prompt, event
// GetStep retrieves a workflow step by ID.
func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT `+stepSelectColumns+`
FROM workflow_steps WHERE id = ?
`, id)
`), id)
step, err := r.scanStep(row)
if err == sql.ErrNoRows {
@@ -983,15 +717,15 @@ func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep)
return fmt.Errorf("failed to marshal events: %w", err)
}
result, err := r.db.ExecContext(ctx, `
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workflow_steps SET
name = ?, position = ?, color = ?,
prompt = ?, events = ?,
allow_manual_move = ?, is_start_step = ?, auto_archive_after_hours = ?, updated_at = ?
WHERE id = ?
`, step.Name, step.Position, step.Color,
`), step.Name, step.Position, step.Color,
step.Prompt, string(eventsJSON),
sqlite.BoolToInt(step.AllowManualMove), sqlite.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.UpdatedAt, step.ID)
dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.UpdatedAt, step.ID)
if err != nil {
return err
}
@@ -1005,21 +739,21 @@ func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep)
// ClearStartStepFlag clears the is_start_step flag for all steps in a workflow except the given step.
func (r *Repository) ClearStartStepFlag(ctx context.Context, workflowID, exceptStepID string) error {
_, err := r.db.ExecContext(ctx, `
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workflow_steps SET is_start_step = 0
WHERE workflow_id = ? AND id != ?
`, workflowID, exceptStepID)
`), workflowID, exceptStepID)
return err
}
// GetStartStep returns the step marked as is_start_step for a workflow.
// Returns nil if no step is marked.
func (r *Repository) GetStartStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) {
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, r.db.Rebind(`
SELECT `+stepSelectColumns+`
FROM workflow_steps WHERE workflow_id = ? AND is_start_step = 1
LIMIT 1
`, workflowID)
`), workflowID)
step, err := r.scanStep(row)
if err == sql.ErrNoRows {
@@ -1064,7 +798,7 @@ func (r *Repository) ClearStepReferences(ctx context.Context, workflowID, stepID
// DeleteStep deletes a workflow step by ID.
func (r *Repository) DeleteStep(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_steps WHERE id = ?`, id)
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflow_steps WHERE id = ?`), id)
if err != nil {
return err
}
@@ -1078,10 +812,10 @@ func (r *Repository) DeleteStep(ctx context.Context, id string) error {
// ListStepsByWorkflow returns all workflow steps for a workflow.
func (r *Repository) ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*models.WorkflowStep, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT `+stepSelectColumns+`
FROM workflow_steps WHERE workflow_id = ? ORDER BY position
`, workflowID)
`), workflowID)
if err != nil {
return nil, err
}
@@ -1122,16 +856,10 @@ func (r *Repository) CreateHistory(ctx context.Context, history *models.SessionS
metadataJSON = &s
}
result, err := r.db.ExecContext(ctx, `
INSERT INTO session_step_history (session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, history.SessionID, history.FromStepID, history.ToStepID, history.Trigger, history.ActorID, metadataJSON, history.CreatedAt)
if err != nil {
return err
}
// Get the auto-incremented ID
id, err := result.LastInsertId()
id, err := dialect.InsertReturningID(ctx, r.db,
`INSERT INTO session_step_history (session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
history.SessionID, history.FromStepID, history.ToStepID, history.Trigger, history.ActorID, metadataJSON, history.CreatedAt)
if err != nil {
return err
}
@@ -1142,10 +870,10 @@ func (r *Repository) CreateHistory(ctx context.Context, history *models.SessionS
// ListHistoryBySession returns all step history entries for a session.
func (r *Repository) ListHistoryBySession(ctx context.Context, sessionID string) ([]*models.SessionStepHistory, error) {
rows, err := r.db.QueryContext(ctx, `
rows, err := r.db.QueryContext(ctx, r.db.Rebind(`
SELECT id, session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at
FROM session_step_history WHERE session_id = ? ORDER BY created_at
`, sessionID)
`), sessionID)
if err != nil {
return nil, err
}
@@ -4,7 +4,9 @@ import (
"context"
"database/sql"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -14,28 +16,30 @@ import (
"github.com/kandev/kandev/internal/workflow/repository"
)
func setupTestService(t *testing.T) (*Service, *sql.DB) {
db, err := sql.Open("sqlite3", ":memory:")
func setupTestService(t *testing.T) (*Service, *sqlx.DB) {
rawDB, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
sqlxDB := sqlx.NewDb(rawDB, "sqlite3")
t.Cleanup(func() { _ = sqlxDB.Close() })
// Create workflows table (normally created by task repo)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS workflows (
_, err = sqlxDB.Exec(`CREATE TABLE IF NOT EXISTS workflows (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '',
workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL,
description TEXT DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL
description TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL
)`)
require.NoError(t, err)
repo, err := repository.NewWithDB(db)
repo, err := repository.NewWithDB(sqlxDB)
require.NoError(t, err)
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
return NewService(repo, log), db
return NewService(repo, log), sqlxDB
}
func insertWorkflow(t *testing.T, db *sql.DB, id, name string) {
_, err := db.Exec("INSERT INTO workflows (id, workspace_id, name, created_at, updated_at) VALUES (?, '', ?, datetime('now'), datetime('now'))", id, name)
func insertWorkflow(t *testing.T, db *sqlx.DB, id, name string) {
now := time.Now().UTC()
_, err := db.Exec("INSERT INTO workflows (id, workspace_id, name, created_at, updated_at) VALUES (?, '', ?, ?, ?)", id, name, now, now)
require.NoError(t, err)
}
+2 -2
View File
@@ -1,14 +1,14 @@
package worktree
import (
"database/sql"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
)
// Provide creates the worktree manager using the shared database connection.
func Provide(db *sql.DB, cfg *config.Config, log *logger.Logger) (*Manager, func() error, error) {
func Provide(db *sqlx.DB, cfg *config.Config, log *logger.Logger) (*Manager, func() error, error) {
store, err := NewSQLiteStore(db)
if err != nil {
return nil, nil, err
+24 -23
View File
@@ -7,16 +7,17 @@ import (
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// SQLiteStore implements Store interface using SQLite.
type SQLiteStore struct {
db *sql.DB
db *sqlx.DB
}
// NewSQLiteStore creates a new SQLite-backed worktree store.
// It uses the provided sql.DB connection and ensures the task_session_worktrees table exists.
func NewSQLiteStore(db *sql.DB) (*SQLiteStore, error) {
// It uses the provided sqlx.DB connection and ensures the task_session_worktrees table exists.
func NewSQLiteStore(db *sqlx.DB) (*SQLiteStore, error) {
store := &SQLiteStore{db: db}
if err := store.initSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize worktree schema: %w", err)
@@ -36,10 +37,10 @@ func (s *SQLiteStore) initSchema() error {
worktree_path TEXT DEFAULT '',
worktree_branch TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
merged_at DATETIME,
deleted_at DATETIME,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
merged_at TIMESTAMP,
deleted_at TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE,
UNIQUE(session_id, worktree_id)
);
@@ -73,7 +74,7 @@ func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error {
wt.UpdatedAt = now
}
_, err := s.db.ExecContext(ctx, `
_, err := s.db.ExecContext(ctx, s.db.Rebind(`
INSERT INTO task_session_worktrees (
id, session_id, worktree_id, repository_id, position,
worktree_path, worktree_branch, status,
@@ -87,7 +88,7 @@ func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error {
updated_at = excluded.updated_at,
merged_at = excluded.merged_at,
deleted_at = excluded.deleted_at
`, uuid.New().String(), wt.SessionID, wt.ID, wt.RepositoryID, 0,
`), uuid.New().String(), wt.SessionID, wt.ID, wt.RepositoryID, 0,
wt.Path, wt.Branch, wt.Status,
wt.CreatedAt, wt.UpdatedAt, wt.MergedAt, wt.DeletedAt)
@@ -100,7 +101,7 @@ func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree
var mergedAt, deletedAt sql.NullTime
var repositoryPath, baseBranch sql.NullString
err := s.db.QueryRowContext(ctx, `
err := s.db.QueryRowContext(ctx, s.db.Rebind(`
SELECT
tsw.worktree_id,
tsw.session_id,
@@ -119,7 +120,7 @@ func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree
LEFT JOIN task_sessions s ON tsw.session_id = s.id
LEFT JOIN repositories r ON tsw.repository_id = r.id
WHERE tsw.worktree_id = ?
`, id).Scan(
`), id).Scan(
&wt.ID,
&wt.SessionID,
&wt.TaskID,
@@ -164,7 +165,7 @@ func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID stri
var mergedAt, deletedAt sql.NullTime
var repositoryPath, baseBranch sql.NullString
err := s.db.QueryRowContext(ctx, `
err := s.db.QueryRowContext(ctx, s.db.Rebind(`
SELECT
tsw.worktree_id,
tsw.session_id,
@@ -183,7 +184,7 @@ func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID stri
INNER JOIN task_sessions s ON tsw.session_id = s.id
LEFT JOIN repositories r ON tsw.repository_id = r.id
WHERE tsw.session_id = ? AND tsw.status = ?
`, sessionID, StatusActive).Scan(
`), sessionID, StatusActive).Scan(
&wt.ID,
&wt.SessionID,
&wt.TaskID,
@@ -229,7 +230,7 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (*
var mergedAt, deletedAt sql.NullTime
var repositoryPath, baseBranch sql.NullString
err := s.db.QueryRowContext(ctx, `
err := s.db.QueryRowContext(ctx, s.db.Rebind(`
SELECT
tsw.worktree_id,
tsw.session_id,
@@ -249,7 +250,7 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (*
LEFT JOIN repositories r ON tsw.repository_id = r.id
WHERE s.task_id = ? AND tsw.status = ?
ORDER BY tsw.created_at DESC LIMIT 1
`, taskID, StatusActive).Scan(
`), taskID, StatusActive).Scan(
&wt.ID,
&wt.SessionID,
&wt.TaskID,
@@ -290,7 +291,7 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (*
// GetWorktreesByTaskID retrieves all worktrees for a task.
func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) ([]*Worktree, error) {
rows, err := s.db.QueryContext(ctx, `
rows, err := s.db.QueryContext(ctx, s.db.Rebind(`
SELECT
tsw.worktree_id,
tsw.session_id,
@@ -309,7 +310,7 @@ func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) (
INNER JOIN task_sessions s ON tsw.session_id = s.id
LEFT JOIN repositories r ON tsw.repository_id = r.id
WHERE s.task_id = ? ORDER BY tsw.created_at DESC
`, taskID)
`), taskID)
if err != nil {
return nil, err
}
@@ -320,7 +321,7 @@ func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) (
// GetWorktreesByRepositoryID retrieves all worktrees for a repository.
func (s *SQLiteStore) GetWorktreesByRepositoryID(ctx context.Context, repoID string) ([]*Worktree, error) {
rows, err := s.db.QueryContext(ctx, `
rows, err := s.db.QueryContext(ctx, s.db.Rebind(`
SELECT
tsw.worktree_id,
tsw.session_id,
@@ -339,7 +340,7 @@ func (s *SQLiteStore) GetWorktreesByRepositoryID(ctx context.Context, repoID str
LEFT JOIN task_sessions s ON tsw.session_id = s.id
LEFT JOIN repositories r ON tsw.repository_id = r.id
WHERE tsw.repository_id = ?
`, repoID)
`), repoID)
if err != nil {
return nil, err
}
@@ -373,7 +374,7 @@ func (s *SQLiteStore) UpdateWorktree(ctx context.Context, wt *Worktree) error {
args = append(args, wt.SessionID)
}
result, err := s.db.ExecContext(ctx, query, args...)
result, err := s.db.ExecContext(ctx, s.db.Rebind(query), args...)
if err != nil {
return err
}
@@ -387,7 +388,7 @@ func (s *SQLiteStore) UpdateWorktree(ctx context.Context, wt *Worktree) error {
// DeleteWorktree removes a worktree record.
func (s *SQLiteStore) DeleteWorktree(ctx context.Context, id string) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE worktree_id = ?`, id)
result, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM task_session_worktrees WHERE worktree_id = ?`), id)
if err != nil {
return err
}
@@ -401,7 +402,7 @@ func (s *SQLiteStore) DeleteWorktree(ctx context.Context, id string) error {
// ListActiveWorktrees returns all worktrees with status 'active'.
func (s *SQLiteStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, error) {
rows, err := s.db.QueryContext(ctx, `
rows, err := s.db.QueryContext(ctx, s.db.Rebind(`
SELECT
tsw.worktree_id,
tsw.session_id,
@@ -420,7 +421,7 @@ func (s *SQLiteStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, err
LEFT JOIN task_sessions s ON tsw.session_id = s.id
LEFT JOIN repositories r ON tsw.repository_id = r.id
WHERE tsw.status = ?
`, StatusActive)
`), StatusActive)
if err != nil {
return nil, err
}
+74
View File
@@ -0,0 +1,74 @@
# Database Configuration
Kandev supports two database backends: **SQLite** (default) and **PostgreSQL**.
## SQLite (default)
SQLite requires no external services and works out of the box.
### Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver |
| `KANDEV_DATABASE_PATH` | `./kandev.db` | Path to the SQLite database file |
### Config file (`config.yaml`)
```yaml
database:
driver: sqlite
path: ./kandev.db
```
No additional setup is needed — the database file is created automatically on first run.
## PostgreSQL
### 1. Create the database
```sql
CREATE USER kandev WITH PASSWORD 'your-password';
CREATE DATABASE kandev OWNER kandev;
```
### 2. Configure Kandev
#### Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Set to `postgres` |
| `KANDEV_DATABASE_HOST` | `localhost` | PostgreSQL host |
| `KANDEV_DATABASE_PORT` | `5432` | PostgreSQL port |
| `KANDEV_DATABASE_USER` | `kandev` | Database user |
| `KANDEV_DATABASE_PASSWORD` | (empty) | Database password |
| `KANDEV_DATABASE_DBNAME` | `kandev` | Database name |
| `KANDEV_DATABASE_SSLMODE` | `disable` | SSL mode (`disable`, `require`, `verify-ca`, `verify-full`) |
| `KANDEV_DATABASE_MAXCONNS` | `25` | Maximum open connections |
| `KANDEV_DATABASE_MINCONNS` | `5` | Minimum idle connections |
#### Config file (`config.yaml`)
```yaml
database:
driver: postgres
host: localhost
port: 5432
user: kandev
password: your-password
dbName: kandev
sslMode: disable
maxConns: 25
minConns: 5
```
### 3. Run
```bash
KANDEV_DATABASE_DRIVER=postgres \
KANDEV_DATABASE_PASSWORD=your-password \
kandev
```
Tables are created automatically on first run — no manual migrations needed.