// Package sqlite provides SQLite-based repository implementations. package sqlite import ( "database/sql" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) // Repository provides SQLite-based task storage operations. type Repository struct { db *sqlx.DB // writer ro *sqlx.DB // reader (read-only pool) ownsDB bool } // NewWithDB creates a new SQLite repository with an existing database connection (shared ownership). func NewWithDB(writer, reader *sqlx.DB) (*Repository, error) { return newRepository(writer, reader, false) } func newRepository(writer, reader *sqlx.DB, ownsDB bool) (*Repository, error) { repo := &Repository{db: writer, ro: reader, ownsDB: ownsDB} if err := repo.initSchema(); err != nil { if ownsDB { if closeErr := writer.Close(); closeErr != nil { return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr) } } return nil, fmt.Errorf("failed to initialize schema: %w", err) } return repo, nil } // Close closes the database connection func (r *Repository) Close() error { if !r.ownsDB { return nil } return r.db.Close() } // DB returns the underlying sql.DB instance for shared access func (r *Repository) DB() *sql.DB { return r.db.DB } // ensureWorkspaceIndexes creates workspace-related indexes func (r *Repository) ensureWorkspaceIndexes() error { if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`); err != nil { return err } if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_workflows_workspace_id ON workflows(workspace_id)`); err != nil { return err } if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_archived ON tasks(workspace_id, archived_at)`); err != nil { return err } return nil } // ensureMessageMetadataIndexes creates indexes on JSON metadata fields for fast lookups. func (r *Repository) ensureMessageMetadataIndexes() error { if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_messages_metadata_tool_call_id ON task_session_messages(task_session_id, json_extract(metadata, '$.tool_call_id'))`); err != nil { return err } if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_messages_metadata_pending_id ON task_session_messages(task_session_id, json_extract(metadata, '$.pending_id'))`); err != nil { return err } return nil } // initSchema creates the database tables if they don't exist func (r *Repository) initSchema() error { if err := r.initCoreSchema(); err != nil { return err } if err := r.initPlansSchema(); err != nil { return err } if err := r.initSessionSchema(); err != nil { return err } if err := r.initGitSchema(); err != nil { return err } if err := r.initReviewSchema(); err != nil { return err } if err := r.migrateExecutorProfiles(); err != nil { return err } if err := r.migrateTaskSessions(); err != nil { return err } if err := r.ensureDefaultWorkspace(); err != nil { return err } if err := r.ensureDefaultExecutorsAndEnvironments(); err != nil { return err } if err := r.runMigrations(); err != nil { return err } if err := r.backfillTaskEnvironments(); err != nil { return err } if err := r.backfillTaskEnvironmentRepos(); err != nil { return err } if err := r.healTaskEnvironmentWorkspacePaths(); err != nil { return err } if err := r.healDuplicateTaskEnvironments(); err != nil { return err } if err := r.ensureTaskEnvironmentTaskUniqueIndex(); err != nil { return err } if err := r.healSessionTaskEnvironmentIDs(); err != nil { return err } if err := r.ensureWorkspaceIndexes(); err != nil { return err } return r.ensureMessageMetadataIndexes() } // migrateExecutorProfiles adds mcp_policy column and drops is_default from executor_profiles. func (r *Repository) migrateExecutorProfiles() error { // Add mcp_policy column if it doesn't exist _, _ = r.db.Exec(`ALTER TABLE executor_profiles ADD COLUMN mcp_policy TEXT DEFAULT ''`) // Drop is_default column - SQLite doesn't support DROP COLUMN before 3.35.0, // so we just ignore the old column if present. New schema omits it. return nil } // migrateTaskSessions adds new columns to task_sessions. func (r *Repository) migrateTaskSessions() error { _, _ = r.db.Exec(`ALTER TABLE task_sessions ADD COLUMN executor_profile_id TEXT DEFAULT ''`) return nil } // runMigrations applies idempotent ALTER TABLE migrations for schema evolution. func (r *Repository) runMigrations() error { // Add last_message_uuid column to executors_running (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE executors_running ADD COLUMN last_message_uuid TEXT DEFAULT ''`) // Add metadata column to executors_running (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE executors_running ADD COLUMN metadata TEXT DEFAULT '{}'`) // Add is_ephemeral column to tasks for quick chat (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE tasks ADD COLUMN is_ephemeral INTEGER NOT NULL DEFAULT 0`) // Add checkout_branch column to task_repositories (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE task_repositories ADD COLUMN checkout_branch TEXT DEFAULT ''`) // Add base_commit_sha column to task_sessions for tracking session start commit (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE task_sessions ADD COLUMN base_commit_sha TEXT DEFAULT ''`) // Add default_config_agent_profile_id column to workspaces (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`) // Add task_environment_id column to task_sessions for shared environment reference (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE task_sessions ADD COLUMN task_environment_id TEXT DEFAULT ''`) // Add parent_id column to tasks for subtask support (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE tasks ADD COLUMN parent_id TEXT DEFAULT ''`) // Remove FK constraint on workflow_id to allow ephemeral tasks without workflows if err := r.migrateTasksRemoveWorkflowFK(); err != nil { return err } // Remove deprecated workflow_step_id column from task_sessions if err := r.migrateSessionsRemoveWorkflowStepID(); err != nil { return err } // Backfill executors_running from task_sessions and drop the denormalized // agent_execution_id / container_id columns. After this migration, // executors_running is the single source of truth for "active execution per // session" — see persistence.go in the lifecycle package for the new ownership // model. Order matters: backfill must run BEFORE the column drop. if err := r.backfillExecutorsRunningFromTaskSessions(); err != nil { return err } if err := r.migrateSessionsRemoveAgentExecutionID(); err != nil { return err } // Add task_dir_name column to task_environments for multi-repo task root layout (ignore error if already exists). // Must run BEFORE migrateTaskEnvironmentsRemoveAgentExecutionID, which copies task_dir_name into the recreated table. _, _ = r.db.Exec(`ALTER TABLE task_environments ADD COLUMN task_dir_name TEXT DEFAULT ''`) if err := r.migrateTaskEnvironmentsRemoveAgentExecutionID(); err != nil { return err } // Add sort_order column to workflows for user-defined ordering (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) // Add agent_profile_id column to workflows for per-workflow agent profile override (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`) // Add hidden flag to workflows for system-only flows excluded from management UI (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`) return nil } // recreateTable checks whether tableName's DDL contains triggerPhrase and, if so, // runs statements inside a transaction with FK enforcement disabled. // This is the standard SQLite pattern for dropping columns or FK constraints, // since SQLite has no ALTER TABLE DROP COLUMN / DROP CONSTRAINT. // Note: PRAGMA statements cannot run inside a transaction in SQLite, so FK enforcement // is toggled outside the transaction. The writer pool must have MaxOpenConns(1) so that // the PRAGMA and the subsequent transaction use the same connection. func (r *Repository) recreateTable(tableName, triggerPhrase string, statements []string) error { var tableSql string err := r.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`, tableName).Scan(&tableSql) if errors.Is(err, sql.ErrNoRows) { return nil // Table doesn't exist yet; migration not applicable } if err != nil { return fmt.Errorf("query %s schema: %w", tableName, err) } if !strings.Contains(tableSql, triggerPhrase) { return nil // Trigger phrase absent; migration already applied or not needed } if _, err := r.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil { return fmt.Errorf("disable foreign keys: %w", err) } defer func() { _, _ = r.db.Exec(`PRAGMA foreign_keys=ON`) }() tx, err := r.db.Beginx() if err != nil { return fmt.Errorf("begin migration transaction: %w", err) } defer func() { _ = tx.Rollback() }() for _, stmt := range statements { if _, err := tx.Exec(stmt); err != nil { return fmt.Errorf("migration %s failed: %w", tableName, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration transaction: %w", err) } return nil } // migrateTasksRemoveWorkflowFK removes the foreign key constraint on workflow_id // to allow ephemeral tasks (quick chat) to have empty workflow_id. func (r *Repository) migrateTasksRemoveWorkflowFK() error { return r.recreateTable("tasks", "FOREIGN KEY (workflow_id)", []string{ `CREATE TABLE tasks_new ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', workflow_id TEXT NOT NULL DEFAULT '', workflow_step_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, description TEXT DEFAULT '', state TEXT DEFAULT 'TODO', priority INTEGER DEFAULT 0, position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', is_ephemeral INTEGER NOT NULL DEFAULT 0, parent_id TEXT DEFAULT '', archived_at TIMESTAMP, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL )`, `INSERT INTO tasks_new SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at FROM tasks`, `DROP TABLE tasks`, `ALTER TABLE tasks_new RENAME TO tasks`, `CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id)`, `CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id)`, `CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)`, }) } // backfillExecutorsRunningFromTaskSessions creates an executors_running row for // any session that has a non-empty task_sessions.agent_execution_id but no matching // executors_running row. This preserves the data we're about to drop from // task_sessions in the canonical executors_running table. // // Sessions with empty agent_execution_id are skipped intentionally — they were // never launched (e.g. CREATED state, PR-watcher review tasks), and the new // invariant says "executors_running row exists iff session was launched". // // Idempotent: rows that already exist on either side are left untouched. func (r *Repository) backfillExecutorsRunningFromTaskSessions() error { // Check whether task_sessions still has the column. If migration already ran, // the column is gone and there's nothing to backfill. var tableSql string if err := r.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='task_sessions'`).Scan(&tableSql); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil } return fmt.Errorf("backfill executors_running: read schema: %w", err) } if !strings.Contains(tableSql, "agent_execution_id") { return nil } now := time.Now().UTC().Format(time.RFC3339Nano) // SELECT … LEFT JOIN to find sessions with execution data but no executors_running row. // Insert with the minimum field set; runtime/status are best-effort defaults // (subsequent Launch / Resume will overwrite via the lifecycle manager's persistence). if _, err := r.db.Exec(` INSERT INTO executors_running ( id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, worktree_id, worktree_path, worktree_branch, error_message, metadata, created_at, updated_at ) -- executors_running.id mirrors session_id (both columns must hold the same UUID -- so the row is self-referential by design — the dupword linter complaint is -- a false positive on the SQL projection list). SELECT ts.id AS er_id, ts.id AS er_session_id, ts.task_id, ts.executor_id, '', 'unknown', 1, '', '', ts.agent_execution_id, ts.container_id, '', 0, 0, '', '', '', '', '{}', ts.started_at, ? FROM task_sessions ts LEFT JOIN executors_running er ON er.session_id = ts.id WHERE COALESCE(ts.agent_execution_id, '') != '' AND er.id IS NULL `, now); err != nil { return fmt.Errorf("backfill executors_running: %w", err) } return nil } // migrateSessionsRemoveAgentExecutionID drops the agent_execution_id and // container_id columns from task_sessions. After this migration, executors_running // is the single source of truth for both fields — no more denormalization. // // Must run after backfillExecutorsRunningFromTaskSessions so any data we're about // to drop is preserved on the executors_running side. // // The trigger phrase "agent_execution_id" detects when the migration hasn't yet // run (column still present); recreateTable is a no-op once the column is gone. func (r *Repository) migrateSessionsRemoveAgentExecutionID() error { return r.recreateTable("task_sessions", "agent_execution_id", []string{ `CREATE TABLE task_sessions_new ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_profile_id TEXT NOT NULL, executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', repository_id TEXT DEFAULT '', base_branch TEXT DEFAULT '', agent_profile_snapshot TEXT DEFAULT '{}', executor_snapshot TEXT DEFAULT '{}', environment_snapshot TEXT DEFAULT '{}', repository_snapshot TEXT DEFAULT '{}', state TEXT NOT NULL DEFAULT 'CREATED', error_message TEXT DEFAULT '', metadata TEXT DEFAULT '{}', started_at TIMESTAMP NOT NULL, completed_at TIMESTAMP, updated_at TIMESTAMP NOT NULL, is_primary INTEGER DEFAULT 0, is_passthrough INTEGER DEFAULT 0, review_status TEXT DEFAULT '', base_commit_sha TEXT DEFAULT '', task_environment_id TEXT DEFAULT '', FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE )`, `INSERT INTO task_sessions_new SELECT id, task_id, agent_profile_id, executor_id, executor_profile_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, is_passthrough, review_status, COALESCE(base_commit_sha, ''), COALESCE(task_environment_id, '') FROM task_sessions`, `DROP TABLE task_sessions`, `ALTER TABLE task_sessions_new RENAME TO task_sessions`, `CREATE INDEX IF NOT EXISTS idx_task_sessions_task_id ON task_sessions(task_id)`, `CREATE INDEX IF NOT EXISTS idx_task_sessions_state ON task_sessions(state)`, `CREATE INDEX IF NOT EXISTS idx_task_sessions_task_state ON task_sessions(task_id, state)`, }) } // migrateTaskEnvironmentsRemoveAgentExecutionID drops the agent_execution_id // column from task_environments. Like task_sessions, this column was a stale // denormalized copy that drifted from the in-memory store. The orchestrator // now reads execution state from executors_running only. func (r *Repository) migrateTaskEnvironmentsRemoveAgentExecutionID() error { return r.recreateTable("task_environments", "agent_execution_id", []string{ `CREATE TABLE task_environments_new ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, repository_id TEXT DEFAULT '', executor_type TEXT NOT NULL DEFAULT '', executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', control_port INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'creating', worktree_id TEXT DEFAULT '', worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', workspace_path TEXT DEFAULT '', container_id TEXT DEFAULT '', sandbox_id TEXT DEFAULT '', task_dir_name TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE )`, `INSERT INTO task_environments_new SELECT 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, COALESCE(task_dir_name, ''), created_at, updated_at FROM task_environments`, `DROP TABLE task_environments`, `ALTER TABLE task_environments_new RENAME TO task_environments`, `CREATE INDEX IF NOT EXISTS idx_task_environments_task_id ON task_environments(task_id)`, `CREATE INDEX IF NOT EXISTS idx_task_environments_status ON task_environments(status)`, // uniq_task_environments_task_id is created by ensureTaskEnvironmentTaskUniqueIndex // AFTER healDuplicateTaskEnvironments collapses any pre-existing duplicates. // Creating it here would fail on databases that still have duplicate task_id rows. }) } // migrateSessionsRemoveWorkflowStepID removes the deprecated workflow_step_id column // from task_sessions. Workflow step is now tracked on the task, not the session. func (r *Repository) migrateSessionsRemoveWorkflowStepID() error { return r.recreateTable("task_sessions", "workflow_step_id", []string{ `CREATE TABLE task_sessions_new ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_execution_id TEXT NOT NULL DEFAULT '', container_id TEXT NOT NULL DEFAULT '', agent_profile_id TEXT NOT NULL, executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', repository_id TEXT DEFAULT '', base_branch TEXT DEFAULT '', agent_profile_snapshot TEXT DEFAULT '{}', executor_snapshot TEXT DEFAULT '{}', environment_snapshot TEXT DEFAULT '{}', repository_snapshot TEXT DEFAULT '{}', state TEXT NOT NULL DEFAULT 'CREATED', error_message TEXT DEFAULT '', metadata TEXT DEFAULT '{}', started_at TIMESTAMP NOT NULL, completed_at TIMESTAMP, updated_at TIMESTAMP NOT NULL, is_primary INTEGER DEFAULT 0, is_passthrough INTEGER DEFAULT 0, review_status TEXT DEFAULT '', base_commit_sha TEXT DEFAULT '', task_environment_id TEXT DEFAULT '', FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE )`, `INSERT INTO task_sessions_new SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, executor_profile_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, is_passthrough, review_status, COALESCE(base_commit_sha, ''), COALESCE(task_environment_id, '') FROM task_sessions`, `DROP TABLE task_sessions`, `ALTER TABLE task_sessions_new RENAME TO task_sessions`, `CREATE INDEX IF NOT EXISTS idx_task_sessions_task_id ON task_sessions(task_id)`, `CREATE INDEX IF NOT EXISTS idx_task_sessions_state ON task_sessions(state)`, `CREATE INDEX IF NOT EXISTS idx_task_sessions_task_state ON task_sessions(task_id, state)`, }) } type backfillRow struct { taskID, executorID, executorProfileID string repositoryID, containerID string startedAt string } // backfillTaskEnvironments creates TaskEnvironment records for historical tasks // that have sessions but no environment, and links orphaned sessions. // Idempotent: tasks with existing environments are skipped. func (r *Repository) backfillTaskEnvironments() error { orphaned, err := r.findOrphanedTasks() if err != nil { return err } if len(orphaned) == 0 { return nil } tx, err := r.db.Begin() if err != nil { return fmt.Errorf("backfill: begin tx: %w", err) } defer tx.Rollback() //nolint:errcheck for _, row := range orphaned { if err := r.backfillSingleTask(tx, row); err != nil { return err } } return tx.Commit() } // findOrphanedTasks returns tasks that have sessions but no task_environments row. // // Pre-refactor this also read ts.container_id; that column was dropped from // task_sessions when executors_running became the source of truth. Historical // orphaned envs for already-launched sessions get container_id from the // executors_running row via the LEFT JOIN; sessions without a row have empty // container_id (they were never launched, so no container to track). func (r *Repository) findOrphanedTasks() ([]backfillRow, error) { rows, err := r.db.Query(` SELECT ts.task_id, COALESCE(ts.executor_id, ''), COALESCE(ts.executor_profile_id, ''), COALESCE(ts.repository_id, ''), COALESCE(er.container_id, ''), ts.started_at FROM task_sessions ts LEFT JOIN task_environments te ON te.task_id = ts.task_id LEFT JOIN executors_running er ON er.session_id = ts.id WHERE te.id IS NULL GROUP BY ts.task_id `) if err != nil { return nil, fmt.Errorf("backfill: query orphaned tasks: %w", err) } defer func() { _ = rows.Close() }() var orphaned []backfillRow for rows.Next() { var row backfillRow if err := rows.Scan(&row.taskID, &row.executorID, &row.executorProfileID, &row.repositoryID, &row.containerID, &row.startedAt); err != nil { return nil, fmt.Errorf("backfill: scan: %w", err) } orphaned = append(orphaned, row) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("backfill: rows: %w", err) } return orphaned, nil } // healTaskEnvironmentWorkspacePaths backfills workspace_path on worktree-mode // envs that have a worktree_path set but an empty workspace_path. Such rows // trigger ErrSessionWorkspaceNotReady forever in GetOrEnsureExecutionForEnvironment // and leave shell terminals stuck on "Connecting terminal...". // // Two worktree layouts produce two different workspace_path values, mirroring // the create branch in orchestrator/executor/executor_execute.go's // computeWorkspacePath: // // - Plain worktree (.../.kandev/worktrees/) — workspace_path = worktree_path // - Task-dir mode (.../.kandev/tasks//) — workspace_path = Dir(worktree_path) // // The pattern lookup is hardcoded to the kandev path layout — anything that // doesn't match either pattern falls back to worktree_path (the common-case // plain-worktree assumption) and is logged for manual review. // // Idempotent — only touches rows where workspace_path is empty. func (r *Repository) healTaskEnvironmentWorkspacePaths() error { rows, err := r.db.Query(` SELECT id, worktree_path FROM task_environments WHERE executor_type = 'worktree' AND COALESCE(workspace_path, '') = '' AND COALESCE(worktree_path, '') != '' `) if err != nil { return fmt.Errorf("heal workspace_path: query: %w", err) } type healRow struct{ id, worktreePath string } var pending []healRow for rows.Next() { var hr healRow if err := rows.Scan(&hr.id, &hr.worktreePath); err != nil { _ = rows.Close() return fmt.Errorf("heal workspace_path: scan: %w", err) } pending = append(pending, hr) } if err := rows.Err(); err != nil { _ = rows.Close() return fmt.Errorf("heal workspace_path: rows: %w", err) } _ = rows.Close() if len(pending) == 0 { return nil } for _, hr := range pending { ws := healedWorkspacePath(hr.worktreePath) if _, err := r.db.Exec( `UPDATE task_environments SET workspace_path = ?, updated_at = datetime('now') WHERE id = ?`, ws, hr.id, ); err != nil { return fmt.Errorf("heal workspace_path: update %s: %w", hr.id, err) } } return nil } // healedWorkspacePath derives workspace_path from worktree_path using the // kandev layout rules. Mirrors computeWorkspacePath in the orchestrator so // the heal lands the same value the create branch would. func healedWorkspacePath(worktreePath string) string { if worktreePath == "" { return "" } // Task-dir mode places the worktree at /.kandev/tasks//. // The workspace is the per-task root one level up from the repo subdir. // Detect via the segment .kandev/tasks/ in the path. if strings.Contains(worktreePath, "/.kandev/tasks/") { parent := worktreePath[:strings.LastIndex(worktreePath, "/")] if parent != "" { return parent } } // Plain worktree mode: workspace_path == worktree_path. return worktreePath } // healDuplicateTaskEnvironments collapses rows where a single task has more // than one task_environments row (race in lazy create). Keeps the most recently // updated row and re-points any sessions still referring to the loser. // // Runs before ensureTaskEnvironmentTaskUniqueIndex so the unique constraint // can be added cleanly. Idempotent — a no-op once the data is healed. func (r *Repository) healDuplicateTaskEnvironments() error { tx, err := r.db.Begin() if err != nil { return fmt.Errorf("heal duplicate envs: begin tx: %w", err) } defer tx.Rollback() //nolint:errcheck rows, err := tx.Query(` SELECT task_id FROM task_environments GROUP BY task_id HAVING COUNT(*) > 1 `) if err != nil { return fmt.Errorf("heal duplicate envs: list duplicates: %w", err) } var taskIDs []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { _ = rows.Close() return fmt.Errorf("heal duplicate envs: scan: %w", err) } taskIDs = append(taskIDs, id) } if err := rows.Err(); err != nil { _ = rows.Close() return fmt.Errorf("heal duplicate envs: rows: %w", err) } _ = rows.Close() for _, taskID := range taskIDs { if err := healDuplicateTaskEnvForTask(tx, taskID); err != nil { return err } } return tx.Commit() } // healDuplicateTaskEnvForTask keeps the most recently updated env for a task, // re-points sessions on the loser rows to the winner, then deletes losers. func healDuplicateTaskEnvForTask(tx *sql.Tx, taskID string) error { var winnerID string if err := tx.QueryRow(` SELECT id FROM task_environments WHERE task_id = ? ORDER BY updated_at DESC, created_at DESC LIMIT 1 `, taskID).Scan(&winnerID); err != nil { return fmt.Errorf("heal duplicate envs: find winner for task %s: %w", taskID, err) } if _, err := tx.Exec(` UPDATE task_sessions SET task_environment_id = ? WHERE task_id = ? AND task_environment_id != ? `, winnerID, taskID, winnerID); err != nil { return fmt.Errorf("heal duplicate envs: relink sessions for task %s: %w", taskID, err) } if _, err := tx.Exec(` DELETE FROM task_environments WHERE task_id = ? AND id != ? `, taskID, winnerID); err != nil { return fmt.Errorf("heal duplicate envs: delete losers for task %s: %w", taskID, err) } return nil } // ensureTaskEnvironmentTaskUniqueIndex adds a UNIQUE index on // task_environments(task_id) so that a future race in env creation fails loud // instead of silently producing two rows for the same task. Must run AFTER // healDuplicateTaskEnvironments, which collapses any pre-existing duplicates. func (r *Repository) ensureTaskEnvironmentTaskUniqueIndex() error { _, err := r.db.Exec(` CREATE UNIQUE INDEX IF NOT EXISTS uniq_task_environments_task_id ON task_environments(task_id) `) return err } // healSessionTaskEnvironmentIDs backfills task_sessions.task_environment_id // for any session whose task already has a task_environments row. Sessions // created via paths that don't write the FK leave shell ops broken because // every user-shell RPC is env-keyed and the frontend can't resolve session→env // without this column. Idempotent: rows that already point at the env are // untouched. // // Must run AFTER backfillTaskEnvironments + healDuplicateTaskEnvironments + // ensureTaskEnvironmentTaskUniqueIndex so each task has exactly one env to // link to. func (r *Repository) healSessionTaskEnvironmentIDs() error { // LIMIT 1 is defensive — the unique index added by // ensureTaskEnvironmentTaskUniqueIndex guarantees ≤1 row per task at // runtime, but the SQL reads as non-deterministic in isolation. Belt // and suspenders. if _, err := r.db.Exec(` UPDATE task_sessions SET task_environment_id = ( SELECT te.id FROM task_environments te WHERE te.task_id = task_sessions.task_id LIMIT 1 ) WHERE (task_environment_id = '' OR task_environment_id IS NULL) AND EXISTS ( SELECT 1 FROM task_environments te WHERE te.task_id = task_sessions.task_id ) `); err != nil { return fmt.Errorf("heal session env id: update: %w", err) } return nil } // backfillSingleTask creates a task_environment and links sessions for one orphaned task. func (r *Repository) backfillSingleTask(tx *sql.Tx, row backfillRow) error { envID := uuid.New().String() // Look up executor type from executors table, default to "local_pc" var executorType string if err := tx.QueryRow(`SELECT type FROM executors WHERE id = ?`, row.executorID).Scan(&executorType); err != nil { executorType = "local_pc" } // Look up worktree info from task_session_worktrees (best effort) var wtID, wtPath, wtBranch string _ = tx.QueryRow(` SELECT w.worktree_id, w.worktree_path, w.worktree_branch FROM task_session_worktrees w JOIN task_sessions ts ON ts.id = w.session_id WHERE ts.task_id = ? LIMIT 1 `, row.taskID).Scan(&wtID, &wtPath, &wtBranch) // Insert task_environment with status "stopped" (historical, agentctl not running). // Pre-refactor this also wrote agent_execution_id; that column is gone from // task_environments (executors_running is the only carrier of execution state now). if _, err := tx.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, 'stopped', ?, ?, ?, '', ?, '', ?, datetime('now')) `, envID, row.taskID, row.repositoryID, executorType, row.executorID, row.executorProfileID, wtID, wtPath, wtBranch, row.containerID, row.startedAt); err != nil { return fmt.Errorf("backfill: insert env for task %s: %w", row.taskID, err) } // Link all sessions for this task that lack task_environment_id if _, err := tx.Exec(` UPDATE task_sessions SET task_environment_id = ? WHERE task_id = ? AND (task_environment_id = '' OR task_environment_id IS NULL) `, envID, row.taskID); err != nil { return fmt.Errorf("backfill: link sessions for task %s: %w", row.taskID, err) } return nil } // backfillTaskEnvironmentRepos populates task_environment_repos from the legacy // single-repo fields on task_environments. One row per environment that has a // non-empty repository_id and no existing task_environment_repos row. // Idempotent. func (r *Repository) backfillTaskEnvironmentRepos() error { rows, err := r.db.Query(` SELECT te.id, te.repository_id, COALESCE(te.worktree_id, ''), COALESCE(te.worktree_path, ''), COALESCE(te.worktree_branch, ''), te.created_at FROM task_environments te LEFT JOIN task_environment_repos ter ON ter.task_environment_id = te.id WHERE te.repository_id != '' AND ter.id IS NULL `) if err != nil { return fmt.Errorf("backfill repos: query: %w", err) } defer func() { _ = rows.Close() }() type envRepoRow struct { envID, repoID, wtID, wtPath, wtBranch, createdAt string } var pending []envRepoRow for rows.Next() { var row envRepoRow if err := rows.Scan(&row.envID, &row.repoID, &row.wtID, &row.wtPath, &row.wtBranch, &row.createdAt); err != nil { return fmt.Errorf("backfill repos: scan: %w", err) } pending = append(pending, row) } if err := rows.Err(); err != nil { return fmt.Errorf("backfill repos: rows: %w", err) } if len(pending) == 0 { return nil } tx, err := r.db.Begin() if err != nil { return fmt.Errorf("backfill repos: begin tx: %w", err) } defer tx.Rollback() //nolint:errcheck for _, row := range pending { if _, err := tx.Exec(` INSERT INTO task_environment_repos ( id, task_environment_id, repository_id, worktree_id, worktree_path, worktree_branch, position, error_message, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?) `, uuid.New().String(), row.envID, row.repoID, row.wtID, row.wtPath, row.wtBranch, row.createdAt, row.createdAt); err != nil { return fmt.Errorf("backfill repos: insert env %s: %w", row.envID, err) } } return tx.Commit() } func (r *Repository) initCoreSchema() error { if err := r.initInfraSchema(); err != nil { return err } if err := r.initTaskSchema(); err != nil { return err } return r.initCoreIndexes() } func (r *Repository) initInfraSchema() error { _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT DEFAULT '', owner_id TEXT DEFAULT '', default_executor_id TEXT DEFAULT '', default_environment_id TEXT DEFAULT '', default_agent_profile_id TEXT DEFAULT '', default_config_agent_profile_id TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS executors ( id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', is_system INTEGER NOT NULL DEFAULT 0, resumable INTEGER NOT NULL DEFAULT 1, config TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS executors_running ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, task_id TEXT NOT NULL, executor_id TEXT NOT NULL, runtime TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'starting', resumable INTEGER NOT NULL DEFAULT 0, resume_token TEXT DEFAULT '', agent_execution_id TEXT DEFAULT '', container_id TEXT DEFAULT '', agentctl_url TEXT DEFAULT '', agentctl_port INTEGER DEFAULT 0, pid INTEGER DEFAULT 0, worktree_id TEXT DEFAULT '', worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', last_seen_at TIMESTAMP, error_message TEXT DEFAULT '', metadata TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS executor_profiles ( id TEXT PRIMARY KEY, executor_id TEXT NOT NULL, name TEXT NOT NULL, mcp_policy TEXT DEFAULT '', config TEXT DEFAULT '{}', prepare_script TEXT DEFAULT '', cleanup_script TEXT DEFAULT '', env_vars TEXT DEFAULT '[]', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (executor_id) REFERENCES executors(id) ); CREATE TABLE IF NOT EXISTS environments ( id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL, is_system INTEGER NOT NULL DEFAULT 0, worktree_root TEXT DEFAULT '', image_tag TEXT DEFAULT '', dockerfile TEXT DEFAULT '', build_config TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS workflows ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL, description TEXT DEFAULT '', hidden INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); `) return err } func (r *Repository) initTaskSchema() error { _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', workflow_id TEXT NOT NULL DEFAULT '', workflow_step_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, description TEXT DEFAULT '', state TEXT DEFAULT 'TODO', priority INTEGER DEFAULT 0, position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', archived_at TIMESTAMP, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS task_repositories ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, repository_id TEXT NOT NULL, base_branch TEXT DEFAULT '', position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE, UNIQUE(task_id, repository_id) ); CREATE TABLE IF NOT EXISTS repositories ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, source_type TEXT NOT NULL DEFAULT 'local', local_path TEXT DEFAULT '', provider TEXT DEFAULT '', provider_repo_id TEXT DEFAULT '', provider_owner TEXT DEFAULT '', provider_name TEXT DEFAULT '', default_branch TEXT DEFAULT '', worktree_branch_prefix TEXT DEFAULT 'feature/', pull_before_worktree INTEGER NOT NULL DEFAULT 1, setup_script TEXT DEFAULT '', cleanup_script TEXT DEFAULT '', dev_script TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS repository_scripts ( id TEXT PRIMARY KEY, repository_id TEXT NOT NULL, name TEXT NOT NULL, command TEXT NOT NULL, position INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE ); `) return err } func (r *Repository) initCoreIndexes() error { _, err := r.db.Exec(` CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id); CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id); CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at); CREATE INDEX IF NOT EXISTS idx_task_repositories_task_id ON task_repositories(task_id); CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id); CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id); CREATE INDEX IF NOT EXISTS idx_repository_scripts_repo_id ON repository_scripts(repository_id); `) return err } func (r *Repository) initPlansSchema() error { if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_plans ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL UNIQUE, title TEXT NOT NULL DEFAULT 'Plan', content TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT 'agent', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_task_plans_task_id ON task_plans(task_id); `); err != nil { return err } if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_plan_revisions ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, revision_number INTEGER NOT NULL, title TEXT NOT NULL DEFAULT 'Plan', content TEXT NOT NULL DEFAULT '', author_kind TEXT NOT NULL DEFAULT 'agent', author_name TEXT NOT NULL DEFAULT '', revert_of_revision_id TEXT, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, UNIQUE (task_id, revision_number) ); CREATE INDEX IF NOT EXISTS idx_task_plan_revisions_task_created ON task_plan_revisions(task_id, created_at DESC); -- Hot-path index: GetLatestTaskPlanRevision (called on every plan write -- as part of the coalesce check), ListTaskPlanRevisions, and the -- MAX(revision_number) lookup in WritePlanRevision all order/scan by -- (task_id, revision_number DESC). With this index the latest-row lookup -- is O(1) instead of an O(N) scan + sort per task. CREATE INDEX IF NOT EXISTS idx_task_plan_revisions_task_number ON task_plan_revisions(task_id, revision_number DESC); `); err != nil { return err } return r.backfillInitialPlanRevisions() } // backfillInitialPlanRevisions ensures every existing task_plans row has at least // one corresponding revision. Runs once at startup and is idempotent. func (r *Repository) backfillInitialPlanRevisions() error { rows, err := r.db.Query(` SELECT p.id, p.task_id, p.title, p.content, p.created_by, p.created_at, p.updated_at FROM task_plans p WHERE NOT EXISTS ( SELECT 1 FROM task_plan_revisions r WHERE r.task_id = p.task_id )`) if err != nil { return fmt.Errorf("query plans missing revisions: %w", err) } defer func() { _ = rows.Close() }() type row struct { id, taskID, title, content, createdBy string createdAt, updatedAt interface{} } var pending []row for rows.Next() { var x row if err := rows.Scan(&x.id, &x.taskID, &x.title, &x.content, &x.createdBy, &x.createdAt, &x.updatedAt); err != nil { return fmt.Errorf("scan plan for backfill: %w", err) } pending = append(pending, x) } if err := rows.Err(); err != nil { return fmt.Errorf("iterate plans for backfill: %w", err) } for _, x := range pending { authorKind := x.createdBy // Match CreateTaskPlan (plan.go) and the task_plan_revisions column DEFAULT 'agent'. if authorKind != "user" && authorKind != authorKindAgent { authorKind = authorKindAgent } _, err := r.db.Exec(r.db.Rebind(` INSERT INTO task_plan_revisions (id, task_id, revision_number, title, content, author_kind, author_name, revert_of_revision_id, created_at, updated_at) VALUES (?, ?, 1, ?, ?, ?, 'legacy', NULL, ?, ?) `), uuid.New().String(), x.taskID, x.title, x.content, authorKind, x.createdAt, x.updatedAt) if err != nil { return fmt.Errorf("backfill revision for task %s: %w", x.taskID, err) } } return nil } func (r *Repository) initSessionSchema() error { if err := r.initMessageTurnSchema(); err != nil { return err } return r.initSessionWorktreeSchema() } func (r *Repository) initMessageTurnSchema() error { _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_session_messages ( id TEXT PRIMARY KEY, task_session_id TEXT NOT NULL, task_id TEXT DEFAULT '', turn_id TEXT NOT NULL, author_type TEXT NOT NULL DEFAULT 'user', author_id TEXT DEFAULT '', content TEXT NOT NULL, requests_input INTEGER DEFAULT 0, type TEXT NOT NULL DEFAULT 'message', metadata TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, FOREIGN KEY (turn_id) REFERENCES task_session_turns(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_messages_session_id ON task_session_messages(task_session_id); CREATE INDEX IF NOT EXISTS idx_messages_created_at ON task_session_messages(created_at); CREATE INDEX IF NOT EXISTS idx_messages_session_created ON task_session_messages(task_session_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_turn_id ON task_session_messages(turn_id); CREATE TABLE IF NOT EXISTS task_session_turns ( id TEXT PRIMARY KEY, task_session_id TEXT NOT NULL, task_id TEXT NOT NULL, started_at TIMESTAMP NOT NULL, completed_at TIMESTAMP, metadata TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_turns_session_id ON task_session_turns(task_session_id); CREATE INDEX IF NOT EXISTS idx_turns_session_started ON task_session_turns(task_session_id, started_at); CREATE INDEX IF NOT EXISTS idx_turns_task_id ON task_session_turns(task_id); `) return err } func (r *Repository) initSessionWorktreeSchema() error { _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_sessions ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_execution_id TEXT NOT NULL DEFAULT '', container_id TEXT NOT NULL DEFAULT '', agent_profile_id TEXT NOT NULL, executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', repository_id TEXT DEFAULT '', base_branch TEXT DEFAULT '', agent_profile_snapshot TEXT DEFAULT '{}', executor_snapshot TEXT DEFAULT '{}', environment_snapshot TEXT DEFAULT '{}', repository_snapshot TEXT DEFAULT '{}', state TEXT NOT NULL DEFAULT 'CREATED', error_message TEXT DEFAULT '', metadata TEXT DEFAULT '{}', started_at TIMESTAMP NOT NULL, completed_at TIMESTAMP, updated_at TIMESTAMP NOT NULL, is_primary INTEGER DEFAULT 0, is_passthrough INTEGER DEFAULT 0, review_status TEXT DEFAULT '', base_commit_sha TEXT DEFAULT '', task_environment_id TEXT DEFAULT '', FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_task_sessions_task_id ON task_sessions(task_id); CREATE INDEX IF NOT EXISTS idx_task_sessions_state ON task_sessions(state); CREATE INDEX IF NOT EXISTS idx_task_sessions_task_state ON task_sessions(task_id, state); CREATE TABLE IF NOT EXISTS task_environments ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, repository_id TEXT DEFAULT '', executor_type TEXT NOT NULL DEFAULT '', executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', agent_execution_id TEXT DEFAULT '', control_port INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'creating', worktree_id TEXT DEFAULT '', worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', workspace_path TEXT DEFAULT '', container_id TEXT DEFAULT '', sandbox_id TEXT DEFAULT '', task_dir_name TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_task_environments_task_id ON task_environments(task_id); CREATE INDEX IF NOT EXISTS idx_task_environments_status ON task_environments(status); CREATE TABLE IF NOT EXISTS task_environment_repos ( id TEXT PRIMARY KEY, task_environment_id TEXT NOT NULL, repository_id TEXT NOT NULL, worktree_id TEXT DEFAULT '', worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', position INTEGER DEFAULT 0, error_message TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE, UNIQUE(task_environment_id, repository_id) ); CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id); CREATE INDEX IF NOT EXISTS idx_task_environment_repos_repository_id ON task_environment_repos(repository_id); CREATE TABLE IF NOT EXISTS task_session_worktrees ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, worktree_id TEXT NOT NULL, repository_id TEXT NOT NULL, position INTEGER DEFAULT 0, worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, merged_at TIMESTAMP, deleted_at TIMESTAMP, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, UNIQUE(session_id, worktree_id) ); CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_session_id ON task_session_worktrees(session_id); CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_worktree_id ON task_session_worktrees(worktree_id); CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_repository_id ON task_session_worktrees(repository_id); CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_status ON task_session_worktrees(status); `) return err } func (r *Repository) initGitSchema() error { _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_session_git_snapshots ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, snapshot_type TEXT NOT NULL, branch TEXT NOT NULL, remote_branch TEXT DEFAULT '', head_commit TEXT DEFAULT '', base_commit TEXT DEFAULT '', ahead INTEGER DEFAULT 0, behind INTEGER DEFAULT 0, files TEXT DEFAULT '{}', triggered_by TEXT DEFAULT '', metadata TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_git_snapshots_session ON task_session_git_snapshots(session_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_git_snapshots_type ON task_session_git_snapshots(session_id, snapshot_type); CREATE TABLE IF NOT EXISTS task_session_commits ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, commit_sha TEXT NOT NULL, parent_sha TEXT DEFAULT '', author_name TEXT DEFAULT '', author_email TEXT DEFAULT '', commit_message TEXT DEFAULT '', committed_at TIMESTAMP NOT NULL, pre_commit_snapshot_id TEXT DEFAULT '', post_commit_snapshot_id TEXT DEFAULT '', files_changed INTEGER DEFAULT 0, insertions INTEGER DEFAULT 0, deletions INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_session_commits_session ON task_session_commits(session_id, committed_at DESC); CREATE INDEX IF NOT EXISTS idx_session_commits_sha ON task_session_commits(commit_sha); `) return err } func (r *Repository) initReviewSchema() error { _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS session_file_reviews ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, file_path TEXT NOT NULL, reviewed INTEGER NOT NULL DEFAULT 0, diff_hash TEXT NOT NULL DEFAULT '', reviewed_at TIMESTAMP, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, UNIQUE(session_id, file_path) ); CREATE INDEX IF NOT EXISTS idx_session_file_reviews_session ON session_file_reviews(session_id); `) return err }