fix(cli): isolate dev mode state when running inside a kandev task (#617)

This commit is contained in:
Carlos Florêncio
2026-04-17 19:41:29 +01:00
committed by GitHub
parent cb8abfed78
commit 814efdfa67
17 changed files with 395 additions and 95 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ Start the backend:
```bash
E2E_TMP=$(mktemp -d) && mkdir -p "$E2E_TMP/.kandev" && \
printf '[user]\n name = E2E Test\n email = e2e@test.local\n[commit]\n gpgsign = false\n' > "$E2E_TMP/.gitconfig" && \
HOME="$E2E_TMP" KANDEV_DATA_DIR="$E2E_TMP/.kandev" KANDEV_SERVER_PORT=$BACKEND_PORT \
HOME="$E2E_TMP" KANDEV_HOME_DIR="$E2E_TMP/.kandev" KANDEV_SERVER_PORT=$BACKEND_PORT \
KANDEV_DATABASE_PATH="$E2E_TMP/kandev.db" KANDEV_MOCK_AGENT=only \
KANDEV_MOCK_GITHUB=true KANDEV_DOCKER_ENABLED=false KANDEV_WORKTREE_ENABLED=false \
KANDEV_LOG_LEVEL=warn apps/backend/bin/kandev &
+5
View File
@@ -49,6 +49,11 @@ temp/
# Agent session files (local development)
.augment/
# Isolated kandev home used by `make dev` (see apps/cli/src/dev.ts).
# Keeps dev backend state (db, tasks, worktrees, repos) out of the user's
# production ~/.kandev when developing kandev using kandev.
/.kandev-dev/
# Build artifacts
dist/
build/
+1 -1
View File
@@ -100,7 +100,7 @@ VOLUME ["/data"]
# Environment defaults for containerized operation
ENV KANDEV_NO_BROWSER=1 \
KANDEV_DATA_DIR=/data \
KANDEV_HOME_DIR=/data \
KANDEV_DOCKER_ENABLED=false \
HOSTNAME=0.0.0.0 \
NODE_ENV=production
+39 -31
View File
@@ -13,9 +13,19 @@ import (
"github.com/spf13/viper"
)
// kandevHomeSubdir is the hidden directory name used for the Kandev root
// under the user's home directory (e.g. ~/.kandev). This is the single
// source of truth for the dotdir name; derived paths go through
// ResolvedHomeDir / ResolvedDataDir rather than re-constructing it.
const kandevHomeSubdir = ".kandev"
// Config holds all configuration sections for Kandev.
type Config struct {
DataDir string `mapstructure:"dataDir"`
// HomeDir is the root Kandev directory (e.g. ~/.kandev in prod, or
// <repo>/.kandev-dev during local development). When empty, falls back
// to ~/.kandev. All workspace artifacts (data, tasks, worktrees, repos)
// live under this root.
HomeDir string `mapstructure:"homeDir"`
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
NATS NATSConfig `mapstructure:"nats"`
@@ -30,43 +40,41 @@ type Config struct {
Debug DebugConfig `mapstructure:"debug"`
}
// ResolvedHomeDir returns the Kandev home directory (~/.kandev for local dev).
// When KANDEV_DATA_DIR is explicitly set (e.g., /data in Docker), it returns that value
// so containers keep a flat layout. When unset, returns ~/.kandev — the parent of the data dir.
// Use this for workspace artifacts: worktrees, repos, sessions, quick-chat, lsp-servers.
func (c *Config) ResolvedHomeDir() string {
if c.DataDir != "" {
if strings.HasPrefix(c.DataDir, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, c.DataDir[2:])
}
}
return c.DataDir
// expandTilde expands a leading "~/" to the user's home directory.
// Returns the input unchanged if expansion is unnecessary or fails.
func expandTilde(p string) string {
if !strings.HasPrefix(p, "~/") {
return p
}
home, err := os.UserHomeDir()
if err != nil {
return ".kandev"
return p
}
return filepath.Join(home, ".kandev")
return filepath.Join(home, p[2:])
}
// ResolvedDataDir returns the base data directory for Kandev.
// Config.DataDir is populated by Viper from the config file or KANDEV_DATA_DIR env var.
// If empty, falls back to ~/.kandev/data. Use this for the database only.
func (c *Config) ResolvedDataDir() string {
if c.DataDir != "" {
if strings.HasPrefix(c.DataDir, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, c.DataDir[2:])
}
}
return c.DataDir
// ResolvedHomeDir returns the Kandev root directory — the parent of data,
// tasks, worktrees, repos, sessions, quick-chat and lsp-servers.
//
// Resolution order:
// 1. KANDEV_HOME_DIR (explicit override — e.g. /data in Docker, /kandev in K8s,
// or <repo>/.kandev-dev during local development).
// 2. ~/.kandev (default).
func (c *Config) ResolvedHomeDir() string {
if c.HomeDir != "" {
return expandTilde(c.HomeDir)
}
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(".kandev", "data")
return kandevHomeSubdir
}
return filepath.Join(home, ".kandev", "data")
return filepath.Join(home, kandevHomeSubdir)
}
// ResolvedDataDir returns the base data directory (where the SQLite DB lives).
// Always <ResolvedHomeDir>/data — relocate via KANDEV_HOME_DIR, not a separate knob.
func (c *Config) ResolvedDataDir() string {
return filepath.Join(c.ResolvedHomeDir(), "data")
}
// ServerConfig holds HTTP server configuration.
@@ -228,8 +236,8 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("server.writeTimeout", 30)
v.SetDefault("server.webInternalUrl", "")
// DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev/data
v.SetDefault("dataDir", "")
// HomeDir default — empty means resolve from KANDEV_HOME_DIR env or ~/.kandev
v.SetDefault("homeDir", "")
// Database defaults
v.SetDefault("database.driver", "sqlite")
@@ -346,7 +354,7 @@ func LoadWithPath(configPath string) (*Config, error) {
_ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT")
_ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL")
_ = v.BindEnv("server.webInternalUrl", "KANDEV_WEB_INTERNAL_URL")
_ = v.BindEnv("dataDir", "KANDEV_DATA_DIR")
_ = v.BindEnv("homeDir", "KANDEV_HOME_DIR")
_ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL")
_ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE")
_ = v.BindEnv("debug.pprofEnabled", "KANDEV_DEBUG_PPROF_ENABLED")
@@ -6,7 +6,7 @@ import (
"testing"
)
func TestResolvedHomeDir_NoDataDir(t *testing.T) {
func TestResolvedHomeDir_Default(t *testing.T) {
cfg := &Config{}
home, err := os.UserHomeDir()
if err != nil {
@@ -18,10 +18,10 @@ func TestResolvedHomeDir_NoDataDir(t *testing.T) {
}
}
func TestResolvedHomeDir_WithDataDir(t *testing.T) {
cfg := &Config{DataDir: "/data"}
if got := cfg.ResolvedHomeDir(); got != "/data" {
t.Errorf("ResolvedHomeDir() = %q, want %q", got, "/data")
func TestResolvedHomeDir_WithHomeDir(t *testing.T) {
cfg := &Config{HomeDir: "/custom/kandev"}
if got := cfg.ResolvedHomeDir(); got != "/custom/kandev" {
t.Errorf("ResolvedHomeDir() = %q, want %q", got, "/custom/kandev")
}
}
@@ -30,21 +30,14 @@ func TestResolvedHomeDir_TildeExpansion(t *testing.T) {
if err != nil {
t.Skip("cannot determine home directory")
}
cfg := &Config{DataDir: "~/mykandev"}
want := filepath.Join(home, "mykandev")
cfg := &Config{HomeDir: "~/.kandev-dev"}
want := filepath.Join(home, ".kandev-dev")
if got := cfg.ResolvedHomeDir(); got != want {
t.Errorf("ResolvedHomeDir() = %q, want %q", got, want)
}
}
func TestResolvedDataDir_ExplicitConfig(t *testing.T) {
cfg := &Config{DataDir: "/custom/data"}
if got := cfg.ResolvedDataDir(); got != "/custom/data" {
t.Errorf("ResolvedDataDir() = %q, want %q", got, "/custom/data")
}
}
func TestResolvedDataDir_FallbackToHome(t *testing.T) {
func TestResolvedDataDir_Default(t *testing.T) {
cfg := &Config{}
home, err := os.UserHomeDir()
if err != nil {
@@ -56,22 +49,10 @@ func TestResolvedDataDir_FallbackToHome(t *testing.T) {
}
}
func TestResolvedDataDir_ConfigTakesPrecedence(t *testing.T) {
t.Setenv("KANDEV_DATA_DIR", "/env/data")
// Even with env set, explicit Config.DataDir wins
cfg := &Config{DataDir: "/explicit/data"}
if got := cfg.ResolvedDataDir(); got != "/explicit/data" {
t.Errorf("ResolvedDataDir() = %q, want %q", got, "/explicit/data")
}
}
func TestResolvedDataDir_TildeExpansion(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home directory")
}
cfg := &Config{DataDir: "~/kandev"}
want := filepath.Join(home, "kandev")
func TestResolvedDataDir_DerivedFromHomeDir(t *testing.T) {
// Data always lives under <HomeDir>/data. No independent override.
cfg := &Config{HomeDir: "/custom/kandev"}
want := filepath.Join("/custom/kandev", "data")
if got := cfg.ResolvedDataDir(); got != want {
t.Errorf("ResolvedDataDir() = %q, want %q", got, want)
}
@@ -1,7 +1,10 @@
package persistence
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/jmoiron/sqlx"
@@ -36,6 +39,9 @@ func provideSQLite(cfg *config.Config, log *logger.Logger) (*db.Pool, func() err
dbPath := cfg.Database.Path
if dbPath == "" {
dbPath = filepath.Join(cfg.ResolvedDataDir(), "kandev.db")
if err := migrateLegacyDBPath(cfg, dbPath, log); err != nil {
return nil, nil, fmt.Errorf("migrate legacy DB: %w", err)
}
}
// Writer: single connection, owns WAL/journal_mode setup.
@@ -71,6 +77,50 @@ func provideSQLite(cfg *config.Config, log *logger.Logger) (*db.Pool, func() err
return pool, cleanup, nil
}
// migrateLegacyDBPath moves a pre-KANDEV_HOME_DIR SQLite DB from
// <HomeDir>/kandev.db into the new derived location at <HomeDir>/data/kandev.db
// on first boot after an upgrade, so `docker pull && docker restart` doesn't
// silently start against an empty DB.
//
// Runs only when:
// - KANDEV_DATABASE_PATH is not explicitly set (caller checks this).
// - The new derived path does not exist yet.
// - A legacy file exists at <HomeDir>/kandev.db.
// - The legacy path differs from the new path (skip when HomeDir == DataDir).
//
// Only the main .db file is moved — SQLite recreates -wal/-shm on open, and a
// cleanly-shut-down DB (the expected state on container restart) has empty WAL.
func migrateLegacyDBPath(cfg *config.Config, newPath string, log *logger.Logger) error {
if _, err := os.Stat(newPath); !errors.Is(err, fs.ErrNotExist) {
return nil // new path exists (or stat failed) — nothing to migrate
}
legacyPath := filepath.Join(cfg.ResolvedHomeDir(), "kandev.db")
if legacyPath == newPath {
return nil
}
if _, err := os.Stat(legacyPath); err != nil {
return nil // no legacy file to migrate
}
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
return fmt.Errorf("create data dir %s: %w", filepath.Dir(newPath), err)
}
if err := os.Rename(legacyPath, newPath); err != nil {
return fmt.Errorf("move %s -> %s: %w", legacyPath, newPath, err)
}
// Also move -wal / -shm if present. These are transient (SQLite recreates
// them on open) but moving them avoids orphaned files on the volume.
for _, suffix := range []string{"-wal", "-shm"} {
_ = os.Rename(legacyPath+suffix, newPath+suffix)
}
if log != nil {
log.Info("Migrated SQLite database from pre-KANDEV_HOME_DIR location",
zap.String("legacy_path", legacyPath),
zap.String("new_path", newPath),
)
}
return nil
}
func providePostgres(cfg *config.Config, log *logger.Logger) (*db.Pool, func() error, error) {
dsn := cfg.Database.DSN()
dbConn, err := db.OpenPostgres(dsn, cfg.Database.MaxConns, cfg.Database.MinConns)
@@ -0,0 +1,130 @@
package persistence
import (
"os"
"path/filepath"
"testing"
"github.com/kandev/kandev/internal/common/config"
)
func TestMigrateLegacyDBPath_MovesLegacyDB(t *testing.T) {
homeDir := t.TempDir()
legacyPath := filepath.Join(homeDir, "kandev.db")
newPath := filepath.Join(homeDir, "data", "kandev.db")
if err := os.WriteFile(legacyPath, []byte("legacy-db-content"), 0o600); err != nil {
t.Fatalf("seed legacy db: %v", err)
}
cfg := &config.Config{HomeDir: homeDir}
if err := migrateLegacyDBPath(cfg, newPath, nil); err != nil {
t.Fatalf("migrateLegacyDBPath: %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Errorf("legacy file still exists: %v", err)
}
data, err := os.ReadFile(newPath)
if err != nil {
t.Fatalf("read migrated db: %v", err)
}
if string(data) != "legacy-db-content" {
t.Errorf("migrated content = %q, want %q", data, "legacy-db-content")
}
}
func TestMigrateLegacyDBPath_MovesWalAndShm(t *testing.T) {
homeDir := t.TempDir()
legacyPath := filepath.Join(homeDir, "kandev.db")
newPath := filepath.Join(homeDir, "data", "kandev.db")
for _, suffix := range []string{"", "-wal", "-shm"} {
if err := os.WriteFile(legacyPath+suffix, []byte("x"+suffix), 0o600); err != nil {
t.Fatalf("seed %s: %v", legacyPath+suffix, err)
}
}
cfg := &config.Config{HomeDir: homeDir}
if err := migrateLegacyDBPath(cfg, newPath, nil); err != nil {
t.Fatalf("migrateLegacyDBPath: %v", err)
}
for _, suffix := range []string{"", "-wal", "-shm"} {
if _, err := os.Stat(newPath + suffix); err != nil {
t.Errorf("missing new file %s: %v", newPath+suffix, err)
}
if _, err := os.Stat(legacyPath + suffix); !os.IsNotExist(err) {
t.Errorf("legacy file %s still exists", legacyPath+suffix)
}
}
}
func TestMigrateLegacyDBPath_NoLegacyFile(t *testing.T) {
homeDir := t.TempDir()
newPath := filepath.Join(homeDir, "data", "kandev.db")
cfg := &config.Config{HomeDir: homeDir}
if err := migrateLegacyDBPath(cfg, newPath, nil); err != nil {
t.Fatalf("migrateLegacyDBPath: %v", err)
}
// Should be a no-op; the data dir should NOT be created if nothing to migrate.
if _, err := os.Stat(filepath.Dir(newPath)); !os.IsNotExist(err) {
t.Errorf("data dir was created unexpectedly")
}
}
func TestMigrateLegacyDBPath_NewPathExists_SkipsMigration(t *testing.T) {
homeDir := t.TempDir()
legacyPath := filepath.Join(homeDir, "kandev.db")
newPath := filepath.Join(homeDir, "data", "kandev.db")
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(legacyPath, []byte("legacy"), 0o600); err != nil {
t.Fatalf("seed legacy: %v", err)
}
if err := os.WriteFile(newPath, []byte("new"), 0o600); err != nil {
t.Fatalf("seed new: %v", err)
}
cfg := &config.Config{HomeDir: homeDir}
if err := migrateLegacyDBPath(cfg, newPath, nil); err != nil {
t.Fatalf("migrateLegacyDBPath: %v", err)
}
// New path untouched.
got, err := os.ReadFile(newPath)
if err != nil {
t.Fatalf("read new: %v", err)
}
if string(got) != "new" {
t.Errorf("new path was overwritten: got %q", got)
}
// Legacy untouched.
if _, err := os.Stat(legacyPath); err != nil {
t.Errorf("legacy file removed despite new existing: %v", err)
}
}
func TestMigrateLegacyDBPath_LegacyEqualsNew_NoOp(t *testing.T) {
// If HomeDir somehow resolves so that legacy == new (shouldn't happen with
// the current resolver, but guard against it), we must not rename a file
// onto itself.
tmp := t.TempDir()
sameFile := filepath.Join(tmp, "kandev.db")
if err := os.WriteFile(sameFile, []byte("x"), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
cfg := &config.Config{HomeDir: tmp}
// Pretend the derived path is the same as the legacy path.
if err := migrateLegacyDBPath(cfg, sameFile, nil); err != nil {
t.Fatalf("migrateLegacyDBPath: %v", err)
}
if _, err := os.Stat(sameFile); err != nil {
t.Errorf("file disappeared: %v", err)
}
}
+17 -2
View File
@@ -16,6 +16,21 @@ export const RANDOM_PORT_RETRIES = 10;
export const HEALTH_TIMEOUT_MS_RELEASE = 45000;
export const HEALTH_TIMEOUT_MS_DEV = 600000;
// Kandev root directory. Single source of truth for the dotdir name and
// everything derived from it (data, tasks, bin). Dev mode uses a separate
// root under the repo (see DEV_KANDEV_DOTDIR).
export const KANDEV_DOTDIR = ".kandev";
export const KANDEV_HOME_DIR = path.join(os.homedir(), KANDEV_DOTDIR);
export const KANDEV_TASKS_DIR = path.join(KANDEV_HOME_DIR, "tasks");
// Local user cache/data directories for release bundles and DB.
export const CACHE_DIR = path.join(os.homedir(), ".kandev", "bin");
export const DATA_DIR = path.join(os.homedir(), ".kandev", "data");
export const CACHE_DIR = path.join(KANDEV_HOME_DIR, "bin");
export const DATA_DIR = path.join(KANDEV_HOME_DIR, "data");
// Dev-mode root: an isolated kandev home inside the repo so that running
// `make dev` from inside a kandev-spawned task workspace does not touch the
// user's production state.
export const DEV_KANDEV_DOTDIR = ".kandev-dev";
export function devKandevHome(repoRoot: string): string {
return path.join(repoRoot, DEV_KANDEV_DOTDIR);
}
+44 -9
View File
@@ -1,8 +1,9 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { HEALTH_TIMEOUT_MS_DEV } from "./constants";
import { devKandevHome, HEALTH_TIMEOUT_MS_DEV } from "./constants";
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
import { isInsideKandevTask } from "./kandev-env";
import { createProcessSupervisor } from "./process";
import {
attachBackendExitHandler,
@@ -21,14 +22,7 @@ export type DevOptions = {
export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Promise<void> {
const ports = await pickPorts(backendPort, webPort);
const dbPath =
process.env.KANDEV_DATABASE_PATH || path.join(repoRoot, "apps", "backend", "kandev.db");
const extra: Record<string, string> = {
KANDEV_DATABASE_PATH: dbPath,
KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true",
KANDEV_DEBUG_PPROF_ENABLED: "true",
};
const { dbPath, extra } = resolveDevBackendEnv(repoRoot);
const backendEnv = buildBackendEnv({ ports, extra });
const webEnv = buildWebEnv({ ports, debug: true });
const logLevel =
@@ -72,3 +66,44 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
console.log(`[kandev] web ready at ${webUrl}`);
openBrowser(webUrl);
}
type DevBackendEnv = {
dbPath: string;
extra: Record<string, string>;
};
// Computes the dev-mode backend env. When invoked from inside a kandev task
// workspace, force-relocates the entire kandev root to <repo>/.kandev-dev so
// dev state is isolated from the user's production ~/.kandev. In a normal
// shell, preserves the existing override-friendly behavior (honor an
// explicit KANDEV_DATABASE_PATH, otherwise default to a repo-local db).
function resolveDevBackendEnv(repoRoot: string): DevBackendEnv {
const baseExtra: Record<string, string> = {
KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true",
KANDEV_DEBUG_PPROF_ENABLED: "true",
};
if (isInsideKandevTask(repoRoot)) {
const devHome = devKandevHome(repoRoot);
// Display only; the backend derives its own DB path from KANDEV_HOME_DIR
// via ResolvedDataDir(). Both resolve to the same location.
const dbPath = path.join(devHome, "data", "kandev.db");
console.log("[kandev] task workspace detected → using local dev state");
return {
dbPath,
extra: {
...baseExtra,
KANDEV_HOME_DIR: devHome,
// Clear a parent-leaked DB path so the backend uses the HomeDir-derived default.
KANDEV_DATABASE_PATH: "",
},
};
}
const dbPath =
process.env.KANDEV_DATABASE_PATH || path.join(repoRoot, "apps", "backend", "kandev.db");
return {
dbPath,
extra: { ...baseExtra, KANDEV_DATABASE_PATH: dbPath },
};
}
+48
View File
@@ -0,0 +1,48 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { KANDEV_TASKS_DIR } from "./constants";
import { isInsideKandevTask } from "./kandev-env";
describe("isInsideKandevTask", () => {
const originalTaskId = process.env.KANDEV_TASK_ID;
beforeEach(() => {
delete process.env.KANDEV_TASK_ID;
});
afterEach(() => {
if (originalTaskId === undefined) {
delete process.env.KANDEV_TASK_ID;
} else {
process.env.KANDEV_TASK_ID = originalTaskId;
}
});
it("returns true when KANDEV_TASK_ID is set regardless of repo path", () => {
process.env.KANDEV_TASK_ID = "some-id";
expect(isInsideKandevTask("/any/path")).toBe(true);
});
it("returns true for a repoRoot under the kandev tasks dir", () => {
const repoRoot = path.join(KANDEV_TASKS_DIR, "task-123", "kandev");
expect(isInsideKandevTask(repoRoot)).toBe(true);
});
it("returns false for a repoRoot outside the kandev tasks dir", () => {
expect(isInsideKandevTask("/home/user/projects/kandev")).toBe(false);
});
it("does not match a sibling directory that shares the 'tasks' prefix", () => {
// Boundary: ~/.kandev/tasks-extra/... must NOT be treated as a task workspace.
// The `path.sep` suffix on KANDEV_TASKS_DIR is the only guard against this.
const adjacent = path.join(os.homedir(), ".kandev", "tasks-extra", "proj");
expect(isInsideKandevTask(adjacent)).toBe(false);
});
it("does not match the tasks dir itself without a subdirectory", () => {
expect(isInsideKandevTask(KANDEV_TASKS_DIR)).toBe(false);
});
});
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
import { KANDEV_TASKS_DIR } from "./constants";
/**
* Returns true when the current process looks like it was spawned inside a
* kandev-created task workspace. Two signals:
* 1. The parent kandev backend exports KANDEV_TASK_ID into every task shell.
* 2. Task worktrees live under ~/.kandev/tasks/ (see KANDEV_TASKS_DIR).
*
* Used by dev mode to auto-isolate the backend onto a local dev root so that
* `make dev` never mutates the user's production state.
*
* Note: the path-prefix fallback is a defensive secondary signal for nested
* shells where KANDEV_TASK_ID was stripped. It is case-sensitive and does not
* resolve symlinks, so a realpath'd repoRoot may miss a symlinked HOME on
* macOS / Windows. KANDEV_TASK_ID remains the primary guarantee.
*/
export function isInsideKandevTask(repoRoot: string): boolean {
if (process.env.KANDEV_TASK_ID) {
return true;
}
return repoRoot.startsWith(KANDEV_TASKS_DIR + path.sep);
}
+3 -3
View File
@@ -167,12 +167,12 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), `kandev-e2e-${workerInfo.workerIndex}-`),
);
const dataDir = path.join(tmpDir, ".kandev");
const homeDir = path.join(tmpDir, ".kandev");
const dbPath = path.join(tmpDir, "kandev.db");
const worktreeBase = path.join(tmpDir, "worktrees");
const repoCloneBase = path.join(tmpDir, "repos");
fs.mkdirSync(dataDir, { recursive: true });
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(worktreeBase, { recursive: true });
fs.mkdirSync(repoCloneBase, { recursive: true });
@@ -231,7 +231,7 @@ exec git "$@"
KANDEV_E2E_ORIGINAL_PATH: originalPath,
KANDEV_E2E_GIT_DELAY_FILE: shimDelayFile,
HOME: tmpDir,
KANDEV_DATA_DIR: dataDir,
KANDEV_HOME_DIR: homeDir,
KANDEV_SERVER_PORT: String(backendPort),
KANDEV_DATABASE_PATH: dbPath,
KANDEV_MOCK_AGENT: "only",
+4 -4
View File
@@ -10,17 +10,17 @@ SQLite requires no external services and works out of the box.
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_DATA_DIR` | `~/.kandev/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `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_DATA_DIR/kandev.db` | Path to the SQLite database file (override) |
| `KANDEV_DATABASE_PATH` | `$KANDEV_HOME_DIR/data/kandev.db` | Path to the SQLite database file (override) |
### Config file (`config.yaml`)
```yaml
dataDir: "" # empty = resolve from KANDEV_DATA_DIR or ~/.kandev/data
homeDir: "" # empty = resolve from KANDEV_HOME_DIR or ~/.kandev
database:
driver: sqlite
path: "" # empty = $dataDir/kandev.db
path: "" # empty = $homeDir/data/kandev.db
```
No additional setup is needed - the database file is created automatically on first run.
+4 -2
View File
@@ -63,13 +63,15 @@ docker run -p 8080:8080 \
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_DATA_DIR` | `/data` | Kandev home directory — contains DB, worktrees, sessions, repos, and LSP servers |
| `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_DATA_DIR/kandev.db` | SQLite database file path (override) |
| `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) |
> **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`.
### PostgreSQL
To use PostgreSQL instead of SQLite:
+7 -5
View File
@@ -85,13 +85,15 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper).
| Env Var | Default | Description |
|---------|---------|-------------|
| `KANDEV_SERVER_PORT` | `8080` | Server port (API + WebSocket + Web UI) |
| `KANDEV_DATA_DIR` | `/data` | Kandev home directory — contains DB, worktrees, sessions, repos, and LSP servers |
| `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_DATA_DIR/kandev.db` | SQLite database file path (override) |
| `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` |
> **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 |
@@ -108,7 +110,7 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper).
### SQLite (default)
- Zero-config, works out of the box
- Database stored at `/data/kandev.db` on the PV
- Database stored at `/data/data/kandev.db` on the PV (derived from `KANDEV_HOME_DIR=/data`)
- **Single replica only** (SQLite is single-writer)
- Deployment strategy is `Recreate` to prevent concurrent writes
- Good for small teams / personal use
@@ -135,8 +137,8 @@ When using Postgres, the PVC is still needed for worktree storage but the databa
The PVC at `/data` stores:
- **SQLite database** (`/data/kandev.db`, `/data/kandev.db-wal`, `/data/kandev.db-shm`)
- **Git worktrees** (`/data/worktrees/`) for workspace isolation
- **SQLite database** (`/data/data/kandev.db`, `/data/data/kandev.db-wal`, `/data/data/kandev.db-shm`)
- **Git worktrees** (`/data/worktrees/`), **tasks** (`/data/tasks/`), **repos** (`/data/repos/`), **sessions** (`/data/sessions/`), and **LSP servers** (`/data/lsp-servers/`)
The PVC uses `ReadWriteOnce` access mode. If your cluster requires a specific StorageClass, add it to `k8s/pvc.yaml`:
+1 -1
View File
@@ -98,7 +98,7 @@ Each Playwright worker spawns an isolated `kandev` backend and a dedicated `next
**Backend:**
1. Creates a temp directory as `HOME` with its own `.gitconfig`, data dir, and SQLite DB
2. Sets environment variables for isolation:
- `KANDEV_DATA_DIR` — temp data directory
- `KANDEV_HOME_DIR` — temp data directory
- `KANDEV_SERVER_PORT` — unique port per worker (`18080 + workerIndex`)
- `KANDEV_DATABASE_PATH` — temp SQLite path
- `KANDEV_MOCK_AGENT=only` — only loads mock agent, skips agent discovery (use `"true"` in dev mode to enable mock alongside all agents)
+5 -5
View File
@@ -5,14 +5,14 @@ metadata:
labels:
app: kandev
data:
# Database — SQLite on the PV. Set KANDEV_DATABASE_DRIVER=postgres for production.
KANDEV_DATABASE_PATH: /data/kandev.db
# Kandev home directory on the PV. Everything kandev persists — the DB
# (under data/), tasks, worktrees, repos, sessions, and LSP servers — lives
# under this root. Mount the PV at /data. Set KANDEV_DATABASE_DRIVER=postgres
# for production to move the DB off the PV.
KANDEV_HOME_DIR: /data
# Disable Docker runtime (no Docker-in-Docker in K8s)
KANDEV_DOCKER_ENABLED: "false"
# Worktree storage on the PV
KANDEV_WORKTREE_BASEPATH: /data/worktrees
# Logging — auto-detects K8s and uses JSON format
KANDEV_LOG_LEVEL: info