package sqlite import ( "context" "database/sql" "errors" "fmt" "time" "github.com/jmoiron/sqlx" kandevdb "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository/repoerrors" ) // SwitchTaskRunner implements repository.TaskRepository.SwitchTaskRunner. // See the interface doc comment for the contract; this method is the single // transaction system-design's "Control flow" steps 5-7 describe. func (r *Repository) SwitchTaskRunner(ctx context.Context, req models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error) { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) } defer func() { _ = tx.Rollback() }() if err := kandevdb.LockTaskRowInTx(ctx, tx, r.db.DriverName(), req.TaskID); err != nil { if errors.Is(err, kandevdb.ErrTaskRowNotFound) { return nil, fmt.Errorf("%w: %s", ErrTaskNotFound, req.TaskID) } return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) } task, err := r.runnerSwitchReadTaskTx(ctx, tx, req.TaskID) if err != nil { return nil, err } verdict, repoSnapshot, err := r.runnerSwitchEvaluate(ctx, req, task) if err != nil { return nil, err } if !verdict.Editable { return nil, &repoerrors.ErrRunnerMutabilityConflict{Reason: verdict.Reason} } switch { case req.CompatibilityResolutionFailed: // The gate applies to this target, but a lookup pre-transaction // resolution needed (a repository read, or the clone-URL candidate // lookup) errored or timed out. Reported only here, after the // mutability gate above has already passed, so a task that is also // ineligible for a stable reason reports that reason instead of this // retriable one. return nil, fmt.Errorf("%w: compatibility resolution failed", repoerrors.ErrRunnerEvaluationUnavailable) case req.CompatibilityChecked: if err := runnerSwitchConfirmCompatibility(req, repoSnapshot); err != nil { return nil, err } case req.CompatibilityApplicable: // The gate applies to this target but pre-transaction resolution // skipped it because the repository shape didn't allow evaluation // then. Reaching this point already means the mutability gate found // exactly one repository now (its own RepositoryCount==0/>1 checks // come first and would have rejected otherwise), so the shape did // change since resolution and that stale skip cannot be trusted. return nil, fmt.Errorf("%w: repository shape changed since compatibility resolution", repoerrors.ErrRunnerEvaluationUnavailable) } result, err := r.runnerSwitchApply(ctx, tx, task, req.ExecutorProfileID) if err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) } return result, nil } func (r *Repository) runnerSwitchReadTaskTx(ctx context.Context, tx *sqlx.Tx, taskID string) (*models.Task, error) { row := tx.QueryRowContext(ctx, r.db.Rebind( `SELECT `+taskSelectColumns("t")+` FROM tasks t WHERE t.id = ?`), taskID) task, err := r.scanSingleTask(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: %s", ErrTaskNotFound, taskID) } return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) } return task, nil } // runnerRepositoryLinkSnapshot is the single attached repository's identity // and last-updated column, read inside the switch transaction. Empty when // the task does not have exactly one repository at that moment. type runnerRepositoryLinkSnapshot struct { count int repositoryID string updatedAt time.Time } // runnerSwitchEvaluate gathers every mutability signal inside the open // transaction (after the row lock, so every read is as current as the write // it guards) and evaluates the mutability gate. func (r *Repository) runnerSwitchEvaluate( ctx context.Context, req models.RunnerSwitchRequest, task *models.Task, ) (models.RunnerMutabilityVerdict, runnerRepositoryLinkSnapshot, error) { unavailable := func(err error) (models.RunnerMutabilityVerdict, runnerRepositoryLinkSnapshot, error) { return models.RunnerMutabilityVerdict{}, runnerRepositoryLinkSnapshot{}, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) } repoSnapshot, err := r.runnerRepositoryLinkSnapshot(ctx, task.ID) if err != nil { return unavailable(err) } hasSession, err := r.runnerHasSession(ctx, task.ID) if err != nil { return unavailable(err) } hasEnvironment, err := r.runnerHasEnvironment(ctx, task.ID) if err != nil { return unavailable(err) } hasExecutorRunning, err := r.runnerHasExecutorRunning(ctx, task.ID) if err != nil { return unavailable(err) } hasWorkspaceFolder, err := r.runnerHasWorkspaceFolder(ctx, task.ID) if err != nil { return unavailable(err) } hasGroupMembership, err := runnerHasGroupMembership(ctx, req, task.ID) if err != nil { return unavailable(err) } signals := models.RunnerSignalsFromTask(task) signals.RepositoryCount = repoSnapshot.count signals.HasSession = hasSession signals.HasEnvironment = hasEnvironment signals.HasExecutorRunning = hasExecutorRunning signals.HasWorkspaceFolder = hasWorkspaceFolder signals.HasActiveGroupMembership = hasGroupMembership return models.EvaluateRunnerMutability(signals), repoSnapshot, nil } func runnerHasGroupMembership(ctx context.Context, req models.RunnerSwitchRequest, taskID string) (bool, error) { if req.GroupMembershipChecker == nil { return false, nil } return req.GroupMembershipChecker(ctx, taskID) } // runnerSwitchConfirmCompatibility enforces that a compatibility verdict // resolved before the transaction is only valid for the repository it was // resolved against. A mismatch discards the verdict and rejects as the // retriable evaluation_unavailable rather than applying a verdict computed // against a repository the task no longer has. func runnerSwitchConfirmCompatibility(req models.RunnerSwitchRequest, snapshot runnerRepositoryLinkSnapshot) error { if snapshot.count != 1 || snapshot.repositoryID != req.ResolvedRepositoryID || !snapshot.updatedAt.Equal(req.ResolvedRepositoryUpdatedAt) { return fmt.Errorf("%w: repository link changed since compatibility resolution", repoerrors.ErrRunnerEvaluationUnavailable) } if !req.CompatibilityCloneURLFound { return repoerrors.ErrRunnerCompatibilityConflict } return nil } // runnerSwitchApply is stage 7 of the control flow: compare the requested // profile against the stored one and either report a no-op success or write // the sole metadata change. func (r *Repository) runnerSwitchApply( ctx context.Context, tx *sqlx.Tx, task *models.Task, executorProfileID string, ) (*models.RunnerSwitchResult, error) { stored, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string) if stored == executorProfileID { return &models.RunnerSwitchResult{Task: task, Changed: false}, nil } now := time.Now().UTC() if err := r.setTaskMetadataKeyWithExecutor(ctx, tx, task.ID, models.MetaKeyExecutorProfileID, executorProfileID, now); err != nil { return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) } if task.Metadata == nil { task.Metadata = map[string]interface{}{} } task.Metadata[models.MetaKeyExecutorProfileID] = executorProfileID task.UpdatedAt = now return &models.RunnerSwitchResult{Task: task, Changed: true}, nil } // runnerRepositoryLinkSnapshot reads through the reader pool (r.ro), not a // transaction. Isolation for this read comes from the task row lock the // caller already holds (LockTaskRowInTx in SwitchTaskRunner), not from // executing inside tx. func (r *Repository) runnerRepositoryLinkSnapshot(ctx context.Context, taskID string) (runnerRepositoryLinkSnapshot, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind( `SELECT repository_id, updated_at FROM task_repositories WHERE task_id = ?`), taskID) if err != nil { return runnerRepositoryLinkSnapshot{}, err } defer func() { _ = rows.Close() }() var snapshot runnerRepositoryLinkSnapshot for rows.Next() { var repositoryID string var updatedAt time.Time if err := rows.Scan(&repositoryID, &updatedAt); err != nil { return runnerRepositoryLinkSnapshot{}, err } snapshot.count++ snapshot.repositoryID = repositoryID snapshot.updatedAt = updatedAt } return snapshot, rows.Err() } func (r *Repository) runnerHasSession(ctx context.Context, taskID string) (bool, error) { var exists bool err := r.ro.QueryRowContext(ctx, r.ro.Rebind( `SELECT EXISTS (SELECT 1 FROM task_sessions WHERE task_id = ?)`), taskID).Scan(&exists) return exists, err } func (r *Repository) runnerHasEnvironment(ctx context.Context, taskID string) (bool, error) { var exists bool err := r.ro.QueryRowContext(ctx, r.ro.Rebind( `SELECT EXISTS (SELECT 1 FROM task_environments WHERE task_id = ?)`), taskID).Scan(&exists) return exists, err } func (r *Repository) runnerHasExecutorRunning(ctx context.Context, taskID string) (bool, error) { var exists bool err := r.ro.QueryRowContext(ctx, r.ro.Rebind( `SELECT EXISTS (SELECT 1 FROM executors_running WHERE task_id = ?)`), taskID).Scan(&exists) return exists, err } func (r *Repository) runnerHasWorkspaceFolder(ctx context.Context, taskID string) (bool, error) { var exists bool err := r.ro.QueryRowContext(ctx, r.ro.Rebind( `SELECT EXISTS (SELECT 1 FROM task_workspace_folders WHERE task_id = ?)`), taskID).Scan(&exists) return exists, err }