650 lines
23 KiB
Go
650 lines
23 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
func newRepoForHealTests(t *testing.T) *Repository {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
dbConn, err := db.OpenSQLite(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
|
|
repo, err := NewWithDB(sqlxDB, sqlxDB, nil)
|
|
if err != nil {
|
|
t.Fatalf("new repo: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = sqlxDB.Close()
|
|
})
|
|
return repo
|
|
}
|
|
|
|
// insertEnv writes a minimal task_environments row directly. Bypasses
|
|
// CreateTaskEnvironment so tests can construct rows that violate invariants
|
|
// (the very rows the heal step is supposed to repair).
|
|
func insertEnv(t *testing.T, db *sqlx.DB, env *models.TaskEnvironment) {
|
|
t.Helper()
|
|
if env.CreatedAt.IsZero() {
|
|
env.CreatedAt = time.Now().UTC()
|
|
}
|
|
if env.UpdatedAt.IsZero() {
|
|
env.UpdatedAt = env.CreatedAt
|
|
}
|
|
if env.Status == "" {
|
|
env.Status = models.TaskEnvironmentStatusReady
|
|
}
|
|
_, err := db.Exec(`
|
|
INSERT INTO task_environments (
|
|
id, task_id, repository_id, executor_type, executor_id, executor_profile_id,
|
|
control_port, status,
|
|
worktree_id, worktree_path, worktree_branch, workspace_path,
|
|
container_id, sandbox_id,
|
|
created_at, updated_at
|
|
) VALUES (?, ?, '', ?, '', '', 0, ?, '', ?, '', ?, '', '', ?, ?)
|
|
`, env.ID, env.TaskID, env.ExecutorType, string(env.Status),
|
|
env.WorktreePath, env.WorkspacePath, env.CreatedAt, env.UpdatedAt)
|
|
if err != nil {
|
|
t.Fatalf("insert env: %v", err)
|
|
}
|
|
}
|
|
|
|
func insertTask(t *testing.T, db *sqlx.DB, taskID string) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
_, err := db.Exec(`
|
|
INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, created_at, updated_at)
|
|
VALUES (?, '', '', '', 'test task', '', 'todo', ?, ?)
|
|
`, taskID, now, now)
|
|
if err != nil {
|
|
t.Fatalf("insert task: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHealTaskEnvironmentWorkspacePaths_BackfillsEmpty seeds a worktree-mode
|
|
// env with worktree_path set but workspace_path empty (the corrupt state seen
|
|
// in the live DB) and asserts the heal step backfills workspace_path from
|
|
// worktree_path.
|
|
func TestHealTaskEnvironmentWorkspacePaths_BackfillsEmpty(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-A")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-A",
|
|
TaskID: "task-A",
|
|
ExecutorType: "worktree",
|
|
WorktreePath: "/home/user/.kandev/worktrees/foo",
|
|
// WorkspacePath intentionally empty.
|
|
})
|
|
|
|
if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
got, err := repo.GetTaskEnvironment(context.Background(), "env-A")
|
|
if err != nil {
|
|
t.Fatalf("get env: %v", err)
|
|
}
|
|
if got.WorkspacePath != "/home/user/.kandev/worktrees/foo" {
|
|
t.Errorf("workspace_path = %q, want it backfilled from worktree_path", got.WorkspacePath)
|
|
}
|
|
}
|
|
|
|
// TestHealTaskEnvironmentWorkspacePaths_RepairsCollapsedParent — a row where
|
|
// workspace_path was the task-root parent of worktree_path (the pre-fix value
|
|
// left by legacy computeWorkspacePath's filepath.Dir) must be repaired:
|
|
// workspace_path becomes worktree_path so ACP session/load finds the agent's
|
|
// saved jsonl on cold start.
|
|
func TestHealTaskEnvironmentWorkspacePaths_RepairsCollapsedParent(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-B")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-B",
|
|
TaskID: "task-B",
|
|
ExecutorType: "worktree",
|
|
WorktreePath: "/home/user/.kandev/tasks/foo_abc/repo",
|
|
WorkspacePath: "/home/user/.kandev/tasks/foo_abc", // legacy collapsed parent
|
|
})
|
|
|
|
if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
got, err := repo.GetTaskEnvironment(context.Background(), "env-B")
|
|
if err != nil {
|
|
t.Fatalf("get env: %v", err)
|
|
}
|
|
if got.WorkspacePath != "/home/user/.kandev/tasks/foo_abc/repo" {
|
|
t.Errorf("workspace_path = %q, want repaired to worktree_path subdir", got.WorkspacePath)
|
|
}
|
|
}
|
|
|
|
// TestHealTaskEnvironmentWorkspacePaths_LeavesAlreadyCorrectAlone — a row that
|
|
// already has workspace_path == worktree_path must not be touched.
|
|
func TestHealTaskEnvironmentWorkspacePaths_LeavesAlreadyCorrectAlone(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-B2")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-B2",
|
|
TaskID: "task-B2",
|
|
ExecutorType: "worktree",
|
|
WorktreePath: "/home/user/.kandev/tasks/foo_abc/repo",
|
|
WorkspacePath: "/home/user/.kandev/tasks/foo_abc/repo",
|
|
})
|
|
|
|
if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
got, err := repo.GetTaskEnvironment(context.Background(), "env-B2")
|
|
if err != nil {
|
|
t.Fatalf("get env: %v", err)
|
|
}
|
|
if got.WorkspacePath != "/home/user/.kandev/tasks/foo_abc/repo" {
|
|
t.Errorf("workspace_path = %q, must not be overwritten", got.WorkspacePath)
|
|
}
|
|
}
|
|
|
|
// TestHealTaskEnvironmentWorkspacePaths_TaskDirMode — task-dir-mode envs
|
|
// place the worktree at <root>/.kandev/tasks/<name>/<repo>; workspace_path
|
|
// must equal worktree_path (the repo subdir), matching the agent process cwd
|
|
// so ACP session/load on cold start hits the same sanitised-cwd folder.
|
|
func TestHealTaskEnvironmentWorkspacePaths_TaskDirMode(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-T")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-T",
|
|
TaskID: "task-T",
|
|
ExecutorType: "worktree",
|
|
WorktreePath: "/home/u/.kandev/tasks/fix-something_abc/kandev",
|
|
})
|
|
|
|
if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
got, err := repo.GetTaskEnvironment(context.Background(), "env-T")
|
|
if err != nil {
|
|
t.Fatalf("get env: %v", err)
|
|
}
|
|
if got.WorkspacePath != "/home/u/.kandev/tasks/fix-something_abc/kandev" {
|
|
t.Errorf("workspace_path = %q, want worktree_path (subdir) — process cwd parity", got.WorkspacePath)
|
|
}
|
|
}
|
|
|
|
// TestHealTaskEnvironmentWorkspacePaths_Idempotent — running the heal twice
|
|
// must not change anything on the second run.
|
|
func TestHealTaskEnvironmentWorkspacePaths_Idempotent(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-C")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-C",
|
|
TaskID: "task-C",
|
|
ExecutorType: "worktree",
|
|
WorktreePath: "/x",
|
|
})
|
|
|
|
for i := 0; i < 2; i++ {
|
|
if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil {
|
|
t.Fatalf("heal pass %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
got, err := repo.GetTaskEnvironment(context.Background(), "env-C")
|
|
if err != nil {
|
|
t.Fatalf("get env: %v", err)
|
|
}
|
|
if got.WorkspacePath != "/x" {
|
|
t.Errorf("workspace_path = %q, want /x after idempotent run", got.WorkspacePath)
|
|
}
|
|
}
|
|
|
|
// TestHealDuplicateTaskEnvironments_KeepsMostRecent seeds two envs for the
|
|
// same task (the race the user hit) with sessions pointing at the older one,
|
|
// runs the heal, and asserts: only the newer env remains, sessions have been
|
|
// re-pointed at it.
|
|
func TestHealDuplicateTaskEnvironments_KeepsMostRecent(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
|
|
// initSchema added the unique-task_id index — it would block our
|
|
// duplicate-row seeding. Drop it for the duration of the test; the heal
|
|
// step will succeed against the duplicate-free DB it leaves behind.
|
|
if _, err := repo.db.Exec(`DROP INDEX IF EXISTS uniq_task_environments_task_id`); err != nil {
|
|
t.Fatalf("drop index: %v", err)
|
|
}
|
|
|
|
insertTask(t, repo.db, "task-D")
|
|
|
|
older := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
|
|
newer := older.Add(5 * time.Second)
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-old", TaskID: "task-D", ExecutorType: "worktree",
|
|
WorktreePath: "/old", WorkspacePath: "/old",
|
|
CreatedAt: older, UpdatedAt: older,
|
|
})
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-new", TaskID: "task-D", ExecutorType: "worktree",
|
|
WorktreePath: "/new", WorkspacePath: "/new",
|
|
CreatedAt: newer, UpdatedAt: newer,
|
|
})
|
|
// A session created against the loser env — must be re-linked.
|
|
if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{
|
|
ID: "sess-D",
|
|
TaskID: "task-D",
|
|
State: models.TaskSessionStateCreated,
|
|
TaskEnvironmentID: "env-old",
|
|
}); err != nil {
|
|
t.Fatalf("insert session: %v", err)
|
|
}
|
|
|
|
if err := repo.healDuplicateTaskEnvironments(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
var remaining int
|
|
if err := repo.db.QueryRow(`SELECT COUNT(*) FROM task_environments WHERE task_id='task-D'`).Scan(&remaining); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if remaining != 1 {
|
|
t.Errorf("expected 1 env after heal, got %d", remaining)
|
|
}
|
|
var winnerID string
|
|
if err := repo.db.QueryRow(`SELECT id FROM task_environments WHERE task_id='task-D'`).Scan(&winnerID); err != nil {
|
|
t.Fatalf("scan winner: %v", err)
|
|
}
|
|
if winnerID != "env-new" {
|
|
t.Errorf("winner = %q, want env-new (most recently updated)", winnerID)
|
|
}
|
|
var sessionEnv string
|
|
if err := repo.db.QueryRow(`SELECT task_environment_id FROM task_sessions WHERE id='sess-D'`).Scan(&sessionEnv); err != nil {
|
|
t.Fatalf("scan session env: %v", err)
|
|
}
|
|
if sessionEnv != "env-new" {
|
|
t.Errorf("session env = %q, want env-new (re-linked from loser)", sessionEnv)
|
|
}
|
|
}
|
|
|
|
// TestHealDuplicateTaskEnvironments_NoOpWhenSingle — single-env tasks must
|
|
// not be affected. Also verifies the heal handles multi-task DBs correctly.
|
|
func TestHealDuplicateTaskEnvironments_NoOpWhenSingle(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-E")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-E", TaskID: "task-E", ExecutorType: "worktree",
|
|
WorktreePath: "/e", WorkspacePath: "/e",
|
|
})
|
|
|
|
if err := repo.healDuplicateTaskEnvironments(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
got, err := repo.GetTaskEnvironment(context.Background(), "env-E")
|
|
if err != nil {
|
|
t.Fatalf("get env: %v", err)
|
|
}
|
|
if got.ID != "env-E" {
|
|
t.Errorf("env-E should still exist")
|
|
}
|
|
}
|
|
|
|
// TestEnsureTaskEnvironmentTaskUniqueIndex_BlocksFutureDuplicates asserts the
|
|
// unique index is enforced after the heal so a future regression in the
|
|
// orchestrator's create path fails loud instead of silently double-inserting.
|
|
func TestEnsureTaskEnvironmentTaskUniqueIndex_BlocksFutureDuplicates(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-F")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-F1", TaskID: "task-F", ExecutorType: "worktree",
|
|
WorktreePath: "/f", WorkspacePath: "/f",
|
|
})
|
|
|
|
// initSchema already added the unique index; a second insert for the same
|
|
// task must fail with a constraint error. (agent_execution_id was dropped
|
|
// from task_environments — the column reference is gone here too.)
|
|
_, err := repo.db.Exec(`
|
|
INSERT INTO task_environments (
|
|
id, task_id, repository_id, executor_type, executor_id, executor_profile_id,
|
|
control_port, status,
|
|
worktree_id, worktree_path, worktree_branch, workspace_path,
|
|
container_id, sandbox_id, created_at, updated_at
|
|
) VALUES (?, 'task-F', '', 'worktree', '', '', 0, 'ready',
|
|
'', '/f2', '', '/f2', '', '', datetime('now'), datetime('now'))
|
|
`, "env-F2")
|
|
if err == nil {
|
|
t.Fatal("expected unique-constraint error inserting second env for same task_id")
|
|
}
|
|
// Confirm it's specifically a UNIQUE constraint failure, not some other DB
|
|
// error masquerading as success.
|
|
if !strings.Contains(err.Error(), "UNIQUE") {
|
|
t.Fatalf("expected UNIQUE constraint error, got: %v", err)
|
|
}
|
|
// Belt-and-suspenders: make sure the second row didn't sneak in.
|
|
var n int
|
|
if err := repo.db.QueryRow(`SELECT COUNT(*) FROM task_environments WHERE task_id='task-F'`).Scan(&n); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected 1 env for task-F, got %d", n)
|
|
}
|
|
}
|
|
|
|
// TestCreateTaskEnvironment_RejectsEmptyWorkspaceForWorktree asserts that
|
|
// inserts of a worktree-mode env with empty workspace_path are refused at
|
|
// the repository boundary — a future writer regression must fail loud.
|
|
func TestCreateTaskEnvironment_RejectsEmptyWorkspaceForWorktree(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-G")
|
|
|
|
err := repo.CreateTaskEnvironment(context.Background(), &models.TaskEnvironment{
|
|
TaskID: "task-G",
|
|
ExecutorType: "worktree",
|
|
WorktreePath: "/g",
|
|
// WorkspacePath intentionally empty.
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected create to fail when workspace_path empty for worktree")
|
|
}
|
|
}
|
|
|
|
// TestCreateTaskEnvironment_AllowsNonWorktreeWithEmptyWorkspace — non-worktree
|
|
// executors (e.g. local_pc) may legitimately have no workspace_path; the
|
|
// guard must not block them.
|
|
func TestCreateTaskEnvironment_AllowsNonWorktreeWithEmptyWorkspace(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-H")
|
|
|
|
err := repo.CreateTaskEnvironment(context.Background(), &models.TaskEnvironment{
|
|
TaskID: "task-H",
|
|
ExecutorType: "local_pc",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateTaskEnvironment_AllowsSameRepoMultiBranchRows(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-H2")
|
|
|
|
err := repo.CreateTaskEnvironment(context.Background(), &models.TaskEnvironment{
|
|
ID: "env-H2",
|
|
TaskID: "task-H2",
|
|
ExecutorType: string(models.ExecutorTypeWorktree),
|
|
WorktreePath: "/workspace/main",
|
|
WorkspacePath: "/workspace",
|
|
Repos: []*models.TaskEnvironmentRepo{
|
|
{
|
|
RepositoryID: "repo-shared",
|
|
WorktreeID: "wt-main",
|
|
WorktreePath: "/workspace/repo",
|
|
WorktreeBranch: "feature/main",
|
|
Position: 0,
|
|
},
|
|
{
|
|
RepositoryID: "repo-shared",
|
|
BranchSlug: "branch-5hn",
|
|
WorktreeID: "wt-branch",
|
|
WorktreePath: "/workspace/repo/branch-5hn",
|
|
WorktreeBranch: "feature/branch",
|
|
Position: 1,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
rows, err := repo.ListTaskEnvironmentRepos(context.Background(), "env-H2")
|
|
if err != nil {
|
|
t.Fatalf("list repos: %v", err)
|
|
}
|
|
if len(rows) != 2 {
|
|
t.Fatalf("expected 2 environment repo rows, got %d", len(rows))
|
|
}
|
|
if rows[0].BranchSlug != "" || rows[1].BranchSlug != "branch-5hn" {
|
|
t.Fatalf("unexpected branch slugs: %+v", rows)
|
|
}
|
|
}
|
|
|
|
// TestUpdateTaskEnvironment_RejectsClearingWorkspaceForWorktree — symmetric
|
|
// guard: a writer must not be able to clear a previously-populated
|
|
// workspace_path on a worktree env.
|
|
func TestUpdateTaskEnvironment_RejectsClearingWorkspaceForWorktree(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-I")
|
|
if err := repo.CreateTaskEnvironment(context.Background(), &models.TaskEnvironment{
|
|
ID: "env-I", TaskID: "task-I", ExecutorType: "worktree",
|
|
WorktreePath: "/i", WorkspacePath: "/i",
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
err := repo.UpdateTaskEnvironment(context.Background(), &models.TaskEnvironment{
|
|
ID: "env-I", TaskID: "task-I", ExecutorType: "worktree",
|
|
WorktreePath: "/i", WorkspacePath: "",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected update to fail when clearing workspace_path on worktree env")
|
|
}
|
|
}
|
|
|
|
// insertSessionWithEnvID writes a task_sessions row directly. Bypasses
|
|
// CreateTaskSession so tests can seed legacy rows with empty/null
|
|
// task_environment_id (the very rows the heal step is supposed to repair).
|
|
func insertSessionWithEnvID(t *testing.T, db *sqlx.DB, sessionID, taskID, envID string) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
_, err := db.Exec(`
|
|
INSERT INTO task_sessions (
|
|
id, task_id, agent_profile_id, executor_id, executor_profile_id, environment_id,
|
|
repository_id, base_branch, base_commit_sha,
|
|
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
|
state, error_message, metadata, started_at, completed_at, updated_at,
|
|
is_primary, review_status, is_passthrough, task_environment_id
|
|
) VALUES (?, ?, '', '', '', '', '', '', '',
|
|
'{}', '{}', '{}', '{}',
|
|
'created', '', '{}', ?, NULL, ?,
|
|
0, '', 0, ?)
|
|
`, sessionID, taskID, now, now, envID)
|
|
if err != nil {
|
|
t.Fatalf("insert session: %v", err)
|
|
}
|
|
}
|
|
|
|
// insertSessionWithNullEnvID writes a task_sessions row with task_environment_id
|
|
// stored as SQL NULL (not the empty string). Some legacy paths produced NULL
|
|
// rather than ”, so the heal must cover both predicates.
|
|
func insertSessionWithNullEnvID(t *testing.T, db *sqlx.DB, sessionID, taskID string) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
_, err := db.Exec(`
|
|
INSERT INTO task_sessions (
|
|
id, task_id, agent_profile_id, executor_id, executor_profile_id, environment_id,
|
|
repository_id, base_branch, base_commit_sha,
|
|
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
|
state, error_message, metadata, started_at, completed_at, updated_at,
|
|
is_primary, review_status, is_passthrough, task_environment_id
|
|
) VALUES (?, ?, '', '', '', '', '', '', '',
|
|
'{}', '{}', '{}', '{}',
|
|
'created', '', '{}', ?, NULL, ?,
|
|
0, '', 0, NULL)
|
|
`, sessionID, taskID, now, now)
|
|
if err != nil {
|
|
t.Fatalf("insert session with null env: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHealSessionTaskEnvironmentIDs_LinksOrphans seeds a task with an existing
|
|
// env and a session whose task_environment_id is empty. Asserts the heal step
|
|
// links the session to the env. This is the exact bug that caused
|
|
// `+ → Terminal` to silently fail with "session has no task environment ID".
|
|
func TestHealSessionTaskEnvironmentIDs_LinksOrphans(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-J")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-J", TaskID: "task-J", ExecutorType: "worktree",
|
|
WorktreePath: "/j", WorkspacePath: "/j",
|
|
})
|
|
insertSessionWithEnvID(t, repo.db, "sess-J-orphan", "task-J", "")
|
|
|
|
if err := repo.healSessionTaskEnvironmentIDs(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
var got string
|
|
if err := repo.db.QueryRow(`SELECT task_environment_id FROM task_sessions WHERE id='sess-J-orphan'`).Scan(&got); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if got != "env-J" {
|
|
t.Errorf("session task_environment_id = %q, want env-J", got)
|
|
}
|
|
}
|
|
|
|
// TestHealSessionTaskEnvironmentIDs_LeavesLinkedAlone — sessions already
|
|
// pointing at an env must not be touched.
|
|
func TestHealSessionTaskEnvironmentIDs_LeavesLinkedAlone(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-K")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-K", TaskID: "task-K", ExecutorType: "worktree",
|
|
WorktreePath: "/k", WorkspacePath: "/k",
|
|
})
|
|
insertSessionWithEnvID(t, repo.db, "sess-K-linked", "task-K", "env-K")
|
|
|
|
if err := repo.healSessionTaskEnvironmentIDs(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
var got string
|
|
if err := repo.db.QueryRow(`SELECT task_environment_id FROM task_sessions WHERE id='sess-K-linked'`).Scan(&got); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if got != "env-K" {
|
|
t.Errorf("session task_environment_id = %q, want env-K untouched", got)
|
|
}
|
|
}
|
|
|
|
// TestHealSessionTaskEnvironmentIDs_NoEnvSkips — a session whose task has no
|
|
// env row at all is left alone (the backfillTaskEnvironments pass earlier in
|
|
// the boot sequence is responsible for creating one; this heal only links).
|
|
func TestHealSessionTaskEnvironmentIDs_NoEnvSkips(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-L")
|
|
insertSessionWithEnvID(t, repo.db, "sess-L-orphan", "task-L", "")
|
|
|
|
if err := repo.healSessionTaskEnvironmentIDs(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
var got string
|
|
if err := repo.db.QueryRow(`SELECT task_environment_id FROM task_sessions WHERE id='sess-L-orphan'`).Scan(&got); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if got != "" {
|
|
t.Errorf("session task_environment_id = %q, want empty (no env to link to)", got)
|
|
}
|
|
}
|
|
|
|
// TestHealSessionTaskEnvironmentIDs_LinksNullOrphans — sessions whose
|
|
// task_environment_id is SQL NULL (not the empty string) must also be
|
|
// healed. The WHERE clause covers both predicates; both must be tested.
|
|
func TestHealSessionTaskEnvironmentIDs_LinksNullOrphans(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-N")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-N", TaskID: "task-N", ExecutorType: "worktree",
|
|
WorktreePath: "/n", WorkspacePath: "/n",
|
|
})
|
|
insertSessionWithNullEnvID(t, repo.db, "sess-N-null", "task-N")
|
|
|
|
if err := repo.healSessionTaskEnvironmentIDs(); err != nil {
|
|
t.Fatalf("heal: %v", err)
|
|
}
|
|
|
|
var got string
|
|
if err := repo.db.QueryRow(`SELECT task_environment_id FROM task_sessions WHERE id='sess-N-null'`).Scan(&got); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if got != "env-N" {
|
|
t.Errorf("session task_environment_id = %q, want env-N (healed from NULL)", got)
|
|
}
|
|
}
|
|
|
|
// TestHealSessionTaskEnvironmentIDs_Idempotent — running twice produces the
|
|
// same result. The heal must be a no-op once the data is consistent.
|
|
func TestHealSessionTaskEnvironmentIDs_Idempotent(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-M")
|
|
insertEnv(t, repo.db, &models.TaskEnvironment{
|
|
ID: "env-M", TaskID: "task-M", ExecutorType: "worktree",
|
|
WorktreePath: "/m", WorkspacePath: "/m",
|
|
})
|
|
insertSessionWithEnvID(t, repo.db, "sess-M", "task-M", "")
|
|
|
|
for i := 0; i < 2; i++ {
|
|
if err := repo.healSessionTaskEnvironmentIDs(); err != nil {
|
|
t.Fatalf("heal pass %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
var got string
|
|
if err := repo.db.QueryRow(`SELECT task_environment_id FROM task_sessions WHERE id='sess-M'`).Scan(&got); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if got != "env-M" {
|
|
t.Errorf("session task_environment_id = %q, want env-M after idempotent heal", got)
|
|
}
|
|
}
|
|
|
|
// silences "imported and not used" if some future refactor drops a use.
|
|
var _ = sql.ErrNoRows
|
|
|
|
// TestBackfillSingleTask_DefaultsExecutorTypeOnMissingRow — when the
|
|
// referenced executor row is genuinely absent (legacy session whose executor
|
|
// was deleted), backfillSingleTask must default executor_type to "local_pc"
|
|
// and continue. Locks in the narrowed error-handling: only sql.ErrNoRows
|
|
// triggers the default; any other scan error must propagate so operators see
|
|
// the real cause instead of every backfilled env silently getting the wrong
|
|
// type.
|
|
func TestBackfillSingleTask_DefaultsExecutorTypeOnMissingRow(t *testing.T) {
|
|
repo := newRepoForHealTests(t)
|
|
insertTask(t, repo.db, "task-bf")
|
|
// Session references an executor id that does NOT exist in `executors`.
|
|
// backfillTaskEnvironments queries task_sessions for orphans (no env)
|
|
// and calls backfillSingleTask for each — exercising the executor
|
|
// lookup path with sql.ErrNoRows.
|
|
insertSessionWithEnvID(t, repo.db, "sess-bf", "task-bf", "")
|
|
if _, err := repo.db.Exec(
|
|
`UPDATE task_sessions SET executor_id = 'exec-deleted', started_at = ? WHERE id = 'sess-bf'`,
|
|
time.Now().UTC(),
|
|
); err != nil {
|
|
t.Fatalf("seed executor_id: %v", err)
|
|
}
|
|
|
|
if err := repo.backfillTaskEnvironments(); err != nil {
|
|
t.Fatalf("backfillTaskEnvironments: %v", err)
|
|
}
|
|
|
|
var executorType string
|
|
if err := repo.db.QueryRow(
|
|
`SELECT executor_type FROM task_environments WHERE task_id = 'task-bf'`,
|
|
).Scan(&executorType); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if executorType != "local_pc" {
|
|
t.Errorf("executor_type = %q, want default 'local_pc' when executor row absent", executorType)
|
|
}
|
|
}
|