Files
kandev/apps/backend/internal/task/repository/sqlite/heal_test.go
T
João Salavisa a627957c5b refactor(task/repo): split sqlite/base.go and add session batch loader (#1058)
* refactor(task/repo): split sqlite/base.go (1486 lines) into schema/migrations/queries

base.go grew to 1.85x the 800-line lint threshold by mixing the Repository
struct, schema initialization, ALTER TABLE migrations, recreate-and-copy
table rebuilds, and historical backfills/heals. Split along those seams:

- base.go (58 lines) keeps only the Repository struct, NewWithDB,
  newRepository, Close, DB.
- base_schema.go owns the initSchema orchestrator, every init*Schema
  function, ensure*Indexes, ensureRunnerProjectionTables, and the
  plan-revision backfill that runs as part of initPlansSchema.
- base_migrations.go owns migrate*, recreateTable[Named], runMigrations,
  the backfillRow type, and every backfill*/heal* function. Verbatim
  function moves with no semantic changes.
- base_queries.go owns sqlLimitClause (still referenced by plan.go,
  document.go, message.go).

To keep golangci-lint (new: true) green after the move:
- initSchema's 17 if-return chain becomes a for-each-step loop so
  cyclomatic complexity drops from 19 to within the 15 limit.
- initInfraSchema and initSessionWorktreeSchema hoist their DDL strings
  into package-level consts (infraSchemaDDL, sessionWorktreeSchemaDDL)
  so each function body fits the 80-line funlen cap.

Step ordering in initSchema is preserved exactly (backfillTaskEnvironments
-> backfillTaskEnvironmentRepos -> healTaskEnvironmentWorkspacePaths ->
healDuplicateTaskEnvironments -> ensureTaskEnvironmentTaskUniqueIndex ->
healSessionTaskEnvironmentIDs) so existing prod DBs continue to migrate
correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(task/repo): add BatchGetSessionsByTaskIDs to eliminate task-list N+1

Adds a single repository method that returns every task_session for a list
of task IDs in one chunked IN (...) query, grouped by task_id. The
workspace task-list endpoint previously needed three batched calls
(GetPrimarySessionIDsByTaskIDs + GetSessionCountsByTaskIDs +
GetPrimarySessionInfoByTaskIDs); with this loader the handler can derive
primary session ID, session count, and per-task session info from a
single round trip.

- buildInPlaceholders and chunkIDs land in base_queries.go.
  sqliteMaxHostParams=500 keeps each chunk well below SQLite's
  compile-time SQLITE_MAX_VARIABLE_NUMBER (999 on older builds, 32766
  on newer).
- session.go gains BatchGetSessionsByTaskIDs, which reuses
  taskSessionSelectCols / scanTaskSessions / loadWorktreesBatch so the
  returned sessions are indistinguishable from ListTaskSessions output
  (worktrees populated, JSON snapshots decoded). Empty input
  short-circuits with a non-nil empty map.
- SessionRepository interface and Service.BatchGetSessionsForTasks
  expose the new method to the handler layer. The three existing
  *ByTaskIDs methods are left in place because service_events.go still
  uses them for single-task event publishing.
- mockRepository implementations updated in two test files so the
  packages still satisfy the interface.

TestBatchGetSessionsByTaskIDs covers the empty-input short-circuit, the
multi-task (3 x 2) case (primary derivation + no cross-task bleed), and
the asked-for-but-missing case (absent from result map).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* perf(task/handlers): use BatchGetSessionsForTasks in task-list endpoint

buildTaskDTOsWithSessionInfo previously issued three batched queries per
request (GetPrimarySessionIDsForTasks + GetSessionCountsForTasks +
GetPrimarySessionInfoForTasks). Each was already chunked by task_id,
but together they were three round trips for data that can be derived
from one. Collapse to a single BatchGetSessionsForTasks call and derive
primary session ID, count, and per-task primary info in memory.

The JSON response shape is unchanged: dto.FromTaskWithSessionInfo
still receives the same pointer-typed arguments, primarySessionID stays
nil when no primary exists, and sessionCount stays nil for tasks with
zero sessions (matches the existing pointer-when-present contract that
the frontend store hydration depends on).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/handlers): restore JOIN-derived executor type/name in task-list response

