1504 lines
56 KiB
Go
1504 lines
56 KiB
Go
// Package sqlite provides SQLite-based repository implementations.
|
|
package sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/db"
|
|
)
|
|
|
|
// sqlLimitClause is the SQL fragment appended to dynamic queries when a row limit is requested.
|
|
const sqlLimitClause = " LIMIT ?"
|
|
|
|
// Repository provides SQLite-based task storage operations.
|
|
type Repository struct {
|
|
db *sqlx.DB // writer
|
|
ro *sqlx.DB // reader (read-only pool)
|
|
ownsDB bool
|
|
log *logger.Logger
|
|
migrate *db.MigrateLogger
|
|
}
|
|
|
|
// NewWithDB creates a new SQLite repository with an existing database connection (shared ownership).
|
|
func NewWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) {
|
|
return newRepository(writer, reader, log, false)
|
|
}
|
|
|
|
func newRepository(writer, reader *sqlx.DB, log *logger.Logger, ownsDB bool) (*Repository, error) {
|
|
repo := &Repository{
|
|
db: writer,
|
|
ro: reader,
|
|
ownsDB: ownsDB,
|
|
log: log,
|
|
migrate: db.NewMigrateLogger(writer, log),
|
|
}
|
|
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.initDocumentsSchema(); 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 {
|
|
r.migrate.Apply("executor_profiles.mcp_policy", `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.migrate.Apply("task_sessions.executor_profile_id", `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 {
|
|
r.migrate.Apply("executors_running.last_message_uuid", `ALTER TABLE executors_running ADD COLUMN last_message_uuid TEXT DEFAULT ''`)
|
|
r.migrate.Apply("executors_running.metadata", `ALTER TABLE executors_running ADD COLUMN metadata TEXT DEFAULT '{}'`)
|
|
r.migrate.Apply("tasks.is_ephemeral", `ALTER TABLE tasks ADD COLUMN is_ephemeral INTEGER NOT NULL DEFAULT 0`)
|
|
r.migrate.Apply("task_repositories.checkout_branch", `ALTER TABLE task_repositories ADD COLUMN checkout_branch TEXT DEFAULT ''`)
|
|
r.migrate.Apply("task_sessions.base_commit_sha", `ALTER TABLE task_sessions ADD COLUMN base_commit_sha TEXT DEFAULT ''`)
|
|
r.migrate.Apply("workspaces.default_config_agent_profile_id", `ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`)
|
|
r.migrate.Apply("task_sessions.task_environment_id", `ALTER TABLE task_sessions ADD COLUMN task_environment_id TEXT DEFAULT ''`)
|
|
r.migrate.Apply("tasks.parent_id", `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
|
|
}
|
|
// Must run BEFORE migrateTaskEnvironmentsRemoveAgentExecutionID, which copies task_dir_name into the recreated table.
|
|
r.migrate.Apply("task_environments.task_dir_name", `ALTER TABLE task_environments ADD COLUMN task_dir_name TEXT DEFAULT ''`)
|
|
if err := r.migrateTaskEnvironmentsRemoveAgentExecutionID(); err != nil {
|
|
return err
|
|
}
|
|
r.migrate.Apply("workflows.sort_order", `ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`)
|
|
r.migrate.Apply("workflows.agent_profile_id", `ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`)
|
|
r.migrate.Apply("workflows.hidden", `ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`)
|
|
r.migrate.Apply("task_sessions.workspace_path", `ALTER TABLE task_sessions ADD COLUMN workspace_path TEXT DEFAULT ''`)
|
|
|
|
// Office task extensions - net-new columns on existing main tables.
|
|
// Idempotent ALTERs; main upgrades pick them up at first boot.
|
|
// The transient in-branch columns (requires_approval,
|
|
// execution_policy, execution_state, assignee_agent_profile_id,
|
|
// task_sessions.agent_instance_id) were never on main and are
|
|
// therefore not added or dropped here.
|
|
r.migrate.Apply("tasks.origin", `ALTER TABLE tasks ADD COLUMN origin TEXT DEFAULT 'manual'`)
|
|
r.migrate.Apply("tasks.project_id", `ALTER TABLE tasks ADD COLUMN project_id TEXT DEFAULT ''`)
|
|
r.migrate.Apply("tasks.labels", `ALTER TABLE tasks ADD COLUMN labels TEXT DEFAULT '[]'`)
|
|
r.migrate.Apply("tasks.identifier", `ALTER TABLE tasks ADD COLUMN identifier TEXT`)
|
|
// Office task-handoffs phase 6 - tag tasks archived as part of a cascade so
|
|
// unarchive can restore exactly the descendants that cascade archived.
|
|
r.migrate.Apply("tasks.archived_by_cascade_id", `ALTER TABLE tasks ADD COLUMN archived_by_cascade_id TEXT DEFAULT ''`)
|
|
|
|
// Office workspace extensions
|
|
r.migrate.Apply("workspaces.task_prefix", `ALTER TABLE workspaces ADD COLUMN task_prefix TEXT DEFAULT 'KAN'`)
|
|
r.migrate.Apply("workspaces.task_sequence", `ALTER TABLE workspaces ADD COLUMN task_sequence INTEGER DEFAULT 0`)
|
|
r.migrate.Apply("workspaces.office_workflow_id", `ALTER TABLE workspaces ADD COLUMN office_workflow_id TEXT DEFAULT ''`)
|
|
|
|
// Office session cost tracking extensions are declared in
|
|
// initSessionWorktreeSchema's CREATE TABLE (cost_subcents, tokens_in,
|
|
// tokens_out). task_sessions.agent_profile_id existed on main as
|
|
// NOT NULL; migrateSessionsRemoveAgentExecutionID rebuilds the table
|
|
// with the column nullable and the cost columns added.
|
|
|
|
r.migrate.Apply("workflows.is_system", `ALTER TABLE workflows ADD COLUMN is_system INTEGER DEFAULT 0`)
|
|
|
|
// Phase 2 (ADR-0004) - workflows.style is a UX hint for the frontend
|
|
// ("kanban" | "office" | "custom"). Backend code MUST NOT branch on
|
|
// this value. Idempotent ALTER; default "kanban" preserves the current
|
|
// presentation for existing workflows.
|
|
r.migrate.Apply("workflows.style", `ALTER TABLE workflows ADD COLUMN style TEXT NOT NULL DEFAULT 'kanban'`)
|
|
|
|
// ADR 0005 Wave F — ensure the runner-projection tables exist so
|
|
// task SELECTs that reference them via correlated subquery don't
|
|
// fail. Required for tests and any environment where the workflow
|
|
// repo hasn't run yet.
|
|
r.ensureRunnerProjectionTables()
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureRunnerProjectionTables creates stub workflow_steps and
|
|
// workflow_step_participants tables if they're not yet present. The
|
|
// task repo's task SELECT projection includes a correlated subquery
|
|
// against both tables to resolve the per-task runner (ADR 0005 Wave F);
|
|
// when only the task repo is initialised (e.g. unit tests), the
|
|
// workflow repo hasn't created the canonical tables and the queries
|
|
// would error with "no such table". Stubs created here are minimal —
|
|
// the workflow repo's init still runs and adds the rest of its columns
|
|
// via idempotent ALTER and CREATE statements.
|
|
func (r *Repository) ensureRunnerProjectionTables() {
|
|
// workflow_steps: matches the full schema declared in the workflow
|
|
// repo so workflow.NewWithDB's later ALTER ADD COLUMNs become no-ops
|
|
// (column-already-exists errors are swallowed). Mirrors
|
|
// internal/workflow/repository/sqlite.go (the canonical owner).
|
|
_, _ = r.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS workflow_steps (
|
|
id TEXT PRIMARY KEY,
|
|
workflow_id TEXT NOT NULL DEFAULT '',
|
|
name TEXT NOT NULL DEFAULT '',
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
color TEXT,
|
|
prompt TEXT,
|
|
events TEXT,
|
|
allow_manual_move INTEGER DEFAULT 1,
|
|
is_start_step INTEGER DEFAULT 0,
|
|
show_in_command_panel INTEGER DEFAULT 1,
|
|
auto_archive_after_hours INTEGER DEFAULT 0,
|
|
agent_profile_id TEXT NOT NULL DEFAULT '',
|
|
stage_type TEXT NOT NULL DEFAULT 'custom',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`)
|
|
_, _ = r.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS workflow_step_participants (
|
|
id TEXT PRIMARY KEY,
|
|
step_id TEXT NOT NULL DEFAULT '',
|
|
task_id TEXT NOT NULL DEFAULT '',
|
|
role TEXT NOT NULL DEFAULT '',
|
|
agent_profile_id TEXT NOT NULL DEFAULT '',
|
|
decision_required INTEGER NOT NULL DEFAULT 0,
|
|
position INTEGER NOT NULL DEFAULT 0
|
|
)`)
|
|
}
|
|
|
|
// 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.
|
|
// Returns true if the migration actually ran (gate fired), false if it was a no-op.
|
|
func (r *Repository) recreateTable(tableName, triggerPhrase string, statements []string) (bool, 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 false, nil // Table doesn't exist yet; migration not applicable
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("query %s schema: %w", tableName, err)
|
|
}
|
|
if !strings.Contains(tableSql, triggerPhrase) {
|
|
return false, nil // Trigger phrase absent; migration already applied or not needed
|
|
}
|
|
|
|
if _, err := r.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
|
|
return false, 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 false, fmt.Errorf("begin migration transaction: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
for _, stmt := range statements {
|
|
if _, err := tx.Exec(stmt); err != nil {
|
|
return false, fmt.Errorf("migration %s failed: %w", tableName, err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return false, fmt.Errorf("commit migration transaction: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// recreateTableNamed wraps recreateTable and logs "migration applied" when the
|
|
// gate fires (trigger phrase found and statements ran).
|
|
func (r *Repository) recreateTableNamed(name, tableName, triggerPhrase string, statements []string) error {
|
|
fired, err := r.recreateTable(tableName, triggerPhrase, statements)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fired && r.log != nil {
|
|
r.log.Info("migration applied", zap.String("name", name))
|
|
}
|
|
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.recreateTableNamed("tasks.recreate_drop_workflow_fk", "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.recreateTableNamed("task_sessions.recreate_drop_agent_execution_id", "task_sessions", "agent_execution_id", []string{
|
|
`CREATE TABLE task_sessions_new (
|
|
id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
agent_profile_id TEXT,
|
|
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 '',
|
|
cost_subcents INTEGER NOT NULL DEFAULT 0,
|
|
tokens_in INTEGER NOT NULL DEFAULT 0,
|
|
tokens_out INTEGER NOT NULL DEFAULT 0,
|
|
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, ''),
|
|
0, 0, 0
|
|
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.recreateTableNamed("task_environments.recreate_drop_agent_execution_id", "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.recreateTableNamed("task_sessions.recreate_drop_workflow_step_id", "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,
|
|
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/<name>) — workspace_path = worktree_path
|
|
// - Task-dir mode (.../.kandev/tasks/<name>/<repo>) — 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 <root>/.kandev/tasks/<name>/<repo>.
|
|
// 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
|
|
}
|
|
|
|
// initDocumentsSchema creates the task_documents and task_document_revisions tables.
|
|
// These tables generalize task_plans: documents have a key (e.g., "plan", "spec") and type.
|
|
func (r *Repository) initDocumentsSchema() error {
|
|
if _, err := r.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS task_documents (
|
|
id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
key TEXT NOT NULL DEFAULT 'plan',
|
|
type TEXT NOT NULL DEFAULT 'plan',
|
|
title TEXT NOT NULL DEFAULT 'Plan',
|
|
content TEXT NOT NULL DEFAULT '',
|
|
author_kind TEXT NOT NULL DEFAULT 'agent',
|
|
author_name TEXT NOT NULL DEFAULT '',
|
|
filename TEXT NOT NULL DEFAULT '',
|
|
mime_type TEXT NOT NULL DEFAULT '',
|
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
disk_path TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL,
|
|
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
|
UNIQUE(task_id, key)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_task_documents_task_id ON task_documents(task_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS task_document_revisions (
|
|
id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
document_key TEXT NOT NULL DEFAULT 'plan',
|
|
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, document_key, revision_number)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_task_document_revisions_task_key
|
|
ON task_document_revisions(task_id, document_key, revision_number DESC);
|
|
`); err != nil {
|
|
return fmt.Errorf("init documents schema: %w", 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,
|
|
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 '',
|
|
cost_subcents INTEGER NOT NULL DEFAULT 0,
|
|
tokens_in INTEGER NOT NULL DEFAULT 0,
|
|
tokens_out INTEGER NOT NULL DEFAULT 0,
|
|
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
|
|
}
|