internal/storage: SQLite-БД для задач и трассировок (таски + статус-машина, трассы с группировкой по task_id)
All checks were successful
build-test / build (push) Successful in 30s
All checks were successful
build-test / build (push) Successful in 30s
This commit is contained in:
21
internal/storage/errors.go
Normal file
21
internal/storage/errors.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Классы ошибок Ratatoskr-стиля: S1–S5
|
||||||
|
var (
|
||||||
|
// S1 — сущность не найдена
|
||||||
|
ErrNotFound = errors.New("S1: task not found")
|
||||||
|
|
||||||
|
// S2 — дубликат (task_tag, title+chat_id)
|
||||||
|
ErrDuplicate = errors.New("S2: duplicate entity")
|
||||||
|
|
||||||
|
// S3 — невалидный переход статуса
|
||||||
|
ErrInvalidStatus = errors.New("S3: invalid status transition")
|
||||||
|
|
||||||
|
// S4 — внутренняя ошибка БД
|
||||||
|
ErrDB = errors.New("S4: database error")
|
||||||
|
|
||||||
|
// S5 — трасса не найдена
|
||||||
|
ErrTraceNotFound = errors.New("S5: trace not found")
|
||||||
|
)
|
||||||
104
internal/storage/models.go
Normal file
104
internal/storage/models.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// Status — статус задачи (state machine).
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusDraft Status = "draft" // только что создана
|
||||||
|
StatusCollecting Status = "collecting" // аналитик собирает детали
|
||||||
|
StatusReady Status = "ready" // черновик готов, ждёт запуска
|
||||||
|
StatusRunning Status = "running" // opencode работает
|
||||||
|
StatusSuccess Status = "success" // задача выполнена
|
||||||
|
StatusFailed Status = "failed" // ошибка выполнения
|
||||||
|
StatusTimeout Status = "timeout" // таймаут opencode
|
||||||
|
StatusCancelled Status = "cancelled" // отменена пользователем
|
||||||
|
StatusAborted Status = "aborted" // сбой сбора, черновик выброшен
|
||||||
|
StatusClosed Status = "closed" // закрыта вручную
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllStatuses — все возможные статусы для валидации.
|
||||||
|
var AllStatuses = []Status{
|
||||||
|
StatusDraft, StatusCollecting, StatusReady,
|
||||||
|
StatusRunning, StatusSuccess, StatusFailed, StatusTimeout,
|
||||||
|
StatusCancelled, StatusAborted, StatusClosed,
|
||||||
|
}
|
||||||
|
|
||||||
|
// validTransitions задаёт разрешённые переходы статусов.
|
||||||
|
var validTransitions = map[Status][]Status{
|
||||||
|
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
|
||||||
|
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
|
||||||
|
StatusReady: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed},
|
||||||
|
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
|
||||||
|
StatusSuccess: {StatusClosed},
|
||||||
|
StatusFailed: {StatusReady, StatusClosed, StatusCancelled}, // retry
|
||||||
|
StatusTimeout: {StatusReady, StatusClosed, StatusCancelled}, // retry
|
||||||
|
StatusCancelled: {StatusClosed},
|
||||||
|
StatusAborted: {StatusClosed},
|
||||||
|
StatusClosed: {}, // терминальный
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidTransition проверяет, допустим ли переход from → to.
|
||||||
|
func IsValidTransition(from, to Status) bool {
|
||||||
|
allowed, ok := validTransitions[from]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, s := range allowed {
|
||||||
|
if s == to {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTerminal возвращает true, если статус терминальный.
|
||||||
|
func IsTerminal(s Status) bool {
|
||||||
|
return s == StatusSuccess || s == StatusCancelled ||
|
||||||
|
s == StatusAborted || s == StatusClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task — запись задачи в БД.
|
||||||
|
type Task struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ChatID string `json:"chat_id"` // tg://<id>
|
||||||
|
Title string `json:"title"`
|
||||||
|
Goal string `json:"goal"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
AC string `json:"ac"` // acceptance criteria
|
||||||
|
TaskTag string `json:"task_tag"` // UUID, стабильный на всю жизнь
|
||||||
|
Status Status `json:"status"`
|
||||||
|
CreatedAt SQLiteTime `json:"created_at"`
|
||||||
|
UpdatedAt SQLiteTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace — запись трассировки выполнения.
|
||||||
|
type TraceStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TraceRunning TraceStatus = "running"
|
||||||
|
TraceSuccess TraceStatus = "success"
|
||||||
|
TraceFailed TraceStatus = "failed"
|
||||||
|
TraceTimeout TraceStatus = "timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trace — лог одного субагента.
|
||||||
|
type Trace struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TaskID int64 `json:"task_id"`
|
||||||
|
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
|
||||||
|
Status TraceStatus `json:"status"`
|
||||||
|
StartedAt SQLiteTime `json:"started_at"`
|
||||||
|
FinishedAt NullSQLiteTime `json:"finished_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskFilter — параметры фильтрации списка задач.
|
||||||
|
type TaskFilter struct {
|
||||||
|
ChatID string
|
||||||
|
Status Status
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
49
internal/storage/models_test.go
Normal file
49
internal/storage/models_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsValidTransition(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
from, to Status
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{StatusDraft, StatusCollecting, true},
|
||||||
|
{StatusDraft, StatusCancelled, true},
|
||||||
|
{StatusDraft, StatusRunning, false},
|
||||||
|
{StatusDraft, StatusClosed, false},
|
||||||
|
{StatusRunning, StatusSuccess, true},
|
||||||
|
{StatusRunning, StatusFailed, true},
|
||||||
|
{StatusRunning, StatusTimeout, true},
|
||||||
|
{StatusSuccess, StatusClosed, true},
|
||||||
|
{StatusSuccess, StatusDraft, false},
|
||||||
|
{StatusFailed, StatusReady, true}, // retry
|
||||||
|
{StatusFailed, StatusClosed, true},
|
||||||
|
{StatusFailed, StatusRunning, false},
|
||||||
|
{StatusTimeout, StatusReady, true}, // retry
|
||||||
|
{StatusClosed, StatusDraft, false},
|
||||||
|
{StatusClosed, StatusRunning, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := IsValidTransition(tc.from, tc.to)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("IsValidTransition(%q → %q) = %v, want %v", tc.from, tc.to, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTerminal(t *testing.T) {
|
||||||
|
if !IsTerminal(StatusSuccess) {
|
||||||
|
t.Error("StatusSuccess should be terminal")
|
||||||
|
}
|
||||||
|
if !IsTerminal(StatusCancelled) {
|
||||||
|
t.Error("StatusCancelled should be terminal")
|
||||||
|
}
|
||||||
|
if IsTerminal(StatusDraft) {
|
||||||
|
t.Error("StatusDraft should NOT be terminal")
|
||||||
|
}
|
||||||
|
if IsTerminal(StatusRunning) {
|
||||||
|
t.Error("StatusRunning should NOT be terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
158
internal/storage/storage.go
Normal file
158
internal/storage/storage.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQLiteTime — time.Time с sql.Scanner и driver.Valuer для modernc.org/sqlite.
|
||||||
|
// Хранит как INTEGER (Unix-секунды).
|
||||||
|
type SQLiteTime time.Time
|
||||||
|
|
||||||
|
func (st *SQLiteTime) Scan(src any) error {
|
||||||
|
if src == nil {
|
||||||
|
*st = SQLiteTime(time.Time{})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := src.(type) {
|
||||||
|
case int64:
|
||||||
|
*st = SQLiteTime(time.Unix(v, 0).UTC())
|
||||||
|
return nil
|
||||||
|
case float64:
|
||||||
|
*st = SQLiteTime(time.Unix(int64(v), 0).UTC())
|
||||||
|
return nil
|
||||||
|
case string:
|
||||||
|
// fallback: RFC3339
|
||||||
|
t, err := time.Parse(time.RFC3339, v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse time %q: %w", v, err)
|
||||||
|
}
|
||||||
|
*st = SQLiteTime(t)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot scan %T as SQLiteTime", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st SQLiteTime) Value() (driver.Value, error) {
|
||||||
|
return time.Time(st).Unix(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st SQLiteTime) Time() time.Time {
|
||||||
|
return time.Time(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NullSQLiteTime — nullable версия SQLiteTime.
|
||||||
|
type NullSQLiteTime struct {
|
||||||
|
Time SQLiteTime
|
||||||
|
Valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NullSQLiteTime) Scan(src any) error {
|
||||||
|
if src == nil {
|
||||||
|
n.Valid = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n.Valid = true
|
||||||
|
return n.Time.Scan(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n NullSQLiteTime) Value() (driver.Value, error) {
|
||||||
|
if !n.Valid {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return n.Time.Value()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage — компонент БД для задач и трассировок.
|
||||||
|
type Storage struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open открывает SQLite-БД и выполняет миграции.
|
||||||
|
// path — путь к файлу БД; ":memory:" для тестов.
|
||||||
|
func Open(ctx context.Context, path string) (*Storage, error) {
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: open: %w", ErrDB, err)
|
||||||
|
}
|
||||||
|
// Прагмы: WAL + синхронность
|
||||||
|
pragmas := []string{
|
||||||
|
"PRAGMA journal_mode=WAL",
|
||||||
|
"PRAGMA synchronous=NORMAL",
|
||||||
|
"PRAGMA busy_timeout=5000",
|
||||||
|
"PRAGMA foreign_keys=ON",
|
||||||
|
}
|
||||||
|
for _, p := range pragmas {
|
||||||
|
if _, err := db.ExecContext(ctx, p); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("%w: pragma %q: %w", ErrDB, p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := &Storage{db: db}
|
||||||
|
if err := s.migrate(ctx); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает БД.
|
||||||
|
func (s *Storage) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB возвращает сырой *sql.DB для использования в транзакциях.
|
||||||
|
func (s *Storage) DB() *sql.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrate создаёт таблицы при первом запуске.
|
||||||
|
func (s *Storage) migrate(ctx context.Context) error {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chat_id TEXT NOT NULL DEFAULT '',
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
goal TEXT NOT NULL DEFAULT '',
|
||||||
|
repo TEXT NOT NULL DEFAULT '',
|
||||||
|
why TEXT NOT NULL DEFAULT '',
|
||||||
|
ac TEXT NOT NULL DEFAULT '',
|
||||||
|
task_tag TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_chat ON tasks(chat_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
agent TEXT NOT NULL DEFAULT '',
|
||||||
|
session_id TEXT NOT NULL DEFAULT '',
|
||||||
|
prompt TEXT NOT NULL DEFAULT '',
|
||||||
|
output TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
started_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
finished_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traces_task ON traces(task_id);
|
||||||
|
`
|
||||||
|
if _, err := s.db.ExecContext(ctx, schema); err != nil {
|
||||||
|
return fmt.Errorf("%w: migrate: %w", ErrDB, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now возвращает текущее время UTC как SQLiteTime.
|
||||||
|
func Now() SQLiteTime {
|
||||||
|
return SQLiteTime(time.Now().UTC())
|
||||||
|
}
|
||||||
239
internal/storage/storage_test.go
Normal file
239
internal/storage/storage_test.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestDB(t *testing.T) (*Storage, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
s, err := Open(ctx, ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open(:memory:): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndGetTask(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
task := &Task{
|
||||||
|
ChatID: "tg://123",
|
||||||
|
Title: "Test task",
|
||||||
|
Goal: "Test goal",
|
||||||
|
Repo: "test/repo",
|
||||||
|
TaskTag: "abc-123",
|
||||||
|
}
|
||||||
|
id, err := s.CreateTask(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
t.Fatal("expected non-zero ID")
|
||||||
|
}
|
||||||
|
if task.Status != StatusDraft {
|
||||||
|
t.Fatalf("expected draft status, got %s", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTask: %v", err)
|
||||||
|
}
|
||||||
|
if got.Title != "Test task" {
|
||||||
|
t.Fatalf("title = %q, want %q", got.Title, "Test task")
|
||||||
|
}
|
||||||
|
if got.Status != StatusDraft {
|
||||||
|
t.Fatalf("status = %s, want draft", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTaskNotFound(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
_, err := s.GetTask(ctx, 999)
|
||||||
|
if !IsNotFound(err) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTaskStatus(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
task := &Task{
|
||||||
|
ChatID: "tg://123",
|
||||||
|
Title: "Update test",
|
||||||
|
TaskTag: "def-456",
|
||||||
|
}
|
||||||
|
_, _ = s.CreateTask(ctx, task)
|
||||||
|
|
||||||
|
// draft → collecting — валидно
|
||||||
|
task.Status = StatusCollecting
|
||||||
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
|
t.Fatalf("UpdateTask draft→collecting: %v", err)
|
||||||
|
}
|
||||||
|
got, _ := s.GetTask(ctx, task.ID)
|
||||||
|
if got.Status != StatusCollecting {
|
||||||
|
t.Fatalf("status = %s, want collecting", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collecting → ready
|
||||||
|
task.Status = StatusReady
|
||||||
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
|
t.Fatalf("UpdateTask collecting→ready: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ready → running
|
||||||
|
task.Status = StatusRunning
|
||||||
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
|
t.Fatalf("UpdateTask ready→running: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// running → success
|
||||||
|
task.Status = StatusSuccess
|
||||||
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
|
t.Fatalf("UpdateTask running→success: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTaskInvalidTransition(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
task := &Task{ChatID: "tg://1", TaskTag: "invalid"}
|
||||||
|
_, _ = s.CreateTask(ctx, task)
|
||||||
|
|
||||||
|
task.Status = StatusRunning // draft → running невалидно
|
||||||
|
err := s.UpdateTask(ctx, task)
|
||||||
|
if !IsInvalidStatus(err) {
|
||||||
|
t.Fatalf("expected ErrInvalidStatus, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTaskNotFound(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
err := s.UpdateTask(ctx, &Task{ID: 999, Status: StatusReady})
|
||||||
|
if !IsNotFound(err) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTasks(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_, _ = s.CreateTask(ctx, &Task{
|
||||||
|
ChatID: "tg://user",
|
||||||
|
Title: "Task",
|
||||||
|
TaskTag: "tag",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tasks, err := s.ListTasks(ctx, TaskFilter{ChatID: "tg://user", Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTasks: %v", err)
|
||||||
|
}
|
||||||
|
if len(tasks) != 5 {
|
||||||
|
t.Fatalf("expected 5 tasks, got %d", len(tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTask(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
task := &Task{ChatID: "tg://del", TaskTag: "delete-me"}
|
||||||
|
id, _ := s.CreateTask(ctx, task)
|
||||||
|
|
||||||
|
if err := s.DeleteTask(ctx, id); err != nil {
|
||||||
|
t.Fatalf("DeleteTask: %v", err)
|
||||||
|
}
|
||||||
|
_, err := s.GetTask(ctx, id)
|
||||||
|
if !IsNotFound(err) {
|
||||||
|
t.Fatal("expected ErrNotFound after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendAndGetTraces(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
task := &Task{ChatID: "tg://trace", TaskTag: "trace-test"}
|
||||||
|
id, _ := s.CreateTask(ctx, task)
|
||||||
|
|
||||||
|
tr := &Trace{
|
||||||
|
TaskID: id,
|
||||||
|
Agent: "analyst",
|
||||||
|
SessionID: "sess-001",
|
||||||
|
Prompt: "analyze this",
|
||||||
|
Output: "",
|
||||||
|
}
|
||||||
|
traceID, err := s.AppendTrace(ctx, tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AppendTrace: %v", err)
|
||||||
|
}
|
||||||
|
if traceID == 0 {
|
||||||
|
t.Fatal("expected non-zero trace ID")
|
||||||
|
}
|
||||||
|
if tr.Status != TraceRunning {
|
||||||
|
t.Fatalf("trace status = %s, want running", tr.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// апдейт статуса
|
||||||
|
if err := s.UpdateTraceStatus(ctx, traceID, TraceSuccess); err != nil {
|
||||||
|
t.Fatalf("UpdateTraceStatus: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// добавим вторую трассу
|
||||||
|
_, _ = s.AppendTrace(ctx, &Trace{
|
||||||
|
TaskID: id,
|
||||||
|
Agent: "dev",
|
||||||
|
SessionID: "sess-002",
|
||||||
|
})
|
||||||
|
|
||||||
|
traces, err := s.GetTraces(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTraces: %v", err)
|
||||||
|
}
|
||||||
|
if len(traces) != 2 {
|
||||||
|
t.Fatalf("expected 2 traces, got %d", len(traces))
|
||||||
|
}
|
||||||
|
if traces[0].Status != TraceSuccess {
|
||||||
|
t.Fatalf("first trace status = %s, want success", traces[0].Status)
|
||||||
|
}
|
||||||
|
if traces[1].Agent != "dev" {
|
||||||
|
t.Fatalf("second trace agent = %s, want dev", traces[1].Agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestTrace(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
task := &Task{ChatID: "tg://latest", TaskTag: "latest-trace"}
|
||||||
|
id, _ := s.CreateTask(ctx, task)
|
||||||
|
|
||||||
|
_, _ = s.AppendTrace(ctx, &Trace{TaskID: id, Agent: "analyst", SessionID: "s1"})
|
||||||
|
_, _ = s.AppendTrace(ctx, &Trace{TaskID: id, Agent: "analyst", SessionID: "s2"})
|
||||||
|
|
||||||
|
latest, err := s.GetLatestTrace(ctx, id, "analyst")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestTrace: %v", err)
|
||||||
|
}
|
||||||
|
if latest.SessionID != "s2" {
|
||||||
|
t.Fatalf("latest session = %s, want s2", latest.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTraceOutput(t *testing.T) {
|
||||||
|
s, ctx := setupTestDB(t)
|
||||||
|
task := &Task{ChatID: "tg://out", TaskTag: "output"}
|
||||||
|
id, _ := s.CreateTask(ctx, task)
|
||||||
|
traceID, _ := s.AppendTrace(ctx, &Trace{TaskID: id, Agent: "dev"})
|
||||||
|
|
||||||
|
if err := s.UpdateTraceOutput(ctx, traceID, "hello world"); err != nil {
|
||||||
|
t.Fatalf("UpdateTraceOutput: %v", err)
|
||||||
|
}
|
||||||
|
traces, _ := s.GetTraces(ctx, id)
|
||||||
|
if traces[0].Output != "hello world" {
|
||||||
|
t.Fatalf("output = %q, want %q", traces[0].Output, "hello world")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// хелперы для проверки классов ошибок
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
return errors.Is(err, ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsInvalidStatus(err error) bool {
|
||||||
|
return errors.Is(err, ErrInvalidStatus)
|
||||||
|
}
|
||||||
138
internal/storage/tasks.go
Normal file
138
internal/storage/tasks.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateTask создаёт задачу со статусом draft. Возвращает её ID.
|
||||||
|
// chat_id и task_tag передаются извне (в диалоге — chat.Address + uuid).
|
||||||
|
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, why, ac, task_tag, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
t.ChatID, t.Title, t.Goal, t.Repo, t.Why, t.AC,
|
||||||
|
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{}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, chat_id, title, goal, repo, why, ac, task_tag, status, created_at, updated_at
|
||||||
|
FROM tasks WHERE id = ?`, id).Scan(
|
||||||
|
&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo,
|
||||||
|
&t.Why, &t.AC, &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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
} else if t.Status == "" {
|
||||||
|
t.Status = oldStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
now := Now()
|
||||||
|
res, err := s.db.ExecContext(ctx, `
|
||||||
|
UPDATE tasks
|
||||||
|
SET title=?, goal=?, repo=?, why=?, ac=?, status=?, updated_at=?
|
||||||
|
WHERE id=?`,
|
||||||
|
t.Title, t.Goal, t.Repo, t.Why, t.AC, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, why, ac, 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{}
|
||||||
|
if err := rows.Scan(&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo,
|
||||||
|
&t.Why, &t.AC, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: scan task: %w", ErrDB, err)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
112
internal/storage/traces.go
Normal file
112
internal/storage/traces.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppendTrace добавляет трассу выполнения к задаче. Возвращает её ID.
|
||||||
|
func (s *Storage) AppendTrace(ctx context.Context, tr *Trace) (int64, error) {
|
||||||
|
now := Now()
|
||||||
|
res, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO traces (task_id, agent, session_id, prompt, output, status, started_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
tr.TaskID, tr.Agent, tr.SessionID, tr.Prompt, tr.Output, TraceRunning, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: append trace: %w", ErrDB, err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: last insert id: %w", ErrDB, err)
|
||||||
|
}
|
||||||
|
tr.ID = id
|
||||||
|
tr.Status = TraceRunning
|
||||||
|
tr.StartedAt = now
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTraceStatus обновляет статус и finished_at трассы.
|
||||||
|
func (s *Storage) UpdateTraceStatus(ctx context.Context, traceID int64, status TraceStatus) error {
|
||||||
|
now := Now()
|
||||||
|
res, err := s.db.ExecContext(ctx, `
|
||||||
|
UPDATE traces SET status=?, finished_at=? WHERE id=?`,
|
||||||
|
status, now, traceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: update trace %d: %w", ErrDB, traceID, err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("%w: trace %d", ErrTraceNotFound, traceID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTraceOutput обновляет output трассы (например, по мере поступления данных).
|
||||||
|
func (s *Storage) UpdateTraceOutput(ctx context.Context, traceID int64, output string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE traces SET output=? WHERE id=?`, output, traceID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: update trace output %d: %w", ErrDB, traceID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTraces возвращает трассы для задачи, отсортированные по started_at.
|
||||||
|
func (s *Storage) GetTraces(ctx context.Context, taskID int64) ([]*Trace, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, task_id, agent, session_id, prompt, output, status, started_at, finished_at
|
||||||
|
FROM traces WHERE task_id=? ORDER BY started_at ASC`, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: get traces for task %d: %w", ErrDB, taskID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var traces []*Trace
|
||||||
|
for rows.Next() {
|
||||||
|
tr := &Trace{}
|
||||||
|
var finishedAt NullSQLiteTime
|
||||||
|
if err := rows.Scan(&tr.ID, &tr.TaskID, &tr.Agent, &tr.SessionID,
|
||||||
|
&tr.Prompt, &tr.Output, &tr.Status, &tr.StartedAt, &finishedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: scan trace: %w", ErrDB, err)
|
||||||
|
}
|
||||||
|
tr.FinishedAt = finishedAt
|
||||||
|
traces = append(traces, tr)
|
||||||
|
}
|
||||||
|
return traces, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestTrace возвращает последнюю трассу задачи для заданного агента.
|
||||||
|
func (s *Storage) GetLatestTrace(ctx context.Context, taskID int64, agent string) (*Trace, error) {
|
||||||
|
tr := &Trace{}
|
||||||
|
var finishedAt NullSQLiteTime
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, task_id, agent, session_id, prompt, output, status, started_at, finished_at
|
||||||
|
FROM traces WHERE task_id=? AND agent=? ORDER BY id DESC LIMIT 1`,
|
||||||
|
taskID, agent).Scan(
|
||||||
|
&tr.ID, &tr.TaskID, &tr.Agent, &tr.SessionID,
|
||||||
|
&tr.Prompt, &tr.Output, &tr.Status, &tr.StartedAt, &finishedAt,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("%w: trace for task %d agent %s", ErrTraceNotFound, taskID, agent)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: get latest trace: %w", ErrDB, err)
|
||||||
|
}
|
||||||
|
tr.FinishedAt = finishedAt
|
||||||
|
return tr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTrace удаляет трассу. Только для тестов/админки.
|
||||||
|
func (s *Storage) DeleteTrace(ctx context.Context, id int64) error {
|
||||||
|
res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: delete trace %d: %w", ErrDB, id, err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("%w: trace %d", ErrTraceNotFound, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user