495 lines
17 KiB
Go
495 lines
17 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
internaldb "github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
// revisionSelectCols lists the task_plan_revisions columns in the fixed order used by
|
|
// every SELECT in this file (and by scanRevisionRow / scanRevisionRows).
|
|
const revisionSelectCols = `id, task_id, revision_number, title, content, author_kind, author_name, revert_of_revision_id, workflow_step_id, workflow_step_name, workflow_step_color, created_at, updated_at`
|
|
|
|
// authorKindAgent matches the task_plan_revisions.author_kind column DEFAULT
|
|
// and is the fallback for unknown values when persisting plan history rows.
|
|
const authorKindAgent = "agent"
|
|
|
|
const planSelectCols = `id, task_id, title, content, created_by, created_at, updated_at, comments_revision, implementation_started_at, implementation_started_session_id, implementation_started_by`
|
|
|
|
// CreateTaskPlan creates a new task plan.
|
|
func (r *Repository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error {
|
|
if plan.ID == "" {
|
|
plan.ID = uuid.New().String()
|
|
}
|
|
now := time.Now().UTC()
|
|
plan.CreatedAt = now
|
|
plan.UpdatedAt = now
|
|
|
|
if plan.Title == "" {
|
|
plan.Title = "Plan"
|
|
}
|
|
if plan.CreatedBy == "" {
|
|
plan.CreatedBy = authorKindAgent
|
|
}
|
|
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO task_plans (id, task_id, title, content, created_by, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`), plan.ID, plan.TaskID, plan.Title, plan.Content, plan.CreatedBy, plan.CreatedAt, plan.UpdatedAt)
|
|
return err
|
|
}
|
|
|
|
// GetTaskPlan retrieves a task plan by task ID.
|
|
func (r *Repository) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) {
|
|
plan, err := scanPlanRow(r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
|
SELECT `+planSelectCols+`
|
|
FROM task_plans WHERE task_id = ?
|
|
`), taskID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get task plan: %w", err)
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
// UpdateTaskPlan updates an existing task plan.
|
|
func (r *Repository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) error {
|
|
plan.UpdatedAt = time.Now().UTC()
|
|
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE task_plans SET title = ?, content = ?, created_by = ?, updated_at = ?
|
|
WHERE task_id = ?
|
|
`), plan.Title, plan.Content, plan.CreatedBy, plan.UpdatedAt, plan.TaskID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update task plan: %w", err)
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("task plan not found for task: %s", plan.TaskID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkTaskPlanImplementationStarted records the first accepted implementation start.
|
|
// It is idempotent: later calls return the existing marker without changing it.
|
|
func (r *Repository) MarkTaskPlanImplementationStarted(ctx context.Context, taskID, sessionID, actor string) (*models.TaskPlan, error) {
|
|
now := time.Now().UTC()
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE task_plans
|
|
SET
|
|
implementation_started_at = COALESCE(implementation_started_at, ?),
|
|
implementation_started_session_id = CASE
|
|
WHEN implementation_started_at IS NULL THEN ?
|
|
ELSE implementation_started_session_id
|
|
END,
|
|
implementation_started_by = CASE
|
|
WHEN implementation_started_at IS NULL THEN ?
|
|
ELSE implementation_started_by
|
|
END,
|
|
updated_at = CASE
|
|
WHEN implementation_started_at IS NULL THEN ?
|
|
ELSE updated_at
|
|
END
|
|
WHERE task_id = ?
|
|
`), now, sessionID, actor, now, taskID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to mark task plan implementation started: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return nil, fmt.Errorf("%w: %s", ErrTaskPlanNotFound, taskID)
|
|
}
|
|
return r.GetTaskPlan(ctx, taskID)
|
|
}
|
|
|
|
// DeleteTaskPlan deletes a task plan by task ID.
|
|
func (r *Repository) DeleteTaskPlan(ctx context.Context, taskID string) error {
|
|
tx, release, err := r.beginPlanCommentTx(ctx, taskID)
|
|
if err != nil {
|
|
return fmt.Errorf("begin task plan deletion: %w", err)
|
|
}
|
|
defer release()
|
|
defer func() { _ = tx.Rollback() }()
|
|
result, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM task_plans WHERE task_id = ?`), taskID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete task plan: %w", err)
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("task plan not found for task: %s", taskID)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit task plan deletion: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Revision history
|
|
|
|
// InsertTaskPlanRevision inserts a new revision row.
|
|
func (r *Repository) InsertTaskPlanRevision(ctx context.Context, rev *models.TaskPlanRevision) error {
|
|
if rev.ID == "" {
|
|
rev.ID = uuid.New().String()
|
|
}
|
|
now := time.Now().UTC()
|
|
if rev.CreatedAt.IsZero() {
|
|
rev.CreatedAt = now
|
|
}
|
|
rev.UpdatedAt = now
|
|
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO task_plan_revisions
|
|
(`+revisionSelectCols+`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`),
|
|
rev.ID, rev.TaskID, rev.RevisionNumber, rev.Title, rev.Content,
|
|
rev.AuthorKind, rev.AuthorName, rev.RevertOfRevisionID,
|
|
rev.WorkflowStepID, rev.WorkflowStepName, rev.WorkflowStepColor,
|
|
rev.CreatedAt, rev.UpdatedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to insert task plan revision: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateTaskPlanRevision updates title/content/updated_at on an existing revision (coalesce merge).
|
|
func (r *Repository) UpdateTaskPlanRevision(ctx context.Context, rev *models.TaskPlanRevision) error {
|
|
rev.UpdatedAt = time.Now().UTC()
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE task_plan_revisions
|
|
SET title = ?, content = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`), rev.Title, rev.Content, rev.UpdatedAt, rev.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update task plan revision: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("task plan revision not found: %s", rev.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetTaskPlanRevision fetches a single revision by ID.
|
|
func (r *Repository) GetTaskPlanRevision(ctx context.Context, id string) (*models.TaskPlanRevision, error) {
|
|
return r.scanRevisionRow(r.ro.QueryRowContext(ctx, r.ro.Rebind(
|
|
`SELECT `+revisionSelectCols+` FROM task_plan_revisions WHERE id = ?`,
|
|
), id))
|
|
}
|
|
|
|
// GetLatestTaskPlanRevision returns the newest revision for a task (by revision_number DESC).
|
|
func (r *Repository) GetLatestTaskPlanRevision(ctx context.Context, taskID string) (*models.TaskPlanRevision, error) {
|
|
return r.scanRevisionRow(r.ro.QueryRowContext(ctx, r.ro.Rebind(
|
|
`SELECT `+revisionSelectCols+` FROM task_plan_revisions WHERE task_id = ? ORDER BY revision_number DESC LIMIT 1`,
|
|
), taskID))
|
|
}
|
|
|
|
// ListTaskPlanRevisions returns revisions newest-first. limit <= 0 returns all.
|
|
func (r *Repository) ListTaskPlanRevisions(ctx context.Context, taskID string, limit int) ([]*models.TaskPlanRevision, error) {
|
|
query := `SELECT ` + revisionSelectCols + ` FROM task_plan_revisions WHERE task_id = ? ORDER BY revision_number DESC`
|
|
args := []interface{}{taskID}
|
|
if limit > 0 {
|
|
query += sqlLimitClause
|
|
args = append(args, limit)
|
|
}
|
|
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list task plan revisions: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var out []*models.TaskPlanRevision
|
|
for rows.Next() {
|
|
rev := &models.TaskPlanRevision{}
|
|
var revertOf sql.NullString
|
|
if err := rows.Scan(
|
|
&rev.ID, &rev.TaskID, &rev.RevisionNumber, &rev.Title, &rev.Content,
|
|
&rev.AuthorKind, &rev.AuthorName, &revertOf,
|
|
&rev.WorkflowStepID, &rev.WorkflowStepName, &rev.WorkflowStepColor,
|
|
&rev.CreatedAt, &rev.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("failed to scan task plan revision: %w", err)
|
|
}
|
|
if revertOf.Valid {
|
|
v := revertOf.String
|
|
rev.RevertOfRevisionID = &v
|
|
}
|
|
out = append(out, rev)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate task plan revisions: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// NextTaskPlanRevisionNumber returns max(revision_number)+1 for a task, or 1 if none exist.
|
|
//
|
|
// Note: prefer WritePlanRevision when writing a new revision — it computes the next number
|
|
// atomically with the insert. This helper reads via the RO replica and is only safe against
|
|
// TOCTOU when followed by a write under a BEGIN IMMEDIATE-equivalent (e.g., within
|
|
// WritePlanRevision). Direct callers should be confined to read-only inspection / tests.
|
|
func (r *Repository) NextTaskPlanRevisionNumber(ctx context.Context, taskID string) (int, error) {
|
|
var maxNum sql.NullInt64
|
|
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
|
SELECT MAX(revision_number) FROM task_plan_revisions WHERE task_id = ?
|
|
`), taskID).Scan(&maxNum)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get next revision number: %w", err)
|
|
}
|
|
if !maxNum.Valid {
|
|
return 1, nil
|
|
}
|
|
return int(maxNum.Int64) + 1, nil
|
|
}
|
|
|
|
// WritePlanRevision atomically upserts HEAD (task_plans) and either appends a new revision
|
|
// or merges into an existing one in a single write transaction keyed by task_id. This closes
|
|
// the TOCTOU window on revision_number (MAX+1 is computed inside the tx) and prevents HEAD
|
|
// from disagreeing with history on partial failure.
|
|
//
|
|
// Coalesce behavior: when coalesceLatestID is non-nil and non-empty, the revision with that
|
|
// ID has title/content/updated_at merged in-place and its other fields (revision_number,
|
|
// author, created_at) are preserved. When nil or empty, a new revision row is inserted with
|
|
// revision_number = MAX(existing)+1 and populated from rev.
|
|
//
|
|
// On success, head contains the authoritative persisted title and created_by values. The rev is
|
|
// mutated to reflect the persisted state (ID, RevisionNumber, CreatedAt, UpdatedAt).
|
|
//
|
|
// preserveTitle and preserveCreatedBy apply only when an existing HEAD row is found (the
|
|
// ON CONFLICT branch): true keeps the row's stored title / created_by instead of overwriting
|
|
// it with head's value. A fresh insert always uses head's value regardless of these flags.
|
|
func (r *Repository) WritePlanRevision(
|
|
ctx context.Context,
|
|
head *models.TaskPlan,
|
|
rev *models.TaskPlanRevision,
|
|
coalesceLatestID *string,
|
|
preserveTitle, preserveCreatedBy bool,
|
|
) error {
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin plan revision tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
now := time.Now().UTC()
|
|
if coalesceLatestID == nil || *coalesceLatestID == "" {
|
|
if err := r.lockTaskForPlanRevision(ctx, tx, rev.TaskID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := upsertPlanHead(ctx, tx, r.db, head, now, preserveTitle, preserveCreatedBy); err != nil {
|
|
return err
|
|
}
|
|
rev.Title = head.Title
|
|
if coalesceLatestID != nil && *coalesceLatestID != "" {
|
|
if err := mergeRevisionInTx(ctx, tx, r.db, rev, *coalesceLatestID, now); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := insertNewRevisionInTx(ctx, tx, r.db, rev, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func scanPlanRow(row *sql.Row) (*models.TaskPlan, error) {
|
|
plan := &models.TaskPlan{}
|
|
var startedAt sql.NullTime
|
|
var sessionID sql.NullString
|
|
var actor sql.NullString
|
|
err := row.Scan(
|
|
&plan.ID,
|
|
&plan.TaskID,
|
|
&plan.Title,
|
|
&plan.Content,
|
|
&plan.CreatedBy,
|
|
&plan.CreatedAt,
|
|
&plan.UpdatedAt,
|
|
&plan.CommentsRevision,
|
|
&startedAt,
|
|
&sessionID,
|
|
&actor,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if startedAt.Valid {
|
|
plan.ImplementationStartedAt = &startedAt.Time
|
|
}
|
|
if sessionID.Valid {
|
|
plan.ImplementationStartedSessionID = &sessionID.String
|
|
}
|
|
if actor.Valid {
|
|
plan.ImplementationStartedBy = &actor.String
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
func upsertPlanHead(
|
|
ctx context.Context,
|
|
tx *sqlx.Tx,
|
|
db *sqlx.DB,
|
|
head *models.TaskPlan,
|
|
now time.Time,
|
|
preserveTitle, preserveCreatedBy bool,
|
|
) error {
|
|
if head.ID == "" {
|
|
head.ID = uuid.New().String()
|
|
}
|
|
if head.Title == "" {
|
|
head.Title = "Plan"
|
|
}
|
|
if head.CreatedBy == "" {
|
|
head.CreatedBy = authorKindAgent
|
|
}
|
|
if head.CreatedAt.IsZero() {
|
|
head.CreatedAt = now
|
|
}
|
|
head.UpdatedAt = now
|
|
if _, err := tx.ExecContext(ctx, db.Rebind(`
|
|
INSERT INTO task_plans (id, task_id, title, content, created_by, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(task_id) DO UPDATE SET
|
|
title = CASE WHEN ? THEN task_plans.title ELSE excluded.title END,
|
|
content = excluded.content,
|
|
created_by = CASE WHEN ? THEN task_plans.created_by ELSE excluded.created_by END,
|
|
updated_at = excluded.updated_at
|
|
`), head.ID, head.TaskID, head.Title, head.Content, head.CreatedBy, head.CreatedAt, head.UpdatedAt,
|
|
preserveTitle, preserveCreatedBy); err != nil {
|
|
if internaldb.IsForeignKeyViolation(err) {
|
|
return fmt.Errorf("upsert task plan head for task %s: %w", head.TaskID, ErrTaskNotFound)
|
|
}
|
|
return fmt.Errorf("upsert task plan head: %w", err)
|
|
}
|
|
if preserveTitle || preserveCreatedBy {
|
|
var storedTitle, storedCreatedBy string
|
|
if err := tx.QueryRowContext(ctx, db.Rebind(`
|
|
SELECT title, created_by FROM task_plans WHERE task_id = ?
|
|
`), head.TaskID).Scan(&storedTitle, &storedCreatedBy); err != nil {
|
|
return fmt.Errorf("read preserved task plan metadata: %w", err)
|
|
}
|
|
if preserveTitle {
|
|
head.Title = storedTitle
|
|
}
|
|
if preserveCreatedBy {
|
|
head.CreatedBy = storedCreatedBy
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mergeRevisionInTx(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, rev *models.TaskPlanRevision, latestID string, now time.Time) error {
|
|
result, err := tx.ExecContext(ctx, db.Rebind(`
|
|
UPDATE task_plan_revisions
|
|
SET title = ?, content = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`), rev.Title, rev.Content, now, latestID)
|
|
if err != nil {
|
|
return fmt.Errorf("merge plan revision: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("task plan revision not found: %s", latestID)
|
|
}
|
|
rev.ID = latestID
|
|
rev.UpdatedAt = now
|
|
return readRevisionWorkflowStampInTx(ctx, tx, db, rev)
|
|
}
|
|
|
|
func insertNewRevisionInTx(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, rev *models.TaskPlanRevision, now time.Time) error {
|
|
var maxNum sql.NullInt64
|
|
if err := tx.QueryRowContext(ctx, db.Rebind(`
|
|
SELECT MAX(revision_number) FROM task_plan_revisions WHERE task_id = ?
|
|
`), rev.TaskID).Scan(&maxNum); err != nil {
|
|
return fmt.Errorf("compute next revision number: %w", err)
|
|
}
|
|
rev.RevisionNumber = 1
|
|
if maxNum.Valid {
|
|
rev.RevisionNumber = int(maxNum.Int64) + 1
|
|
}
|
|
if rev.ID == "" {
|
|
rev.ID = uuid.New().String()
|
|
}
|
|
if rev.CreatedAt.IsZero() {
|
|
rev.CreatedAt = now
|
|
}
|
|
rev.UpdatedAt = now
|
|
result, err := tx.ExecContext(ctx, db.Rebind(`
|
|
INSERT INTO task_plan_revisions
|
|
(id, task_id, revision_number, title, content, author_kind, author_name, revert_of_revision_id, workflow_step_id, workflow_step_name, workflow_step_color, created_at, updated_at)
|
|
SELECT ?, ?, ?, ?, ?, ?, ?, ?,
|
|
COALESCE(ws.id, ''), COALESCE(ws.name, ''), COALESCE(ws.color, ''), ?, ?
|
|
FROM tasks AS t
|
|
LEFT JOIN workflow_steps AS ws ON ws.id = t.workflow_step_id
|
|
WHERE t.id = ?
|
|
`),
|
|
rev.ID, rev.TaskID, rev.RevisionNumber, rev.Title, rev.Content,
|
|
rev.AuthorKind, rev.AuthorName, rev.RevertOfRevisionID,
|
|
rev.CreatedAt, rev.UpdatedAt, rev.TaskID)
|
|
if err != nil {
|
|
return fmt.Errorf("insert plan revision: %w", err)
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("count inserted plan revision: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("%w: %s", ErrTaskNotFound, rev.TaskID)
|
|
}
|
|
return readRevisionWorkflowStampInTx(ctx, tx, db, rev)
|
|
}
|
|
|
|
func (r *Repository) lockTaskForPlanRevision(ctx context.Context, tx *sqlx.Tx, taskID string) error {
|
|
_, _, found, err := r.readTaskStepInTx(ctx, tx, taskID)
|
|
if err != nil {
|
|
return fmt.Errorf("read task for plan revision: %w", err)
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("%w: %s", ErrTaskNotFound, taskID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readRevisionWorkflowStampInTx(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, rev *models.TaskPlanRevision) error {
|
|
if err := tx.QueryRowContext(ctx, db.Rebind(`
|
|
SELECT workflow_step_id, workflow_step_name, workflow_step_color
|
|
FROM task_plan_revisions WHERE id = ?
|
|
`), rev.ID).Scan(&rev.WorkflowStepID, &rev.WorkflowStepName, &rev.WorkflowStepColor); err != nil {
|
|
return fmt.Errorf("read persisted plan revision workflow stamp: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) scanRevisionRow(row *sql.Row) (*models.TaskPlanRevision, error) {
|
|
rev := &models.TaskPlanRevision{}
|
|
var revertOf sql.NullString
|
|
err := row.Scan(
|
|
&rev.ID, &rev.TaskID, &rev.RevisionNumber, &rev.Title, &rev.Content,
|
|
&rev.AuthorKind, &rev.AuthorName, &revertOf,
|
|
&rev.WorkflowStepID, &rev.WorkflowStepName, &rev.WorkflowStepColor,
|
|
&rev.CreatedAt, &rev.UpdatedAt,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan task plan revision: %w", err)
|
|
}
|
|
if revertOf.Valid {
|
|
v := revertOf.String
|
|
rev.RevertOfRevisionID = &v
|
|
}
|
|
return rev, nil
|
|
}
|