feat(analyst): этапы задачи — аналитик раскладывает на steps с критериями готовности, dev идёт по ним
This commit is contained in:
@@ -14,17 +14,17 @@ type Status = model.Status
|
||||
|
||||
// Статусы задачи — re-export из model.
|
||||
const (
|
||||
StatusDraft = model.StatusDraft
|
||||
StatusCollecting = model.StatusCollecting
|
||||
StatusReady = model.StatusReady
|
||||
StatusApproved = model.StatusApproved
|
||||
StatusRunning = model.StatusRunning
|
||||
StatusSuccess = model.StatusSuccess
|
||||
StatusFailed = model.StatusFailed
|
||||
StatusTimeout = model.StatusTimeout
|
||||
StatusCancelled = model.StatusCancelled
|
||||
StatusAborted = model.StatusAborted
|
||||
StatusClosed = model.StatusClosed
|
||||
StatusDraft = model.StatusDraft
|
||||
StatusCollecting = model.StatusCollecting
|
||||
StatusReady = model.StatusReady
|
||||
StatusApproved = model.StatusApproved
|
||||
StatusRunning = model.StatusRunning
|
||||
StatusSuccess = model.StatusSuccess
|
||||
StatusFailed = model.StatusFailed
|
||||
StatusTimeout = model.StatusTimeout
|
||||
StatusCancelled = model.StatusCancelled
|
||||
StatusAborted = model.StatusAborted
|
||||
StatusClosed = model.StatusClosed
|
||||
)
|
||||
|
||||
// AllStatuses — все возможные статусы для валидации.
|
||||
@@ -40,17 +40,24 @@ func IsTerminal(s Status) bool {
|
||||
return model.IsTerminal(s)
|
||||
}
|
||||
|
||||
// Step — этап задачи с собственным критерием готовности.
|
||||
type Step struct {
|
||||
Title string `json:"title"`
|
||||
AC string `json:"ac"` // acceptance criterion этапа
|
||||
}
|
||||
|
||||
// Task — запись задачи в БД.
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
ChatID string `json:"chat_id"` // tg://<id>
|
||||
ChatID string `json:"chat_id"` // tg://<id>
|
||||
Title string `json:"title"`
|
||||
Goal string `json:"goal"`
|
||||
Repo string `json:"repo"` // обратная совместимость: одиночный репозиторий
|
||||
Repos []string `json:"repos"` // список репозиториев (основной)
|
||||
Repo string `json:"repo"` // обратная совместимость: одиночный репозиторий
|
||||
Repos []string `json:"repos"` // список репозиториев (основной)
|
||||
Why string `json:"why"`
|
||||
AC string `json:"ac"` // acceptance criteria
|
||||
TaskTag string `json:"task_tag"` // UUID, стабильный на всю жизнь
|
||||
AC string `json:"ac"` // acceptance criteria
|
||||
Steps []Step `json:"steps"` // этапы задачи (опционально)
|
||||
TaskTag string `json:"task_tag"` // UUID, стабильный на всю жизнь
|
||||
Status Status `json:"status"`
|
||||
CreatedAt SQLiteTime `json:"created_at"`
|
||||
UpdatedAt SQLiteTime `json:"updated_at"`
|
||||
@@ -88,6 +95,25 @@ func (t *Task) SetReposFromDB(repos string) {
|
||||
_ = json.Unmarshal([]byte(repos), &t.Repos)
|
||||
}
|
||||
|
||||
// StepsJoined возвращает steps как одну строку (JSON-массив) для хранения в БД.
|
||||
// Пустой список → пустая строка.
|
||||
func (t *Task) StepsJoined() string {
|
||||
if len(t.Steps) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(t.Steps)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// SetStepsFromDB заполняет Steps из сохранённой строки (JSON).
|
||||
func (t *Task) SetStepsFromDB(steps string) {
|
||||
if steps == "" {
|
||||
t.Steps = nil
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal([]byte(steps), &t.Steps)
|
||||
}
|
||||
|
||||
// TraceStatus — алиас доменного статуса трассировки.
|
||||
type TraceStatus = model.TraceStatus
|
||||
|
||||
@@ -105,7 +131,7 @@ type Trace struct {
|
||||
Agent string `json:"agent"` // analyst | researcher | dev | reviewer
|
||||
SessionID string `json:"session_id"` // opencode session_id
|
||||
Prompt string `json:"prompt"`
|
||||
Output string `json:"output"` // полный NDJSON или summary
|
||||
Output string `json:"output"` // полный NDJSON или summary
|
||||
Status TraceStatus `json:"status"`
|
||||
StartedAt SQLiteTime `json:"started_at"`
|
||||
FinishedAt NullSQLiteTime `json:"finished_at,omitempty"`
|
||||
@@ -113,8 +139,8 @@ type Trace struct {
|
||||
|
||||
// TaskFilter — параметры фильтрации списка задач.
|
||||
type TaskFilter struct {
|
||||
ChatID string
|
||||
Status Status
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
ChatID string
|
||||
Status Status
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
@@ -173,6 +173,13 @@ func (s *Storage) migrate(ctx context.Context) error {
|
||||
return fmt.Errorf("%w: migrate add repos: %w", ErrDB, err)
|
||||
}
|
||||
}
|
||||
// Доп. колонка steps (этапы задачи). Idempotent.
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`ALTER TABLE tasks ADD COLUMN steps TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
if !isDuplicateColumn(err) {
|
||||
return fmt.Errorf("%w: migrate add steps: %w", ErrDB, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -185,4 +192,4 @@ func isDuplicateColumn(err error) bool {
|
||||
// Now возвращает текущее время UTC как SQLiteTime.
|
||||
func Now() SQLiteTime {
|
||||
return SQLiteTime(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,44 @@ func TestUpdateTaskNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndGetTask_Steps(t *testing.T) {
|
||||
s, ctx := setupTestDB(t)
|
||||
task := &Task{
|
||||
ChatID: "tg://steps",
|
||||
Title: "Steps task",
|
||||
TaskTag: "steps-1",
|
||||
Steps: []Step{
|
||||
{Title: "Реализовать модель", AC: "структура готова"},
|
||||
{Title: "Добавить API", AC: "эндпоинт отвечает"},
|
||||
},
|
||||
}
|
||||
id, err := s.CreateTask(ctx, task)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
if len(got.Steps) != 2 {
|
||||
t.Fatalf("steps len = %d, want 2", len(got.Steps))
|
||||
}
|
||||
if got.Steps[0].Title != "Реализовать модель" || got.Steps[0].AC != "структура готова" {
|
||||
t.Fatalf("steps[0] = %q / %q, want модель / структура готова", got.Steps[0].Title, got.Steps[0].AC)
|
||||
}
|
||||
|
||||
// апдейт этапов
|
||||
got.Steps = append(got.Steps, Step{Title: "Ревью", AC: "пройден review"})
|
||||
if err := s.UpdateTask(ctx, got); err != nil {
|
||||
t.Fatalf("UpdateTask steps: %v", err)
|
||||
}
|
||||
got2, _ := s.GetTask(ctx, id)
|
||||
if len(got2.Steps) != 3 {
|
||||
t.Fatalf("steps len after update = %d, want 3", len(got2.Steps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasks(t *testing.T) {
|
||||
s, ctx := setupTestDB(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
|
||||
@@ -30,10 +30,10 @@ func (s *Storage) CreateTask(ctx context.Context, t *Task) (int64, error) {
|
||||
}
|
||||
now := Now()
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO tasks (chat_id, title, goal, repo, repos, why, ac, task_tag, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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.TaskTag, StatusDraft, now, now,
|
||||
t.StepsJoined(), t.TaskTag, StatusDraft, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: create task: %w", ErrDB, err)
|
||||
@@ -53,11 +53,12 @@ func (s *Storage) CreateTask(ctx context.Context, t *Task) (int64, error) {
|
||||
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, task_tag, status, created_at, updated_at
|
||||
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, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt,
|
||||
&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)
|
||||
@@ -66,6 +67,7 @@ func (s *Storage) GetTask(ctx context.Context, id int64) (*Task, error) {
|
||||
return nil, fmt.Errorf("%w: get task %d: %w", ErrDB, id, err)
|
||||
}
|
||||
t.SetReposFromDB(reposStr)
|
||||
t.SetStepsFromDB(stepsStr)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -94,9 +96,9 @@ func (s *Storage) UpdateTask(ctx context.Context, t *Task) error {
|
||||
now := Now()
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE tasks
|
||||
SET title=?, goal=?, repo=?, repos=?, why=?, ac=?, status=?, updated_at=?
|
||||
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.Status, now, t.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)
|
||||
@@ -114,13 +116,14 @@ func (s *Storage) UpdateTask(ctx context.Context, t *Task) error {
|
||||
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, task_tag, status, created_at, updated_at
|
||||
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, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt)
|
||||
&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)
|
||||
}
|
||||
@@ -128,6 +131,7 @@ func (s *Storage) GetActiveTaskByChatID(ctx context.Context, chatID string) (*Ta
|
||||
return nil, fmt.Errorf("%w: get active task %s: %w", ErrDB, chatID, err)
|
||||
}
|
||||
t.SetReposFromDB(reposStr)
|
||||
t.SetStepsFromDB(stepsStr)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -149,7 +153,7 @@ func (s *Storage) ListTasks(ctx context.Context, filter TaskFilter) ([]*Task, er
|
||||
args = append(args, filter.Limit, filter.Offset)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, chat_id, title, goal, repo, repos, why, ac, task_tag, status, created_at, updated_at
|
||||
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)
|
||||
@@ -160,11 +164,13 @@ func (s *Storage) ListTasks(ctx context.Context, filter TaskFilter) ([]*Task, er
|
||||
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, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
&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()
|
||||
|
||||
Reference in New Issue
Block a user