8f48dd838b
Replace task statuses (pending/running/succeeded/failed/cancelled) with
a kanban workflow: todo → in_progress → in_review → done/wontdo.
When a non-review agent task completes, the API auto-creates a child
review task and moves the parent to in_review. Only humans can move
tasks from in_review to done/wontdo via the TUI.
New components:
- cmd/tui: bubbletea kanban board with $EDITOR integration
- POST /api/v1/tasks/{id}/complete: agent completion callback
- Operator --api-url flag for completion callbacks
- ProviderQueue sets tasks to in_progress on pickup
- AgentTask reconciler calls /complete on job finish
244 lines
6.7 KiB
Go
244 lines
6.7 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"git.unkin.net/unkin/forgebot/pkg/models"
|
|
)
|
|
|
|
func (db *DB) CreateTask(ctx context.Context, req models.CreateTaskRequest) (*models.Task, error) {
|
|
task := &models.Task{
|
|
Command: req.Command,
|
|
Skill: req.Skill,
|
|
Repository: req.Repository,
|
|
Ref: req.Ref,
|
|
IssueNumber: req.IssueNumber,
|
|
PRNumber: req.PRNumber,
|
|
CommentID: req.CommentID,
|
|
Body: req.Body,
|
|
Author: req.Author,
|
|
ExtraTools: req.ExtraTools,
|
|
ParentTaskID: req.ParentTaskID,
|
|
PoolRef: req.PoolRef,
|
|
Status: models.StatusTodo,
|
|
}
|
|
if task.ExtraTools == nil {
|
|
task.ExtraTools = []string{}
|
|
}
|
|
|
|
err := db.Pool.QueryRow(ctx, `
|
|
INSERT INTO tasks (
|
|
parent_task_id, command, skill, repository, ref,
|
|
issue_number, pr_number, comment_id, body, author,
|
|
extra_tools, pool_ref
|
|
) VALUES (
|
|
NULLIF($1, '')::uuid, $2, $3, $4, $5,
|
|
$6, $7, $8, $9, $10,
|
|
$11, $12
|
|
) RETURNING id, created_at`,
|
|
task.ParentTaskID, task.Command, task.Skill, task.Repository, task.Ref,
|
|
task.IssueNumber, task.PRNumber, task.CommentID, task.Body, task.Author,
|
|
task.ExtraTools, task.PoolRef,
|
|
).Scan(&task.ID, &task.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func (db *DB) GetTask(ctx context.Context, id string) (*models.Task, error) {
|
|
task := &models.Task{}
|
|
var parentID *string
|
|
var startedAt, completedAt *time.Time
|
|
|
|
err := db.Pool.QueryRow(ctx, `
|
|
SELECT id, parent_task_id, command, skill, repository, ref,
|
|
issue_number, pr_number, comment_id, body, author,
|
|
extra_tools, status, pool_ref, job_name, result, error_message,
|
|
created_at, started_at, completed_at
|
|
FROM tasks WHERE id = $1`, id,
|
|
).Scan(
|
|
&task.ID, &parentID, &task.Command, &task.Skill, &task.Repository, &task.Ref,
|
|
&task.IssueNumber, &task.PRNumber, &task.CommentID, &task.Body, &task.Author,
|
|
&task.ExtraTools, &task.Status, &task.PoolRef, &task.JobName, &task.Result, &task.ErrorMessage,
|
|
&task.CreatedAt, &startedAt, &completedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if parentID != nil {
|
|
task.ParentTaskID = *parentID
|
|
}
|
|
task.StartedAt = startedAt
|
|
task.CompletedAt = completedAt
|
|
return task, nil
|
|
}
|
|
|
|
func (db *DB) ListPendingTasks(ctx context.Context) ([]models.Task, error) {
|
|
return db.listTasksByStatus(ctx, string(models.StatusTodo))
|
|
}
|
|
|
|
func (db *DB) listTasksByStatus(ctx context.Context, status string) ([]models.Task, error) {
|
|
rows, err := db.Pool.Query(ctx, `
|
|
SELECT id, parent_task_id, command, skill, repository, ref,
|
|
issue_number, pr_number, comment_id, body, author,
|
|
extra_tools, status, pool_ref, job_name, result, error_message,
|
|
created_at, started_at, completed_at
|
|
FROM tasks WHERE status = $1
|
|
ORDER BY created_at ASC`, status,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanTasks(rows)
|
|
}
|
|
|
|
func (db *DB) ListTasks(ctx context.Context, status string, repository string) ([]models.Task, error) {
|
|
query := `SELECT id, parent_task_id, command, skill, repository, ref,
|
|
issue_number, pr_number, comment_id, body, author,
|
|
extra_tools, status, pool_ref, job_name, result, error_message,
|
|
created_at, started_at, completed_at
|
|
FROM tasks WHERE 1=1`
|
|
args := []any{}
|
|
argIdx := 1
|
|
|
|
if status != "" {
|
|
query += " AND status = $" + itoa(argIdx)
|
|
args = append(args, status)
|
|
argIdx++
|
|
}
|
|
if repository != "" {
|
|
query += " AND repository = $" + itoa(argIdx)
|
|
args = append(args, repository)
|
|
argIdx++
|
|
}
|
|
query += " ORDER BY created_at DESC LIMIT 100"
|
|
|
|
rows, err := db.Pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanTasks(rows)
|
|
}
|
|
|
|
func (db *DB) UpdateTaskStatus(ctx context.Context, id string, req models.UpdateTaskRequest) error {
|
|
if req.Status == models.StatusInProgress {
|
|
_, err := db.Pool.Exec(ctx, `
|
|
UPDATE tasks SET status = $2, job_name = COALESCE(NULLIF($3, ''), job_name), started_at = NOW()
|
|
WHERE id = $1`, id, req.Status, req.JobName)
|
|
return err
|
|
}
|
|
if req.Status == models.StatusDone || req.Status == models.StatusWontdo {
|
|
_, err := db.Pool.Exec(ctx, `
|
|
UPDATE tasks SET status = $2, result = COALESCE(NULLIF($3, ''), result),
|
|
error_message = COALESCE(NULLIF($4, ''), error_message), completed_at = NOW()
|
|
WHERE id = $1`, id, req.Status, req.Message, req.ErrorMessage)
|
|
return err
|
|
}
|
|
_, err := db.Pool.Exec(ctx, `UPDATE tasks SET status = $2 WHERE id = $1`, id, req.Status)
|
|
return err
|
|
}
|
|
|
|
func (db *DB) CompleteTask(ctx context.Context, id string, req models.CompleteTaskRequest) (*models.Task, error) {
|
|
task, err := db.GetTask(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if req.ErrorMessage != "" {
|
|
_, err := db.Pool.Exec(ctx, `
|
|
UPDATE tasks SET status = 'todo', error_message = $2, completed_at = NOW()
|
|
WHERE id = $1`, id, req.ErrorMessage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
task.Status = models.StatusTodo
|
|
task.ErrorMessage = req.ErrorMessage
|
|
return task, nil
|
|
}
|
|
|
|
if req.Result != "" {
|
|
_, err := db.Pool.Exec(ctx, `
|
|
UPDATE tasks SET result = $2 WHERE id = $1`, id, req.Result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
task.Result = req.Result
|
|
}
|
|
|
|
if task.Command != "review" {
|
|
_, err := db.Pool.Exec(ctx, `
|
|
UPDATE tasks SET status = 'in_review', completed_at = NOW()
|
|
WHERE id = $1`, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
task.Status = models.StatusInReview
|
|
|
|
reviewTask := models.CreateTaskRequest{
|
|
Command: "review",
|
|
Repository: task.Repository,
|
|
Ref: task.Ref,
|
|
IssueNumber: task.IssueNumber,
|
|
PRNumber: task.PRNumber,
|
|
Body: task.Body,
|
|
Author: task.Author,
|
|
ParentTaskID: task.ID,
|
|
PoolRef: task.PoolRef,
|
|
}
|
|
if _, err := db.CreateTask(ctx, reviewTask); err != nil {
|
|
return nil, fmt.Errorf("create review task: %w", err)
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
_, err = db.Pool.Exec(ctx, `
|
|
UPDATE tasks SET status = 'in_review', completed_at = NOW()
|
|
WHERE id = $1`, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
task.Status = models.StatusInReview
|
|
return task, nil
|
|
}
|
|
|
|
func scanTasks(rows pgx.Rows) ([]models.Task, error) {
|
|
var tasks []models.Task
|
|
for rows.Next() {
|
|
var task models.Task
|
|
var parentID *string
|
|
var startedAt, completedAt *time.Time
|
|
|
|
err := rows.Scan(
|
|
&task.ID, &parentID, &task.Command, &task.Skill, &task.Repository, &task.Ref,
|
|
&task.IssueNumber, &task.PRNumber, &task.CommentID, &task.Body, &task.Author,
|
|
&task.ExtraTools, &task.Status, &task.PoolRef, &task.JobName, &task.Result, &task.ErrorMessage,
|
|
&task.CreatedAt, &startedAt, &completedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if parentID != nil {
|
|
task.ParentTaskID = *parentID
|
|
}
|
|
task.StartedAt = startedAt
|
|
task.CompletedAt = completedAt
|
|
tasks = append(tasks, task)
|
|
}
|
|
return tasks, rows.Err()
|
|
}
|
|
|
|
func itoa(i int) string {
|
|
return strconv.Itoa(i)
|
|
}
|