a627957c5b
* 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>
606 lines
22 KiB
Go
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)
|
|
}
|
|
}
|