buildTaskDTOsWithSessionInfo regressed when it stopped calling
GetPrimarySessionInfoForTasks: extractSessionInfo reads
ExecutorSnapshot["executor_type"] and ExecutorSnapshot["executor_name"],
but those keys exist only on the synthetic snapshot built by the
GetPrimarySessionInfoByTaskIDs LEFT JOIN to executors. The persisted
ExecutorSnapshot JSON uses different keys (e.g. "type" — see
share/builder.go:96), so BatchGetSessionsForTasks alone always yielded
empty executor_type / executor_name fields in the task-list response,
breaking the frontend task card's executor badge.

Fix (caught in code review):
- Handler now calls both BatchGetSessionsForTasks (for primary session
  ID and session count) and GetPrimarySessionInfoForTasks (for the
  JOIN-derived executor type/name and other session info). Two queries
  instead of the original three; correctness preserved.
- New TestGetPrimarySessionInfoByTaskIDs_PopulatesExecutorJoinFields
  seeds an executor + primary session, then asserts the JOIN populates
  ExecutorSnapshot["executor_type"] / ["executor_name"] on the returned
  session. Locks in the contract the handler depends on so a future
  refactor that drops the JOIN fails loudly instead of silently
  emitting empty fields.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/repo): chunkIDs returns empty slice for empty input

chunkIDs previously returned [][]string{nil} for an empty/nil input
(a single chunk whose value is nil). The only current caller
(BatchGetSessionsByTaskIDs) short-circuits empty input before reaching
chunkIDs, so the bug stayed dormant — but any new caller that forgot
the short-circuit would feed a nil chunk to buildInPlaceholders (which
returns "" for nil), produce a `... WHERE task_id IN () ORDER BY ...`
query, and hit a SQL syntax error at runtime.

Make the helper safe by construction: an empty input returns nil
(empty outer slice), so `range chunkIDs(empty, n)` iterates zero
times. Add base_queries_test.go covering:

- Empty/nil input → no chunks (locks the safety contract).
- Single-pass input fits in one chunk.
- Over-threshold input splits into correctly-sized, in-order chunks.
- buildInPlaceholders normal + empty-input cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/repo): propagate non-ErrNoRows errors in backfillSingleTask executor lookup

`backfillSingleTask` looked up `executors.type` for the legacy executor
referenced by a session and defaulted to `"local_pc"` on any error.
That silently masked driver failures, schema mismatches, and corruption
— every backfilled task_environment would get the wrong executor_type
without surfacing the underlying problem.

Narrow the default to the legitimate case: only `sql.ErrNoRows` (the
executor row was actually deleted, e.g. legacy session whose executor
is gone) falls back to `"local_pc"`. Any other scan error now aborts
the migration with a wrapped error so operators see the real cause.

Adds TestBackfillSingleTask_DefaultsExecutorTypeOnMissingRow which
seeds a session referencing a non-existent executor id and asserts the
backfill still completes with `executor_type = local_pc` — locks in
the legitimate-fallback path.

Addresses PR #1058 review feedback from cubic & claude.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/repo): chunk ListWorktreesBySessionIDs to honor SQLite var limit

ListWorktreesBySessionIDs built its IN-clause from every input session ID
in one shot. BatchGetSessionsByTaskIDs chunks task IDs at 500 to stay
below SQLITE_MAX_VARIABLE_NUMBER (999 on older builds), but its
loadWorktreesBatch step then handed all sessions in that 500-task chunk
to ListWorktreesBySessionIDs un-chunked — so at modest workloads (500
tasks × ≥2 sessions each = 1000 placeholders) the query fails with
SQLITE_RANGE and bubbles up as a 404 on the task-list endpoint.

ListWorktreesBySessionIDs now applies the same chunkIDs / 500 cap that
BatchGet uses. Body extracted to appendWorktreesForSessionChunk so the
public method stays under the 80-line funlen cap.

New TestListWorktreesBySessionIDs_ChunksOverPlaceholderLimit feeds the
method 1001 non-existent session IDs and asserts it returns cleanly —
without chunking, this fails with SQLITE_RANGE on a default-built SQLite.

Also splits TestBatchGetSessionsByTaskIDs (was 98 lines) into focused
subtests + a shared seedBatchSessions helper, satisfying the test-size
convention. The three sub-scenarios (empty input, multi-task grouping,
missing-task absence) now fail in isolation.

Addresses PR #1058 review feedback from greptile (P1) and claude
(suggestion).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/repo): port repositories.copy_files migration to split files

Rebased onto main; main added copy_files to runMigrations() and
initTaskSchema() in base.go while our branch had split those into
base_migrations.go and base_schema.go. Cherry-pick both additions
into the correct split files so the schema is consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:57:15 +01:00

606 lines
22 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)
}
}
// 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)
}
}