feat: improve db abstraction (#238)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¬Null, &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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¬Null, &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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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, ¬Null, &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, ¬Null, &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, ¬Null, &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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Reference in New Issue
Block a user