Merge branch 'main' into feature/reuse-executors-gmk

Resolves one content conflict and folds in a prettier auto-fix:

  - service.go: main and our branch each added new fields to the
    Service struct, and gofmt aligned them with different column
    widths. Union both sets (envDestroyer + sessionRunningChecker
    from our branch, remoteBranchLister + repoCloneLocation from
    main) and let gofmt realign the whole block.

  - chat-input-toolbar.tsx: prettier auto-removed a stray blank line
    left after the previous ImplementPlanButton extract.
This commit is contained in:
José Almeida
2026-05-11 23:32:36 +01:00
18 changed files with 934 additions and 76 deletions
+15
View File
@@ -2,6 +2,21 @@
All notable changes to Kandev.
## 0.44.0 - 2026-05-11
### Features
- persist N-entry FIFO message queue ([#864](https://github.com/kdlbs/kandev/pull/864))
- add size/age/backup rotation for file output ([#874](https://github.com/kdlbs/kandev/pull/874))
### Bug Fixes
- list remote branches for provider-backed workspace repos ([#876](https://github.com/kdlbs/kandev/pull/876))
### Refactoring
- close rotating sink on shutdown, add config docs ([#877](https://github.com/kdlbs/kandev/pull/877))
## 0.43.0 - 2026-05-11
### Bug Fixes
+9 -2
View File
@@ -164,7 +164,7 @@ func realMain() int {
}
defer func() {
runCleanups()
_ = log.Sync()
_ = log.Close()
}()
logger.SetDefault(log)
@@ -326,6 +326,13 @@ func startAgentInfrastructure(
log.Info("Repository cloner configured",
zap.String("base_path", cfg.RepoClone.BasePath))
// Let the task service treat the cloner's base path as an implicit
// allow-listed root. Without this, deploys that put the clone base
// outside HOME (e.g. KANDEV_REPOCLONE_BASEPATH=/data/repos in a
// container) fail the discoveryRoots() allow-list check and local
// branch listing returns nothing.
services.Task.SetRepoCloneLocation(repoCloner)
// ============================================
// ORCHESTRATOR
// ============================================
@@ -605,7 +612,7 @@ func awaitShutdown(
go func() {
second := <-quit
log.Warn("Received second shutdown signal, forcing exit", zap.String("signal", second.String()))
_ = log.Sync()
_ = log.Close()
os.Exit(1)
}()
+29
View File
@@ -93,6 +93,14 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log)
slackSvc := initSlackService(dbPool, repos.Secrets, log)
// Plumb GitHub branch listing into the task service so provider-backed
// ("Remote") repos serve branches from the GitHub API rather than relying
// on a local clone that may not exist yet (or ever - some executors clone
// inside their own container).
if githubSvc != nil {
taskSvc.SetRemoteBranchLister(githubBranchListerAdapter{svc: githubSvc})
}
return &Services{
Task: taskSvc,
User: userSvc,
@@ -379,3 +387,24 @@ func buildAgentProfileMatcher(repos *Repositories) wfmodels.AgentProfileMatcher
return ""
}
}
// githubBranchListerAdapter bridges github.Service to the task service's
// RemoteBranchLister interface. It maps github.RepoBranch into the task
// service's Branch shape with Type="remote" so the dialog renders branches
// the same way URL-mode does - bare names without an "origin/" prefix, since
// there is no checked-out clone whose tracking config could disambiguate.
type githubBranchListerAdapter struct {
svc *github.Service
}
func (a githubBranchListerAdapter) ListRepoBranches(ctx context.Context, owner, repo string) ([]taskservice.Branch, error) {
remote, err := a.svc.ListRepoBranches(ctx, owner, repo)
if err != nil {
return nil, err
}
out := make([]taskservice.Branch, 0, len(remote))
for _, b := range remote {
out = append(out, taskservice.Branch{Name: b.Name, Type: "remote"})
}
return out, nil
}
+23 -6
View File
@@ -141,12 +141,16 @@ type LoggingConfig struct {
Format string `mapstructure:"format"`
OutputPath string `mapstructure:"outputPath"`
// Rotation options — apply only when OutputPath is a file path
// Rotation options - apply only when OutputPath is a file path
// (ignored for stdout/stderr). Backed by lumberjack.
MaxSizeMB int `mapstructure:"maxSizeMb"`
MaxBackups int `mapstructure:"maxBackups"`
MaxAgeDays int `mapstructure:"maxAgeDays"`
Compress bool `mapstructure:"compress"`
//
// Note: lumberjack creates the active log file with mode 0600 (owner read/write
// only); the previous os.OpenFile path used 0644. External log shippers or
// sidecars running as a different user will need to run as the same user.
MaxSizeMB int `mapstructure:"maxSizeMb"` // rotate when file exceeds this size; 0 = lumberjack default (100MB)
MaxBackups int `mapstructure:"maxBackups"` // max number of rotated files to retain; 0 = unlimited
MaxAgeDays int `mapstructure:"maxAgeDays"` // max age in days of rotated files; 0 = unlimited
Compress bool `mapstructure:"compress"` // gzip rotated files
}
// RepositoryDiscoveryConfig holds configuration for local repository scanning.
@@ -394,7 +398,14 @@ func validate(cfg *Config) error {
errs = append(errs, "server.port must be between 1 and 65535")
}
// Database validation
// Database validation. Normalize the driver in place so downstream
// case-sensitive comparisons (and the postgres-only branch below) see
// a canonical value.
cfg.Database.Driver = strings.ToLower(cfg.Database.Driver)
validDrivers := map[string]bool{"sqlite": true, "postgres": true}
if !validDrivers[cfg.Database.Driver] {
errs = append(errs, "database.driver must be one of: sqlite, postgres")
}
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")
@@ -405,6 +416,12 @@ func validate(cfg *Config) error {
if cfg.Database.DBName == "" {
errs = append(errs, "database.dbName is required for postgres driver")
}
validSSLModes := map[string]bool{
"disable": true, "require": true, "verify-ca": true, "verify-full": true,
}
if !validSSLModes[strings.ToLower(cfg.Database.SSLMode)] {
errs = append(errs, "database.sslMode must be one of: disable, require, verify-ca, verify-full")
}
}
// NATS validation - optional (uses in-memory event bus if not set)
@@ -3,9 +3,24 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// minimalValidConfig returns a Config that passes validate() out of the box.
// Tests modify a copy to exercise individual validation branches.
func minimalValidConfig() *Config {
return &Config{
Server: ServerConfig{Port: 38429},
Database: DatabaseConfig{Driver: "sqlite"},
Auth: AuthConfig{TokenDuration: 3600},
Logging: LoggingConfig{Level: "info", Format: "text"},
RepositoryDiscovery: RepositoryDiscoveryConfig{
MaxDepth: 5,
},
}
}
func TestResolvedHomeDir_Default(t *testing.T) {
cfg := &Config{}
home, err := os.UserHomeDir()
@@ -57,3 +72,73 @@ func TestResolvedDataDir_DerivedFromHomeDir(t *testing.T) {
t.Errorf("ResolvedDataDir() = %q, want %q", got, want)
}
}
func TestValidate_DatabaseDriver(t *testing.T) {
t.Run("sqlite_ok", func(t *testing.T) {
cfg := minimalValidConfig()
if err := validate(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("mixed_case_postgres_normalized", func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.Driver = "Postgres"
cfg.Database.Port = 5432
cfg.Database.User = "u"
cfg.Database.DBName = "db"
cfg.Database.SSLMode = "disable"
if err := validate(cfg); err != nil {
t.Fatalf("expected mixed-case 'Postgres' to normalize, got %v", err)
}
if cfg.Database.Driver != "postgres" {
t.Errorf("driver not normalized: got %q, want %q", cfg.Database.Driver, "postgres")
}
})
t.Run("unknown_driver_rejected", func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.Driver = "mysql"
err := validate(cfg)
if err == nil || !strings.Contains(err.Error(), "database.driver") {
t.Fatalf("expected database.driver error, got %v", err)
}
})
}
func TestValidate_PostgresSSLMode(t *testing.T) {
for _, mode := range []string{"disable", "require", "verify-ca", "verify-full"} {
t.Run(mode, func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.Driver = "postgres"
cfg.Database.Port = 5432
cfg.Database.User = "u"
cfg.Database.DBName = "db"
cfg.Database.SSLMode = mode
if err := validate(cfg); err != nil && strings.Contains(err.Error(), "sslMode") {
t.Errorf("sslMode %q rejected unexpectedly: %v", mode, err)
}
})
}
t.Run("invalid_rejected", func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.Driver = "postgres"
cfg.Database.Port = 5432
cfg.Database.User = "u"
cfg.Database.DBName = "db"
cfg.Database.SSLMode = "bogus"
err := validate(cfg)
if err == nil || !strings.Contains(err.Error(), "sslMode") {
t.Fatalf("expected sslMode error, got %v", err)
}
})
t.Run("sqlite_ignores_sslmode", func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.SSLMode = "bogus"
if err := validate(cfg); err != nil {
t.Errorf("sqlite should ignore sslMode, got %v", err)
}
})
}
+45 -19
View File
@@ -22,24 +22,29 @@ const (
)
// LoggingConfig holds the configuration for the logger.
//
// This struct is constructed in Go (not unmarshaled via viper/mapstructure),
// so it does not carry struct tags. Callers map their own config types
// (e.g. config.LoggingConfig) into these fields explicitly.
type LoggingConfig struct {
Level string `mapstructure:"level"` // debug, info, warn, error
Format string `mapstructure:"format"` // json, console
OutputPath string `mapstructure:"output_path"` // stdout, stderr, or file path
Level string // debug, info, warn, error
Format string // json, console
OutputPath string // stdout, stderr, or file path
// Rotation options — apply only when OutputPath is a file path
// Rotation options - apply only when OutputPath is a file path
// (ignored for stdout/stderr). Backed by lumberjack.
MaxSizeMB int `mapstructure:"max_size_mb"` // rotate when file exceeds this size; 0 = lumberjack default (100MB)
MaxBackups int `mapstructure:"max_backups"` // max number of rotated files to retain; 0 = unlimited
MaxAgeDays int `mapstructure:"max_age_days"` // max age of rotated files; 0 = unlimited
Compress bool `mapstructure:"compress"` // gzip rotated files
MaxSizeMB int // rotate when file exceeds this size; 0 = lumberjack default (100MB)
MaxBackups int // max number of rotated files to retain; 0 = unlimited
MaxAgeDays int // max age of rotated files; 0 = unlimited
Compress bool // gzip rotated files
}
// Logger wraps zap.Logger to provide structured logging with helper methods.
type Logger struct {
zap *zap.Logger
sugar *zap.SugaredLogger
fields []zap.Field
zap *zap.Logger
sugar *zap.SugaredLogger
fields []zap.Field
rotator *lumberjack.Logger // non-nil only when OutputPath is a file path
}
var (
@@ -95,20 +100,24 @@ func NewLogger(cfg LoggingConfig) (*Logger, error) {
encoder = zapcore.NewJSONEncoder(encoderConfig)
}
var writeSyncer zapcore.WriteSyncer
var (
writeSyncer zapcore.WriteSyncer
rotator *lumberjack.Logger
)
switch cfg.OutputPath {
case "", "stdout":
writeSyncer = zapcore.AddSync(os.Stdout)
case "stderr":
writeSyncer = zapcore.AddSync(os.Stderr)
default:
writeSyncer = zapcore.AddSync(&lumberjack.Logger{
rotator = &lumberjack.Logger{
Filename: cfg.OutputPath,
MaxSize: cfg.MaxSizeMB,
MaxBackups: cfg.MaxBackups,
MaxAge: cfg.MaxAgeDays,
Compress: cfg.Compress,
})
}
writeSyncer = zapcore.AddSync(rotator)
}
outputCore := zapcore.NewCore(encoder, writeSyncer, level)
@@ -117,8 +126,9 @@ func NewLogger(cfg LoggingConfig) (*Logger, error) {
zapLogger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
return &Logger{
zap: zapLogger,
sugar: zapLogger.Sugar(),
zap: zapLogger,
sugar: zapLogger.Sugar(),
rotator: rotator,
}, nil
}
@@ -151,12 +161,28 @@ func (l *Logger) Sync() error {
return l.zap.Sync()
}
// Close flushes pending log entries and releases the file handle held by the
// rotating file sink, if any. Safe to call on a stdout/stderr logger (no-op).
// Call before process shutdown to ensure the final batch reaches disk and the
// rotation cycle can complete cleanly.
func (l *Logger) Close() error {
_ = l.zap.Sync()
if l.rotator != nil {
return l.rotator.Close()
}
return nil
}
// WithFields returns a new Logger with the given fields added.
// The rotator reference is propagated so Close() on a derived logger
// still releases the underlying file handle.
func (l *Logger) WithFields(fields ...zap.Field) *Logger {
z := l.zap.With(fields...)
return &Logger{
zap: l.zap.With(fields...),
sugar: l.zap.With(fields...).Sugar(),
fields: append(l.fields, fields...),
zap: z,
sugar: z.Sugar(),
fields: append(l.fields, fields...),
rotator: l.rotator,
}
}
@@ -0,0 +1,117 @@
package logger
import (
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
)
func TestNewLogger_StdoutHasNoRotator(t *testing.T) {
for _, path := range []string{"", "stdout", "stderr"} {
t.Run(path, func(t *testing.T) {
log, err := NewLogger(LoggingConfig{Level: "info", Format: "json", OutputPath: path})
if err != nil {
t.Fatalf("NewLogger: %v", err)
}
if log.rotator != nil {
t.Fatalf("expected rotator to be nil for %q output, got %#v", path, log.rotator)
}
if err := log.Close(); err != nil {
t.Fatalf("Close on stdout/stderr logger should be a no-op, got %v", err)
}
})
}
}
func TestNewLogger_FileOutputUsesRotator(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "kandev.log")
log, err := NewLogger(LoggingConfig{
Level: "info",
Format: "json",
OutputPath: logPath,
MaxSizeMB: 10,
MaxBackups: 3,
MaxAgeDays: 7,
Compress: true,
})
if err != nil {
t.Fatalf("NewLogger: %v", err)
}
if log.rotator == nil {
t.Fatal("expected rotator to be configured for file output")
}
if log.rotator.Filename != logPath {
t.Errorf("Filename: got %q, want %q", log.rotator.Filename, logPath)
}
if log.rotator.MaxSize != 10 {
t.Errorf("MaxSize: got %d, want 10", log.rotator.MaxSize)
}
if log.rotator.MaxBackups != 3 {
t.Errorf("MaxBackups: got %d, want 3", log.rotator.MaxBackups)
}
if log.rotator.MaxAge != 7 {
t.Errorf("MaxAge: got %d, want 7", log.rotator.MaxAge)
}
if !log.rotator.Compress {
t.Error("Compress: got false, want true")
}
log.Info("hello", zap.String("k", "v"))
if err := log.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read log file: %v", err)
}
if !strings.Contains(string(data), "hello") {
t.Errorf("log file missing entry; got %q", string(data))
}
}
func TestWithFields_PropagatesRotator(t *testing.T) {
dir := t.TempDir()
log, err := NewLogger(LoggingConfig{
Level: "info",
Format: "json",
OutputPath: filepath.Join(dir, "kandev.log"),
})
if err != nil {
t.Fatalf("NewLogger: %v", err)
}
derived := log.WithFields(zap.String("k", "v"))
if derived.rotator != log.rotator {
t.Fatalf("WithFields dropped rotator: got %p, want %p", derived.rotator, log.rotator)
}
// Derived helpers all funnel through WithFields, so spot-check one.
if log.WithTaskID("t1").rotator != log.rotator {
t.Fatal("WithTaskID dropped rotator")
}
}
func TestLoggerClose_IsIdempotent(t *testing.T) {
dir := t.TempDir()
log, err := NewLogger(LoggingConfig{
Level: "info",
Format: "json",
OutputPath: filepath.Join(dir, "kandev.log"),
})
if err != nil {
t.Fatalf("NewLogger: %v", err)
}
if err := log.Close(); err != nil {
t.Fatalf("first Close: %v", err)
}
if err := log.Close(); err != nil {
t.Fatalf("second Close should be a no-op, got %v", err)
}
}
@@ -58,6 +58,11 @@ var ErrPathNotAllowed = errors.New("path is not within an allowed root")
// gitHEAD is the HEAD git ref.
const gitHEAD = "HEAD"
// sourceTypeLocal is the Repository.SourceType value for on-machine repos
// (a path the user discovered or added manually). Provider-backed repos use
// other values like "provider".
const sourceTypeLocal = "local"
func (s *Service) DiscoverLocalRepositories(ctx context.Context, root string) (RepositoryDiscoveryResult, error) {
roots := s.discoveryRoots()
if root != "" {
@@ -149,7 +154,16 @@ type BranchListResult struct {
// (by id, path resolved from the DB row) or an on-machine folder (by path
// directly). Exactly one of `repoID` or `path` should be set; the caller
// (the HTTP handler) validates that.
//
// For provider-backed workspace repos (the "Remote" badge in the UI), the
// branches come from the remote API rather than a local clone. This makes
// the picker work the moment a URL is added - before the orchestrator's
// async clone finishes, and even when no clone ever happens because the
// chosen executor runs the agent in a container that clones on its own.
func (s *Service) ListBranches(ctx context.Context, repoID, path string) ([]Branch, error) {
if remote, ok, err := s.listRemoteBranchesIfApplicable(ctx, repoID); ok {
return remote, err
}
resolved, err := s.resolveBranchListingPath(ctx, repoID, path)
if err != nil {
return nil, err
@@ -160,6 +174,14 @@ func (s *Service) ListBranches(ctx context.Context, repoID, path string) ([]Bran
// ListBranchesWithCurrent is ListBranches plus the current-branch readout.
// One method so the handler resolves the path once instead of twice.
func (s *Service) ListBranchesWithCurrent(ctx context.Context, repoID, path string) (BranchListResult, error) {
if remote, ok, err := s.listRemoteBranchesIfApplicable(ctx, repoID); ok {
if err != nil {
return BranchListResult{}, err
}
// No CurrentBranch for remote: there's no working tree to check.
// The dialog falls back to its preferred-default-branch heuristic.
return BranchListResult{Branches: remote}, nil
}
resolved, err := s.resolveBranchListingPath(ctx, repoID, path)
if err != nil {
return BranchListResult{}, err
@@ -176,6 +198,36 @@ func (s *Service) ListBranchesWithCurrent(ctx context.Context, repoID, path stri
}, nil
}
// listRemoteBranchesIfApplicable returns (branches, true, err) when the repo
// should be answered from a remote provider, and (_, false, _) when the
// caller should fall through to the local-path code path.
//
// "Applicable" means: a repository id is supplied, the repo has a non-local
// source_type (i.e. is provider-backed), and the provider has a registered
// remote lister. The local-path arm of ListBranches stays untouched; this
// only widens the answer for the existing repository-id arm.
//
// Errors from GetRepository propagate with handled=true so callers
// short-circuit instead of falling through to resolveBranchListingPath,
// which would re-issue the same DB lookup. Repo-not-found falls through.
func (s *Service) listRemoteBranchesIfApplicable(ctx context.Context, repoID string) ([]Branch, bool, error) {
if repoID == "" || s.remoteBranchLister == nil {
return nil, false, nil
}
repo, err := s.repoEntities.GetRepository(ctx, repoID)
if err != nil {
return nil, true, err
}
if repo == nil {
return nil, false, nil
}
if repo.SourceType == sourceTypeLocal || repo.ProviderOwner == "" || repo.ProviderName == "" {
return nil, false, nil
}
branches, err := s.remoteBranchLister.ListRepoBranches(ctx, repo.ProviderOwner, repo.ProviderName)
return branches, true, err
}
// resolveBranchListingPath turns the request inputs into a validated
// absolute path to list branches in. Both inputs go through the same
// allowed-roots / dir / symlink validation via resolveAllowedLocalPath.
@@ -292,14 +344,24 @@ func pathWithinRoots(abs string, roots []string) (string, error) {
}
func (s *Service) discoveryRoots() []string {
var roots []string
if len(s.discoveryConfig.Roots) > 0 {
return normalizeRoots(s.discoveryConfig.Roots)
roots = append(roots, s.discoveryConfig.Roots...)
} else if home, err := os.UserHomeDir(); err == nil {
roots = append(roots, home)
}
homeDir, err := os.UserHomeDir()
if err != nil {
return []string{}
// The orchestrator clones provider-backed repos into a configurable base
// path. When that base path sits outside HOME (e.g. /data/repos in a
// container deployment) it would otherwise be rejected by the allow-list
// and local branch listing would silently return nothing. Adding it here
// keeps the allow-list narrow while still covering kandev's own clone
// destination.
if s.repoCloneLocation != nil {
if base, err := s.repoCloneLocation.ExpandedBasePath(); err == nil && base != "" {
roots = append(roots, base)
}
}
return []string{filepath.Clean(homeDir)}
return normalizeRoots(roots)
}
func (s *Service) discoveryMaxDepth() int {
@@ -13,6 +13,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
)
@@ -637,3 +638,179 @@ func newDiscoveryService(t *testing.T, root string) *Service {
MaxDepth: 6,
})
}
// --- listRemoteBranchesIfApplicable + discoveryRoots routing ---
// stubRemoteLister captures the call args and returns canned branches/err.
type stubRemoteLister struct {
branches []Branch
err error
calls int
}
func (s *stubRemoteLister) ListRepoBranches(_ context.Context, _, _ string) ([]Branch, error) {
s.calls++
return s.branches, s.err
}
// stubRepoErrors embeds a real RepositoryEntityRepository and overrides
// GetRepository to return a forced error - lets us cover the DB-error
// branch without standing up a broken sqlite.
type stubRepoErrors struct {
repository.RepositoryEntityRepository
getErr error
}
func (s *stubRepoErrors) GetRepository(_ context.Context, _ string) (*models.Repository, error) {
return nil, s.getErr
}
func TestListBranches_RoutesProviderRepoToRemoteLister(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "ws"}); err != nil {
t.Fatalf("create workspace: %v", err)
}
mustCreateRepo(t, repo, &models.Repository{
ID: "remote-1",
WorkspaceID: "ws-1",
Name: "owner/repo",
SourceType: "provider",
Provider: "github",
ProviderOwner: "owner",
ProviderName: "repo",
})
lister := &stubRemoteLister{branches: []Branch{{Name: "main", Type: "remote"}, {Name: "develop", Type: "remote"}}}
svc.SetRemoteBranchLister(lister)
got, err := svc.ListBranches(ctx, "remote-1", "")
if err != nil {
t.Fatalf("ListBranches: %v", err)
}
if lister.calls != 1 {
t.Fatalf("remote lister calls = %d, want 1", lister.calls)
}
if len(got) != 2 || got[0].Name != "main" || got[1].Name != "develop" {
t.Fatalf("unexpected branches: %+v", got)
}
}
func TestListBranches_FallsThroughWhenNoRemoteListerWired(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "ws"}); err != nil {
t.Fatalf("create workspace: %v", err)
}
mustCreateRepo(t, repo, &models.Repository{
ID: "remote-2", WorkspaceID: "ws-1", Name: "o/r",
SourceType: "provider", Provider: "github", ProviderOwner: "o", ProviderName: "r",
})
// No SetRemoteBranchLister call. listRemoteBranchesIfApplicable should
// return ok=false and the call falls through to the local-path arm,
// which errors out on empty local_path.
_, err := svc.ListBranches(ctx, "remote-2", "")
if err == nil {
t.Fatal("expected local-path error when remote lister unwired")
}
}
func TestListBranches_FallsThroughForLocalSourceType(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "ws"}); err != nil {
t.Fatalf("create workspace: %v", err)
}
mustCreateRepo(t, repo, &models.Repository{
ID: "local-1", WorkspaceID: "ws-1", Name: "local",
SourceType: sourceTypeLocal, Provider: "github", ProviderOwner: "o", ProviderName: "r",
})
lister := &stubRemoteLister{branches: []Branch{{Name: "main"}}}
svc.SetRemoteBranchLister(lister)
// Local source-type repos must use the local-path arm even with provider
// info populated. local_path is empty here, so we expect an error - what
// matters for the test is that the remote lister is never consulted.
_, _ = svc.ListBranches(ctx, "local-1", "")
if lister.calls != 0 {
t.Fatalf("remote lister called for source_type=local: calls = %d", lister.calls)
}
}
func TestListBranches_FallsThroughOnMissingProviderInfo(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "ws"}); err != nil {
t.Fatalf("create workspace: %v", err)
}
mustCreateRepo(t, repo, &models.Repository{
ID: "remote-3", WorkspaceID: "ws-1", Name: "no-owner",
SourceType: "provider", Provider: "github", ProviderOwner: "", ProviderName: "repo",
})
lister := &stubRemoteLister{branches: []Branch{{Name: "main"}}}
svc.SetRemoteBranchLister(lister)
_, _ = svc.ListBranches(ctx, "remote-3", "")
if lister.calls != 0 {
t.Fatalf("remote lister called for repo with no owner: calls = %d", lister.calls)
}
}
func TestListBranches_PropagatesRemoteListerError(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "ws"}); err != nil {
t.Fatalf("create workspace: %v", err)
}
mustCreateRepo(t, repo, &models.Repository{
ID: "remote-4", WorkspaceID: "ws-1", Name: "o/r",
SourceType: "provider", Provider: "github", ProviderOwner: "o", ProviderName: "r",
})
wantErr := errors.New("rate limited")
svc.SetRemoteBranchLister(&stubRemoteLister{err: wantErr})
if _, err := svc.ListBranches(ctx, "remote-4", ""); !errors.Is(err, wantErr) {
t.Fatalf("ListBranches err = %v, want %v", err, wantErr)
}
}
func TestListBranches_PropagatesGetRepositoryError(t *testing.T) {
svc, _, repo := createTestService(t)
wrapped := &stubRepoErrors{RepositoryEntityRepository: repo, getErr: errors.New("db down")}
// Swap repoEntities for the wrapper to force the error path. Direct field
// access stays inside the package, which is fine for a white-box test.
svc.repoEntities = wrapped
svc.SetRemoteBranchLister(&stubRemoteLister{})
_, err := svc.ListBranches(context.Background(), "repo-id", "")
if err == nil || err.Error() != "db down" {
t.Fatalf("ListBranches err = %v, want db down", err)
}
}
// --- discoveryRoots extension ---
type stubCloneLocation struct{ path string }
func (s stubCloneLocation) ExpandedBasePath() (string, error) { return s.path, nil }
func TestDiscoveryRoots_IncludesCloneBasePath(t *testing.T) {
svc, _, _ := createTestService(t)
cloneDir := t.TempDir()
svc.SetRepoCloneLocation(stubCloneLocation{path: cloneDir})
roots := svc.discoveryRoots()
normalizedClone := filepath.Clean(cloneDir)
for _, r := range roots {
if r == normalizedClone {
return
}
}
t.Fatalf("clone base path %q missing from discoveryRoots %v", normalizedClone, roots)
}
func mustCreateRepo(t *testing.T, repo repository.RepositoryEntityRepository, r *models.Repository) {
t.Helper()
if err := repo.CreateRepository(context.Background(), r); err != nil {
t.Fatalf("create repository: %v", err)
}
}
@@ -138,6 +138,8 @@ type Service struct {
branchFetcher *branchFetcher
envDestroyer EnvironmentDestroyer
sessionRunningChecker SessionRunningChecker
remoteBranchLister RemoteBranchLister
repoCloneLocation RepoCloneLocation
}
// NewService creates a new task service
@@ -198,3 +200,32 @@ func (s *Service) SetStartStepResolver(resolver StartStepResolver) {
func (s *Service) SetQuickChatDir(dir string) {
s.quickChatDir = dir
}
// RemoteBranchLister fetches branches from a provider's remote (e.g. GitHub
// API) without needing a local clone. Used by ListBranches so a repo that is
// registered as remote ("Remote" badge in the UI) can serve branches before
// or even without the orchestrator finishing its clone.
type RemoteBranchLister interface {
ListRepoBranches(ctx context.Context, owner, repo string) ([]Branch, error)
}
// SetRemoteBranchLister wires the remote branch source. Currently only GitHub
// is plumbed; other providers can be added by extending the adapter.
func (s *Service) SetRemoteBranchLister(lister RemoteBranchLister) {
s.remoteBranchLister = lister
}
// RepoCloneLocation reports the base path the orchestrator clones repos into
// (e.g. ~/.kandev/repos or KANDEV_REPOCLONE_BASEPATH). Listing local branches
// for a cloned repo requires that path to be allow-listed by
// discoveryRoots(); without this hook clones to a custom basepath silently
// fall outside the allow-list and branch listing returns no results.
type RepoCloneLocation interface {
ExpandedBasePath() (string, error)
}
// SetRepoCloneLocation wires the cloner so its base path is treated as an
// implicit discovery root.
func (s *Service) SetRepoCloneLocation(loc RepoCloneLocation) {
s.repoCloneLocation = loc
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kandev",
"version": "0.43.0",
"version": "0.44.0",
"private": false,
"description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value",
"license": "AGPL-3.0-only",
@@ -282,7 +282,10 @@ export function branchToOption(b: Branch): PillOption {
// from local branches with the same short name (e.g. "main" vs "origin/main").
// Without the prefix, the dropdown shows two indistinguishable rows.
const display = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name;
const badge = b.type === "local" ? "local" : (b.remote ?? "remote");
// `||` (not `??`) so an empty-string `remote` falls back too. Provider-backed
// workspace repos (URL-added) list branches without a tracking remote, so the
// backend sends `remote: ""`; `??` would render an invisible empty badge.
const badge = b.type === "local" ? "local" : b.remote || "remote";
return {
value: display,
label: display,
@@ -13,7 +13,6 @@ import {
IconPaperclip,
} from "@tabler/icons-react";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { GridSpinner } from "@/components/grid-spinner";
import { Button } from "@kandev/ui/button";
@@ -0,0 +1,122 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
// Regression test for the user-reported bug:
//
// 1. Open the create-task dialog, switch to GitHub URL mode, enter a URL.
// 2. Submit the task - the URL flow registers the repo in the workspace as
// a remote (provider-backed) repository, without a local clone.
// 3. Reopen the dialog - the repo now appears in the workspace dropdown.
// Pre-fix: picking it showed "no branches" with the
// "No branches available for this repository." tooltip, because the
// backend tried to list branches from an empty local_path and the
// frontend cached the failure as a successful (empty) load.
//
// Fix: branch listing for a provider-backed repo now goes through the
// GitHub API (mocked in e2e) instead of the local clone. The local clone is
// only consulted when source_type=local. This test deliberately uses a fake
// owner/repo so the orchestrator's background `gh repo clone` fails - that
// keeps local_path empty, proving the remote-first path is what's serving
// the branches.
test.describe("Create-task URL flow - branches after reopen", () => {
test.describe.configure({ retries: 1 });
test("repo added via GitHub URL still lists branches when re-picked from the workspace dropdown", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
// Fake owner/repo so the background clone in the orchestrator fails with
// 404 and local_path stays empty. The bug only repros when local listing
// can't help - that's the path the fix has to keep working.
const owner = "kandev-e2e-no-such-owner";
const repo = "kandev-e2e-no-such-repo";
const repoFullName = `${owner}/${repo}`;
const taskTitle = "URL repo reopen bug";
await apiClient.mockGitHubAddBranches(owner, repo, [
{ name: "main" },
{ name: "develop" },
{ name: "feature/test" },
]);
const kanban = new KanbanPage(testPage);
await kanban.goto();
// ── First open: enter URL, pick worktree executor, submit task ──
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
await testPage.getByTestId("toggle-github-url").click();
await testPage.getByTestId("github-url-input").fill(`https://github.com/${repoFullName}`);
await testPage.getByTestId("task-title-input").fill(taskTitle);
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
await selectWorktreeExecutor(testPage);
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
await startBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
// Wait for the workspace to surface the URL-added repo so the second
// dialog open sees it in the dropdown.
await expect
.poll(
async () => {
const res = await apiClient.rawRequest(
"GET",
`/api/v1/workspaces/${seedData.workspaceId}/repositories`,
);
const body = (await res.json()) as { repositories?: Array<{ name: string }> };
return (body.repositories ?? []).some((r) => r.name === repoFullName);
},
{ timeout: 15_000, intervals: [200, 500, 1000] },
)
.toBe(true);
// ── Second open: pick the same repo from the workspace dropdown ──
await kanban.createTaskButton.first().click();
await expect(dialog).toBeVisible();
await selectWorktreeExecutor(testPage);
await testPage.getByTestId("repo-chip-trigger").first().click();
await testPage
.getByRole("option", { name: new RegExp(`^${repoFullName}\\b`) })
.first()
.click();
// The branch chip should become enabled once the remote-branch fetch
// resolves. Pre-fix this never happened: the listing returned an error
// and the chip stayed disabled with the "no branches" tooltip.
const branchChip = testPage.getByTestId("branch-chip-trigger").first();
await expect(branchChip).toBeEnabled({ timeout: 10_000 });
// Open the dropdown and verify every mocked branch is present. Substring
// name matching (string, not regex) tolerates the "remote" badge that the
// pill renders alongside the branch name in the option's accessible name.
await branchChip.click();
for (const name of ["main", "develop", "feature/test"]) {
await expect(testPage.getByRole("option", { name })).toBeVisible({ timeout: 5_000 });
}
});
});
// Pin the executor on every dialog open. This test runs in a shard alongside
// create-task-branch-selector specs that leave local-executor profiles
// behind, and a stale local default trips the local-executor's
// currentLocalBranch autoselect race. Worktree is the cleanest neutral
// choice and exercises the same remote-branch-listing path.
async function selectWorktreeExecutor(page: import("@playwright/test").Page): Promise<void> {
const selector = page.getByTestId("executor-profile-selector");
await selector.click();
await page
.getByRole("option", { name: /Worktree/i })
.first()
.click();
}
+148
View File
@@ -0,0 +1,148 @@
# Configuration
Kandev's backend reads configuration from three sources, in this order of precedence (later sources override earlier ones):
1. Built-in defaults (`apps/backend/internal/common/config/config.go`).
2. A YAML config file (`config.yaml`).
3. Environment variables (`KANDEV_*`).
Both the file and env vars are optional; the backend boots with sensible defaults out of the box. See [`docker.md`](./docker.md) and [`k8s.md`](./k8s.md) for deployment-specific tables; this page is the full reference.
## Config file
The backend looks for `config.yaml` in, in order:
- An explicit path passed at startup (used in tests).
- The current working directory.
- `/etc/kandev/`.
A missing file is not an error - defaults plus env vars take over.
## Environment variables
All env vars use the `KANDEV_` prefix. Nested keys map by replacing `.` with `_` and uppercasing; **camelCase becomes one uppercase run** (no underscore inserted), because viper does not synthesize a snake_case form.
| YAML key | Env var |
|---|---|
| `server.port` | `KANDEV_SERVER_PORT` |
| `server.webInternalUrl` | `KANDEV_SERVER_WEBINTERNALURL` (or alias `KANDEV_WEB_INTERNAL_URL`) |
| `database.dbName` | `KANDEV_DATABASE_DBNAME` |
| `logging.maxSizeMb` | `KANDEV_LOGGING_MAXSIZEMB` |
| `homeDir` | `KANDEV_HOME_DIR` (alias) |
| `logging.level` | `KANDEV_LOG_LEVEL` (alias) |
The aliases on the right are explicit bindings - see `LoadWithPath` in `config.go` for the full list. New keys should follow the deterministic rule (`KANDEV_<SECTION>_<KEYUPPERCASE>`) unless there is a reason to add an alias.
## Full `config.yaml` example
Every key shown here has a default - copying the whole file changes nothing. Use it as a starting point and delete what you don't need to override.
```yaml
# Kandev root directory. Empty = ~/.kandev (or KANDEV_HOME_DIR if set).
# All workspace artifacts (data, tasks, worktrees, repos, sessions) live here.
homeDir: ""
server:
host: "0.0.0.0"
port: 38429 # API + WebSocket + Web UI
readTimeout: 30 # seconds
writeTimeout: 30 # seconds
webInternalUrl: "" # internal URL the backend uses to call the web app
database:
driver: "sqlite" # "sqlite" or "postgres"
path: "" # sqlite: empty = $homeDir/data/kandev.db
# postgres-only fields below (ignored when driver=sqlite)
host: "localhost"
port: 5432
user: "kandev" # required when driver=postgres
password: "" # required when driver=postgres in most setups
dbName: "kandev" # required when driver=postgres
sslMode: "disable" # disable | require | verify-ca | verify-full
maxConns: 25
minConns: 5
nats:
url: "" # empty = use in-memory event bus
clusterId: "kandev-cluster"
clientId: "kandev-client"
maxReconnects: 10
events:
namespace: "" # empty = derive from runtime data identity
docker:
enabled: true # disables Docker-based executors when false
host: "" # empty = platform default (unix:///var/run/docker.sock, etc.)
apiVersion: "" # empty = auto-negotiate
tlsVerify: false
defaultNetwork: "kandev-network"
volumeBasePath: "" # empty = /var/lib/kandev/volumes (Linux/macOS)
agent:
standaloneHost: "localhost"
standalonePort: 39429 # agentctl control port
auth:
jwtSecret: "" # empty = auto-generate a random dev secret on boot
tokenDuration: 3600 # seconds
logging:
level: "info" # debug | info | warn | error
format: "text" # text | json (auto = json when KUBERNETES_SERVICE_HOST is set)
outputPath: "stdout" # stdout | stderr | /path/to/file.log
# Rotation - only applied when outputPath is a file path.
# Active log files are created with mode 0600 (owner-only).
maxSizeMb: 100 # rotate at this size; 0 = lumberjack default (100MB)
maxBackups: 5 # 0 = unlimited
maxAgeDays: 30 # 0 = unlimited
compress: true # gzip rotated files
repositoryDiscovery:
roots: [] # absolute paths to scan for local git repos
maxDepth: 5
worktree:
enabled: true
defaultBranch: "main"
cleanupOnRemove: true
fetchTimeoutSeconds: 60
pullTimeoutSeconds: 60
repoClone:
basePath: "" # empty = $homeDir/repos
debug:
pprofEnabled: false # enables /debug/pprof and /api/v1/debug/memory
```
## Required vs optional
Almost every field has a default and is optional. The exceptions:
| Field | When required | What happens otherwise |
|---|---|---|
| `database.user` | `database.driver=postgres` | Startup fails with `database.user is required for postgres driver` |
| `database.dbName` | `database.driver=postgres` | Startup fails with `database.dbName is required for postgres driver` |
| `database.password` | `database.driver=postgres` in most setups (some Postgres configs allow passwordless local auth) | Connection fails at runtime |
| `auth.jwtSecret` | Never strictly required, but in production set an explicit value | A random secret is generated on boot - tokens become invalid on restart |
Validated value sets (any other value is a startup error):
| Field | Allowed values |
|---|---|
| `server.port` | `1`-`65535` |
| `database.driver` | `sqlite`, `postgres` |
| `database.port` | `1`-`65535` (only validated when `driver=postgres`) |
| `database.sslMode` | `disable`, `require`, `verify-ca`, `verify-full` (only validated when `driver=postgres`) |
| `logging.level` | `debug`, `info`, `warn`, `error` |
| `logging.format` | `json`, `text` |
## Tips
- **Env vars override the file.** Useful for secrets (`KANDEV_DATABASE_PASSWORD`) and per-environment knobs (`KANDEV_LOG_LEVEL`).
- **K8s / Docker:** prefer env vars for everything; skip the YAML file entirely.
- **Local dev:** drop a `config.yaml` next to where you run the backend; viper picks it up from the current working directory.
- **Format auto-detection** (`logging.format`): set `KANDEV_ENV=production` or run inside K8s and you get JSON logs without changing the config.
+18 -16
View File
@@ -2,17 +2,19 @@
Kandev supports two database backends: **SQLite** (default) and **PostgreSQL**.
> See [`configuration.md`](./configuration.md) for the full backend configuration reference (every YAML key + env var). This page focuses on database-specific settings.
## SQLite (default)
SQLite requires no external services and works out of the box.
### Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_HOME_DIR` | `~/.kandev` | Kandev root directory — contains `data/` (DB), `tasks/`, `worktrees/`, `repos/`, `sessions/`, and `lsp-servers/` |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver |
| `KANDEV_DATABASE_PATH` | `$KANDEV_HOME_DIR/data/kandev.db` | Path to the SQLite database file (override) |
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `KANDEV_HOME_DIR` | No | `~/.kandev` | Kandev root directory - contains `data/` (DB), `tasks/`, `worktrees/`, `repos/`, `sessions/`, and `lsp-servers/` |
| `KANDEV_DATABASE_DRIVER` | No | `sqlite` | Database driver |
| `KANDEV_DATABASE_PATH` | No | `$KANDEV_HOME_DIR/data/kandev.db` | Path to the SQLite database file (override) |
### Config file (`config.yaml`)
@@ -38,17 +40,17 @@ CREATE DATABASE kandev OWNER 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 |
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `KANDEV_DATABASE_DRIVER` | Yes | `sqlite` | Set to `postgres` |
| `KANDEV_DATABASE_HOST` | No | `localhost` | PostgreSQL host |
| `KANDEV_DATABASE_PORT` | No | `5432` | PostgreSQL port |
| `KANDEV_DATABASE_USER` | Yes | `kandev` | Database user |
| `KANDEV_DATABASE_PASSWORD` | Usually | (empty) | Database password - required unless your Postgres allows passwordless auth |
| `KANDEV_DATABASE_DBNAME` | Yes | `kandev` | Database name |
| `KANDEV_DATABASE_SSLMODE` | No | `disable` | SSL mode (`disable`, `require`, `verify-ca`, `verify-full`) |
| `KANDEV_DATABASE_MAXCONNS` | No | `25` | Maximum open connections |
| `KANDEV_DATABASE_MINCONNS` | No | `5` | Minimum idle connections |
#### Config file (`config.yaml`)
+17 -8
View File
@@ -61,14 +61,23 @@ docker run -p 38429:38429 \
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_HOME_DIR` | `/data` | Kandev home directory — contains `data/` (DB), `tasks/`, `worktrees/`, `repos/`, `sessions/`, and `lsp-servers/` |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | `$KANDEV_HOME_DIR/data/kandev.db` | SQLite database file path (override) |
| `KANDEV_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `KANDEV_LOGGING_FORMAT` | `text` | Log format: `text` or `json` |
| `KANDEV_DOCKER_ENABLED` | `false` | Enable Docker runtime for agents (see below) |
See [`configuration.md`](./configuration.md) for the full reference (including the YAML form and every knob the backend reads). The table below covers the env vars most often set in a Docker deployment.
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `KANDEV_HOME_DIR` | No | `/data` | Kandev home directory - contains `data/` (DB), `tasks/`, `worktrees/`, `repos/`, `sessions/`, and `lsp-servers/` |
| `KANDEV_DATABASE_DRIVER` | No | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | No | `$KANDEV_HOME_DIR/data/kandev.db` | SQLite database file path (override) |
| `KANDEV_LOG_LEVEL` | No | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `KANDEV_LOGGING_FORMAT` | No | `text` | Log format: `text` or `json` |
| `KANDEV_LOGGING_OUTPUTPATH` | No | `stdout` | Log destination: `stdout`, `stderr`, or a file path (rotated when a file) |
| `KANDEV_LOGGING_MAXSIZEMB` | No | `100` | Rotate the log file when it exceeds this size (MB). File output only. |
| `KANDEV_LOGGING_MAXBACKUPS` | No | `5` | Max rotated files to retain (`0` = unlimited). File output only. |
| `KANDEV_LOGGING_MAXAGEDAYS` | No | `30` | Max age of rotated files in days (`0` = unlimited). File output only. |
| `KANDEV_LOGGING_COMPRESS` | No | `true` | Gzip rotated files. File output only. |
| `KANDEV_DOCKER_ENABLED` | No | `false` | Enable Docker runtime for agents (see below) |
> **File-mode note:** when `KANDEV_LOGGING_OUTPUTPATH` is a file path, the active log file is created with mode `0600` (owner read/write only). Run any log shipper or sidecar as the same user, or use `stdout`/`stderr` and let the container runtime collect logs.
> **Upgrading from a pre-`KANDEV_HOME_DIR` image?** The SQLite DB path moved from `/data/kandev.db` to `/data/data/kandev.db`. The backend auto-migrates the legacy `kandev.db` (plus any `-wal`/`-shm` files) on first boot — look for `Migrated SQLite database from pre-KANDEV_HOME_DIR location` in the logs. If you prefer to pin the old location instead, set `-e KANDEV_DATABASE_PATH=/data/kandev.db`. If you previously set `KANDEV_DATA_DIR`, replace it with `KANDEV_HOME_DIR`.
+26 -17
View File
@@ -96,28 +96,37 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper).
### Core Settings
| Env Var | Default | Description |
|---------|---------|-------------|
| `KANDEV_SERVER_PORT` | `38429` | Server port (API + WebSocket + Web UI) |
| `KANDEV_HOME_DIR` | `/data` | Kandev home directory — contains `data/` (DB), `tasks/`, `worktrees/`, `repos/`, `sessions/`, and `lsp-servers/` |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | `$KANDEV_HOME_DIR/data/kandev.db` | SQLite database file path (override) |
| `KANDEV_DOCKER_ENABLED` | `false` | Enable Docker runtime for agents (requires DinD) |
| `KANDEV_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `KANDEV_LOGGING_FORMAT` | auto | Log format: `json` (auto-detected in K8s) or `text` |
See [`configuration.md`](./configuration.md) for the full reference (every backend knob and its YAML form). The tables below cover what's most commonly set in K8s manifests.
| Env Var | Required | Default | Description |
|---------|----------|---------|-------------|
| `KANDEV_SERVER_PORT` | No | `38429` | Server port (API + WebSocket + Web UI) |
| `KANDEV_HOME_DIR` | No | `/data` | Kandev home directory - contains `data/` (DB), `tasks/`, `worktrees/`, `repos/`, `sessions/`, and `lsp-servers/` |
| `KANDEV_DATABASE_DRIVER` | No | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | No | `$KANDEV_HOME_DIR/data/kandev.db` | SQLite database file path (override) |
| `KANDEV_DOCKER_ENABLED` | No | `false` | Enable Docker runtime for agents (requires DinD) |
| `KANDEV_LOG_LEVEL` | No | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `KANDEV_LOGGING_FORMAT` | No | auto | Log format: `json` (auto-detected in K8s) or `text` |
| `KANDEV_LOGGING_OUTPUTPATH` | No | `stdout` | Log destination: `stdout`, `stderr`, or a file path (rotated when a file) |
| `KANDEV_LOGGING_MAXSIZEMB` | No | `100` | Rotate the log file when it exceeds this size (MB). File output only. |
| `KANDEV_LOGGING_MAXBACKUPS` | No | `5` | Max rotated files to retain (`0` = unlimited). File output only. |
| `KANDEV_LOGGING_MAXAGEDAYS` | No | `30` | Max age of rotated files in days (`0` = unlimited). File output only. |
| `KANDEV_LOGGING_COMPRESS` | No | `true` | Gzip rotated files. File output only. |
> **Logging in K8s:** prefer the default `stdout` so kubelet collects logs. If you set `KANDEV_LOGGING_OUTPUTPATH` to a file, the active log is created with mode `0600` (owner read/write only); any sidecar reading it must run as the same user.
> **Upgrading from a pre-`KANDEV_HOME_DIR` deployment?** The SQLite DB path moved from `/data/kandev.db` to `/data/data/kandev.db`, and `KANDEV_DATA_DIR` is gone — point `KANDEV_HOME_DIR` at the same volume mount (`/data`) instead. (`KANDEV_WORKTREE_BASEPATH` still works as an explicit override if you want to keep worktrees outside the home dir.) The backend auto-migrates the legacy `kandev.db` (plus any `-wal`/`-shm` files) on first boot — look for `Migrated SQLite database from pre-KANDEV_HOME_DIR location` in the pod logs. If you'd rather pin the old path, set `KANDEV_DATABASE_PATH=/data/kandev.db` in the ConfigMap.
### PostgreSQL Settings (when `KANDEV_DATABASE_DRIVER=postgres`)
| Env Var | Default | Description |
|---------|---------|-------------|
| `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 |
| Env Var | Required | Default | Description |
|---------|----------|---------|-------------|
| `KANDEV_DATABASE_HOST` | No | `localhost` | PostgreSQL host |
| `KANDEV_DATABASE_PORT` | No | `5432` | PostgreSQL port |
| `KANDEV_DATABASE_USER` | Yes | `kandev` | Database user |
| `KANDEV_DATABASE_PASSWORD` | Usually | (empty) | Database password - required unless your Postgres allows passwordless auth |
| `KANDEV_DATABASE_DBNAME` | Yes | `kandev` | Database name |
| `KANDEV_DATABASE_SSLMODE` | No | `disable` | SSL mode (`disable`, `require`, `verify-ca`, `verify-full`) |
## Database: SQLite vs PostgreSQL