191 lines
6.6 KiB
Go
191 lines
6.6 KiB
Go
package storage
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"log"
|
||
"time"
|
||
)
|
||
|
||
// newTaskTag генерирует стабильный UUID задачи (task_tag) — он же ложится в
|
||
// feature-ветку git (feat/<taskTag>). Генерится при создании и НЕ меняется.
|
||
func newTaskTag() string {
|
||
b := make([]byte, 8)
|
||
if _, err := rand.Read(b); err != nil {
|
||
// крипто-rand недоступен — fallback по времени
|
||
return hex.EncodeToString([]byte(time.Now().Format("150405.000000000")))
|
||
}
|
||
return hex.EncodeToString(b)
|
||
}
|
||
|
||
// CreateTask создаёт задачу со статусом draft. Возвращает её ID.
|
||
// chat_id и task_tag передаются извне; если task_tag не задан — генерится уник
|
||
// UUID (стабильный на всё время жизни, нужен для feature-ветки feat/<tag>).
|
||
func (s *Storage) CreateTask(ctx context.Context, t *Task) (int64, error) {
|
||
if t.TaskTag == "" {
|
||
t.TaskTag = newTaskTag()
|
||
}
|
||
now := Now()
|
||
res, err := s.db.ExecContext(ctx, `
|
||
INSERT INTO tasks (chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
t.ChatID, t.Title, t.Goal, t.Repo, t.ReposJoined(), t.Why, t.AC,
|
||
t.StepsJoined(), t.TaskTag, StatusDraft, now, now,
|
||
)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("%w: create task: %w", ErrDB, err)
|
||
}
|
||
id, err := res.LastInsertId()
|
||
if err != nil {
|
||
return 0, fmt.Errorf("%w: last insert id: %w", ErrDB, err)
|
||
}
|
||
t.ID = id
|
||
t.Status = StatusDraft
|
||
t.CreatedAt = now
|
||
t.UpdatedAt = now
|
||
return id, nil
|
||
}
|
||
|
||
// GetTask возвращает задачу по ID.
|
||
func (s *Storage) GetTask(ctx context.Context, id int64) (*Task, error) {
|
||
t := &Task{}
|
||
var reposStr string
|
||
var stepsStr string
|
||
err := s.db.QueryRowContext(ctx, `
|
||
SELECT id, chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at
|
||
FROM tasks WHERE id = ?`, id).Scan(
|
||
&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr,
|
||
&t.Why, &t.AC, &stepsStr, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt,
|
||
)
|
||
if err == sql.ErrNoRows {
|
||
return nil, fmt.Errorf("%w: task %d", ErrNotFound, id)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: get task %d: %w", ErrDB, id, err)
|
||
}
|
||
t.SetReposFromDB(reposStr)
|
||
t.SetStepsFromDB(stepsStr)
|
||
return t, nil
|
||
}
|
||
|
||
// UpdateTask обновляет поля задачи. Если newStatus указан и отличен от текущего —
|
||
// проверяет валидность перехода. Возвращает ошибку ErrInvalidStatus при недопустимом переходе.
|
||
func (s *Storage) UpdateTask(ctx context.Context, t *Task) error {
|
||
// получаем текущий статус для валидации перехода
|
||
var oldStatus Status
|
||
err := s.db.QueryRowContext(ctx, `SELECT status FROM tasks WHERE id = ?`, t.ID).Scan(&oldStatus)
|
||
if err == sql.ErrNoRows {
|
||
return fmt.Errorf("%w: task %d", ErrNotFound, t.ID)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("%w: get status %d: %w", ErrDB, t.ID, err)
|
||
}
|
||
|
||
if t.Status != "" && t.Status != oldStatus {
|
||
if !IsValidTransition(oldStatus, t.Status) {
|
||
return fmt.Errorf("%w: %s → %s for task %d", ErrInvalidStatus, oldStatus, t.Status, t.ID)
|
||
}
|
||
log.Printf("task %d: status %q → %q", t.ID, oldStatus, t.Status)
|
||
} else if t.Status == "" {
|
||
t.Status = oldStatus
|
||
}
|
||
|
||
now := Now()
|
||
res, err := s.db.ExecContext(ctx, `
|
||
UPDATE tasks
|
||
SET title=?, goal=?, repo=?, repos=?, why=?, ac=?, steps=?, status=?, updated_at=?
|
||
WHERE id=?`,
|
||
t.Title, t.Goal, t.Repo, t.ReposJoined(), t.Why, t.AC, t.StepsJoined(), t.Status, now, t.ID,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("%w: update task %d: %w", ErrDB, t.ID, err)
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
return fmt.Errorf("%w: task %d", ErrNotFound, t.ID)
|
||
}
|
||
t.UpdatedAt = now
|
||
return nil
|
||
}
|
||
|
||
// GetActiveTaskByChatID возвращает последнюю не-терминальную задачу чата.
|
||
// Терминальные статусы: success, cancelled, aborted, closed.
|
||
func (s *Storage) GetActiveTaskByChatID(ctx context.Context, chatID string) (*Task, error) {
|
||
t := &Task{}
|
||
var reposStr string
|
||
var stepsStr string
|
||
err := s.db.QueryRowContext(ctx, `
|
||
SELECT id, chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at
|
||
FROM tasks
|
||
WHERE chat_id = ? AND status NOT IN ('success','cancelled','aborted','closed')
|
||
ORDER BY updated_at DESC LIMIT 1`, chatID).Scan(
|
||
&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr,
|
||
&t.Why, &t.AC, &stepsStr, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt)
|
||
if err == sql.ErrNoRows {
|
||
return nil, fmt.Errorf("%w: no active task for chat %s", ErrNotFound, chatID)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: get active task %s: %w", ErrDB, chatID, err)
|
||
}
|
||
t.SetReposFromDB(reposStr)
|
||
t.SetStepsFromDB(stepsStr)
|
||
return t, nil
|
||
}
|
||
|
||
// ListTasks возвращает задачи по фильтру.
|
||
func (s *Storage) ListTasks(ctx context.Context, filter TaskFilter) ([]*Task, error) {
|
||
if filter.Limit <= 0 {
|
||
filter.Limit = 50
|
||
}
|
||
where := "1=1"
|
||
args := []any{}
|
||
if filter.ChatID != "" {
|
||
where += " AND chat_id = ?"
|
||
args = append(args, filter.ChatID)
|
||
}
|
||
if filter.Status != "" {
|
||
where += " AND status = ?"
|
||
args = append(args, string(filter.Status))
|
||
}
|
||
args = append(args, filter.Limit, filter.Offset)
|
||
|
||
rows, err := s.db.QueryContext(ctx, `
|
||
SELECT id, chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at
|
||
FROM tasks WHERE `+where+` ORDER BY updated_at DESC LIMIT ? OFFSET ?`, args...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: list tasks: %w", ErrDB, err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var tasks []*Task
|
||
for rows.Next() {
|
||
t := &Task{}
|
||
var reposStr string
|
||
var stepsStr string
|
||
if err := rows.Scan(&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr,
|
||
&t.Why, &t.AC, &stepsStr, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||
return nil, fmt.Errorf("%w: scan task: %w", ErrDB, err)
|
||
}
|
||
t.SetReposFromDB(reposStr)
|
||
t.SetStepsFromDB(stepsStr)
|
||
tasks = append(tasks, t)
|
||
}
|
||
return tasks, rows.Err()
|
||
}
|
||
|
||
// DeleteTask удаляет задачу (каскадно — трассы). Только для тестов/админки.
|
||
func (s *Storage) DeleteTask(ctx context.Context, id int64) error {
|
||
res, err := s.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id)
|
||
if err != nil {
|
||
return fmt.Errorf("%w: delete task %d: %w", ErrDB, id, err)
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
return fmt.Errorf("%w: task %d", ErrNotFound, id)
|
||
}
|
||
return nil
|
||
}
|