diff --git a/.agents/skills/e2e/SKILL.md b/.agents/skills/e2e/SKILL.md index 4988aac76d..89e1e7d0a2 100644 --- a/.agents/skills/e2e/SKILL.md +++ b/.agents/skills/e2e/SKILL.md @@ -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 & diff --git a/.gitignore b/.gitignore index ebde992967..1d6101a01f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile index 6a34858efe..a7cb98b7d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index fbcb1d61d2..a86af0bae4 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -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 + // /.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 /.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 /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") diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go index 64159971d4..7538606f76 100644 --- a/apps/backend/internal/common/config/config_test.go +++ b/apps/backend/internal/common/config/config_test.go @@ -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 /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) } diff --git a/apps/backend/internal/persistence/provider.go b/apps/backend/internal/persistence/provider.go index 2d5d7c9804..a1e3565d07 100644 --- a/apps/backend/internal/persistence/provider.go +++ b/apps/backend/internal/persistence/provider.go @@ -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 +// /kandev.db into the new derived location at /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 /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) diff --git a/apps/backend/internal/persistence/provider_test.go b/apps/backend/internal/persistence/provider_test.go new file mode 100644 index 0000000000..b04a74901b --- /dev/null +++ b/apps/backend/internal/persistence/provider_test.go @@ -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) + } +} diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index b4e95e4729..5300e5052f 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -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); +} diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts index 95226368a7..6f4fb3bf0f 100644 --- a/apps/cli/src/dev.ts +++ b/apps/cli/src/dev.ts @@ -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 { const ports = await pickPorts(backendPort, webPort); - const dbPath = - process.env.KANDEV_DATABASE_PATH || path.join(repoRoot, "apps", "backend", "kandev.db"); - - const extra: Record = { - 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; +}; + +// Computes the dev-mode backend env. When invoked from inside a kandev task +// workspace, force-relocates the entire kandev root to /.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 = { + 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 }, + }; +} diff --git a/apps/cli/src/kandev-env.test.ts b/apps/cli/src/kandev-env.test.ts new file mode 100644 index 0000000000..ca4f369619 --- /dev/null +++ b/apps/cli/src/kandev-env.test.ts @@ -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); + }); +}); diff --git a/apps/cli/src/kandev-env.ts b/apps/cli/src/kandev-env.ts new file mode 100644 index 0000000000..e948eb8664 --- /dev/null +++ b/apps/cli/src/kandev-env.ts @@ -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); +} diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts index 6eba0558df..b2681e8d29 100644 --- a/apps/web/e2e/fixtures/backend.ts +++ b/apps/web/e2e/fixtures/backend.ts @@ -167,12 +167,12 @@ export const backendFixture = base.extend({ 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", diff --git a/docs/db.md b/docs/db.md index e09dd051dc..f8b18b1863 100644 --- a/docs/db.md +++ b/docs/db.md @@ -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. diff --git a/docs/docker.md b/docs/docker.md index ca49691300..a40142c659 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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: diff --git a/docs/k8s.md b/docs/k8s.md index b2017b6530..b3d99d6150 100644 --- a/docs/k8s.md +++ b/docs/k8s.md @@ -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`: diff --git a/docs/test_e2e_web.md b/docs/test_e2e_web.md index 378ae40d27..dd1806a08b 100644 --- a/docs/test_e2e_web.md +++ b/docs/test_e2e_web.md @@ -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) diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index 6675330193..21965aa7e9 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -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