512 lines
15 KiB
Go
512 lines
15 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"g.fc.run/ai/byre/internal/api"
|
|
"g.fc.run/ai/byre/internal/workflow"
|
|
)
|
|
|
|
const reviewColumns = `id, task_id, snapshot_n, stage, coalesce(agent_session_id, ''), vendor, model, state, result, error, created_at, finished_at, finding_states, finding_responses`
|
|
|
|
func (s *Store) CreateReview(ctx context.Context, r api.Review, definitionID string) error {
|
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
|
return insertReview(ctx, tx, emit, r, definitionID)
|
|
})
|
|
}
|
|
|
|
func insertReview(ctx context.Context, tx *sql.Tx, emit emitFunc, r api.Review, definitionID string) error {
|
|
finishedAt := ""
|
|
if r.FinishedAt != nil {
|
|
finishedAt = formatTime(*r.FinishedAt)
|
|
}
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO reviews (id, task_id, snapshot_n, stage, definition_id, agent_session_id, vendor, model, state, error, created_at, finished_at)
|
|
VALUES (?, ?, ?, ?, nullif(?, ''), nullif(?, ''), ?, ?, ?, ?, ?, ?)
|
|
`, r.Id, r.TaskId, r.SnapshotN, r.Stage, definitionID, r.AgentSessionId, r.Vendor, r.Model, string(r.State), r.Error, formatTime(r.CreatedAt), finishedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.State == api.ReviewStateRunning {
|
|
return emitReview(ctx, tx, emit, "review.started", r.Id, "byre")
|
|
}
|
|
return emitReview(ctx, tx, emit, "review.finished", r.Id, "byre")
|
|
}
|
|
|
|
type Reviewer struct {
|
|
Session api.AgentSession
|
|
Review api.Review
|
|
}
|
|
|
|
func (s *Store) StartReviewers(ctx context.Context, projectID, definitionID string, reviewers []Reviewer, failed []api.Review) error {
|
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
|
for _, r := range failed {
|
|
err := insertReview(ctx, tx, emit, r, definitionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, r := range reviewers {
|
|
r.Session.State = api.AgentSessionStateRunning
|
|
err := insertSession(ctx, tx, emit, r.Session, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.Review.State = api.ReviewStateRunning
|
|
r.Review.AgentSessionId = r.Session.Id
|
|
err = insertReview(ctx, tx, emit, r.Review, definitionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Store) FinishSettling(ctx context.Context, taskID string, n int) error {
|
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
|
_, err := tx.ExecContext(ctx, `UPDATE snapshots SET settle_state = 'done' WHERE task_id = ? AND n = ?`, taskID, n)
|
|
return err
|
|
})
|
|
}
|
|
|
|
type SnapshotRef struct {
|
|
TaskID string
|
|
N int
|
|
}
|
|
|
|
type StageStart struct {
|
|
TaskID string
|
|
SnapshotN int
|
|
DefinitionID string
|
|
Definition api.WorkflowDefinition
|
|
Stage string
|
|
Inputs []api.Review
|
|
}
|
|
|
|
type EndedReviewer struct {
|
|
ReviewID string
|
|
SessionID string
|
|
ProjectID string
|
|
RepoPath string
|
|
}
|
|
|
|
const currentReview = `(SELECT max(sn.n) FROM snapshots sn WHERE sn.task_id = tasks.id AND sn.review = 1)`
|
|
|
|
func endReviews(ctx context.Context, tx *sql.Tx, emit emitFunc) ([]EndedReviewer, error) {
|
|
type running struct {
|
|
EndedReviewer
|
|
n int
|
|
current int
|
|
active bool
|
|
state string
|
|
}
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT reviews.id, coalesce(reviews.agent_session_id, ''), tasks.project_id, (SELECT p.repo_path FROM projects p WHERE p.id = tasks.project_id),
|
|
reviews.snapshot_n, coalesce(`+currentReview+`, 0), tasks.status = 'active',
|
|
coalesce((SELECT s.state FROM agent_sessions s WHERE s.id = reviews.agent_session_id), '')
|
|
FROM reviews JOIN tasks ON tasks.id = reviews.task_id
|
|
WHERE reviews.state = 'running'`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
found := []running{}
|
|
for rows.Next() {
|
|
r := running{}
|
|
err = rows.Scan(&r.ReviewID, &r.SessionID, &r.ProjectID, &r.RepoPath, &r.n, &r.current, &r.active, &r.state)
|
|
if err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
found = append(found, r)
|
|
}
|
|
err = errors.Join(rows.Err(), rows.Close())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ended := []EndedReviewer{}
|
|
for _, r := range found {
|
|
reason := ""
|
|
switch {
|
|
case !r.active:
|
|
reason = "the task was closed"
|
|
case r.n < r.current:
|
|
reason = fmt.Sprintf("superseded by snapshot %d", r.current)
|
|
case r.state != string(api.AgentSessionStateRunning):
|
|
reason = "the reviewer stopped before submitting a review"
|
|
default:
|
|
continue
|
|
}
|
|
err = finishReview(ctx, tx, emit, r.ReviewID, api.ReviewStateFailed, "", reason)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.state != "" && r.state != string(api.AgentSessionStateCompleted) {
|
|
err = transitionSessionTx(ctx, tx, emit, r.SessionID, nil, api.AgentSessionStateCompleted, "byre", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
ended = append(ended, r.EndedReviewer)
|
|
}
|
|
return ended, nil
|
|
}
|
|
|
|
func startableReviews(ctx context.Context, tx *sql.Tx) ([]SnapshotRef, error) {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT c.id, c.n FROM (SELECT tasks.id, `+currentReview+` AS n FROM tasks WHERE status = 'active') c
|
|
WHERE c.n IS NOT NULL AND NOT EXISTS (SELECT 1 FROM reviews r WHERE r.task_id = c.id AND r.snapshot_n >= c.n)`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanSnapshotRefs(rows)
|
|
}
|
|
|
|
func scanSnapshotRefs(rows *sql.Rows) ([]SnapshotRef, error) {
|
|
defer rows.Close()
|
|
|
|
refs := []SnapshotRef{}
|
|
for rows.Next() {
|
|
r := SnapshotRef{}
|
|
err := rows.Scan(&r.TaskID, &r.N)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs = append(refs, r)
|
|
}
|
|
return refs, rows.Err()
|
|
}
|
|
|
|
func pendingStages(ctx context.Context, tx *sql.Tx) ([]StageStart, error) {
|
|
rows, err := tx.QueryContext(ctx, `SELECT `+reviewColumns+`, coalesce(definition_id, '') FROM reviews
|
|
WHERE (task_id, snapshot_n) IN (SELECT tasks.id, `+currentReview+` FROM tasks WHERE status = 'active')`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
type defined struct {
|
|
review api.Review
|
|
defID string
|
|
}
|
|
found := []defined{}
|
|
for rows.Next() {
|
|
d := defined{}
|
|
d.review, err = scanReview(trailingScanner{rows, []any{&d.defID}})
|
|
if err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
found = append(found, d)
|
|
}
|
|
err = errors.Join(rows.Err(), rows.Close())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
byTask := map[string]map[string]api.Review{}
|
|
for _, d := range found {
|
|
if byTask[d.review.TaskId] == nil {
|
|
byTask[d.review.TaskId] = map[string]api.Review{}
|
|
}
|
|
byTask[d.review.TaskId][d.review.Stage] = d.review
|
|
}
|
|
defs := map[string]api.WorkflowDefinition{}
|
|
starts := []StageStart{}
|
|
seen := map[string]bool{}
|
|
for _, d := range found {
|
|
if d.review.State != api.ReviewStateDone || d.defID == "" {
|
|
continue
|
|
}
|
|
def, loaded := defs[d.defID]
|
|
if !loaded {
|
|
def, err = loadDefinition(ctx, tx, d.defID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defs[d.defID] = def
|
|
}
|
|
byStage := byTask[d.review.TaskId]
|
|
for _, next := range workflow.Next(def, d.review.Stage) {
|
|
key := d.review.TaskId + "\x00" + next
|
|
if _, started := byStage[next]; started || seen[key] {
|
|
continue
|
|
}
|
|
inputs := []api.Review{}
|
|
after := workflow.After(def, next)
|
|
for _, a := range after {
|
|
in := byStage[a]
|
|
if in.State != api.ReviewStateDone {
|
|
break
|
|
}
|
|
inputs = append(inputs, in)
|
|
}
|
|
if len(inputs) != len(after) {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
starts = append(starts, StageStart{TaskID: d.review.TaskId, SnapshotN: d.review.SnapshotN, DefinitionID: d.defID, Definition: def, Stage: next, Inputs: inputs})
|
|
}
|
|
}
|
|
return starts, nil
|
|
}
|
|
|
|
func settleReviews(ctx context.Context, tx *sql.Tx, pending []StageStart) ([]SnapshotRef, error) {
|
|
waiting := map[SnapshotRef]bool{}
|
|
for _, p := range pending {
|
|
waiting[SnapshotRef{TaskID: p.TaskID, N: p.SnapshotN}] = true
|
|
_, err := tx.ExecContext(ctx, `UPDATE snapshots SET settle_state = '' WHERE task_id = ? AND n = ?`, p.TaskID, p.SnapshotN)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT sn.task_id, sn.n FROM tasks JOIN snapshots sn ON sn.task_id = tasks.id
|
|
WHERE tasks.status = 'active' AND sn.settle_state = ''
|
|
AND EXISTS (SELECT 1 FROM reviews r WHERE r.task_id = sn.task_id AND r.snapshot_n = sn.n)
|
|
AND NOT EXISTS (SELECT 1 FROM reviews r WHERE r.task_id = sn.task_id AND r.snapshot_n = sn.n AND r.state = 'running')`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
settled, err := scanSnapshotRefs(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, ref := range settled {
|
|
if waiting[ref] {
|
|
continue
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE snapshots SET settle_state = 'running' WHERE task_id = ? AND n = ?`, ref.TaskID, ref.N)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
rows, err = tx.QueryContext(ctx, `
|
|
SELECT sn.task_id, sn.n FROM tasks JOIN snapshots sn ON sn.task_id = tasks.id
|
|
WHERE tasks.status = 'active' AND sn.settle_state = 'running'`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanSnapshotRefs(rows)
|
|
}
|
|
|
|
func (s *Store) GetReview(ctx context.Context, id string) (api.Review, error) {
|
|
r, err := scanReview(s.db.QueryRowContext(ctx, `SELECT `+reviewColumns+` FROM reviews WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return r, ErrNotFound
|
|
}
|
|
return r, err
|
|
}
|
|
|
|
func (s *Store) ReviewBySession(ctx context.Context, sessionID string) (api.Review, error) {
|
|
r, err := scanReview(s.db.QueryRowContext(ctx, `SELECT `+reviewColumns+` FROM reviews WHERE agent_session_id = ?`, sessionID))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return r, ErrNotFound
|
|
}
|
|
return r, err
|
|
}
|
|
|
|
func (s *Store) ListReviews(ctx context.Context, taskID string) ([]api.Review, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT `+reviewColumns+` FROM reviews WHERE task_id = ? ORDER BY snapshot_n, created_at`, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanReviews(rows)
|
|
}
|
|
|
|
func (s *Store) SnapshotReviews(ctx context.Context, taskID string, n int) ([]api.Review, error) {
|
|
return snapshotReviews(ctx, s.db, taskID, n)
|
|
}
|
|
|
|
func snapshotReviews(ctx context.Context, q querier, taskID string, n int) ([]api.Review, error) {
|
|
rows, err := q.QueryContext(ctx, `SELECT `+reviewColumns+` FROM reviews WHERE task_id = ? AND snapshot_n = ? ORDER BY created_at`, taskID, n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanReviews(rows)
|
|
}
|
|
|
|
func (s *Store) FinishReview(ctx context.Context, id string, state api.ReviewState, result map[string]any, errText string) error {
|
|
text := ""
|
|
if result != nil {
|
|
b, err := json.Marshal(result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
text = string(b)
|
|
}
|
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
|
return finishReview(ctx, tx, emit, id, state, text, errText)
|
|
})
|
|
}
|
|
|
|
func finishReview(ctx context.Context, tx *sql.Tx, emit emitFunc, id string, state api.ReviewState, result, errText string) error {
|
|
res, err := tx.ExecContext(ctx, `UPDATE reviews SET state = ?, result = ?, error = ?, finished_at = ? WHERE id = ? AND state = ?`,
|
|
string(state), result, errText, now(), id, string(api.ReviewStateRunning))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return emitReview(ctx, tx, emit, "review.finished", id, "byre")
|
|
}
|
|
|
|
func (s *Store) SetFindingState(ctx context.Context, id string, index int, state api.FindingState, actor string) error {
|
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
|
res, err := tx.ExecContext(ctx, `
|
|
UPDATE reviews SET finding_states = json_set(finding_states, '$."' || ? || '"', ?)
|
|
WHERE id = ? AND state = ? AND ? < json_array_length(nullif(result, ''), '$.findings')
|
|
`, index, string(state), id, string(api.ReviewStateDone), index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return emitReview(ctx, tx, emit, "review.finding", id, actor)
|
|
})
|
|
}
|
|
|
|
func (s *Store) SetFindingResponse(ctx context.Context, id string, index int, resp api.FindingResponse, actor string) error {
|
|
b, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
|
res, err := tx.ExecContext(ctx, `
|
|
UPDATE reviews SET
|
|
finding_responses = json_set(finding_responses, '$."' || ?1 || '"', json(?2)),
|
|
finding_states = CASE WHEN json_extract(finding_states, '$."' || ?1 || '"') = ?3
|
|
THEN json_remove(finding_states, '$."' || ?1 || '"') ELSE finding_states END
|
|
WHERE id = ?4 AND state = ?5 AND ?1 < json_array_length(nullif(result, ''), '$.findings')
|
|
`, index, string(b), string(api.FindingFixed), id, string(api.ReviewStateDone))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return emitReview(ctx, tx, emit, "review.finding", id, actor)
|
|
})
|
|
}
|
|
|
|
func (s *Store) ReviewerSessionIDs(ctx context.Context, taskID string) (map[string]bool, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id FROM agent_sessions WHERE task_id = ? AND role = ?`, taskID, string(api.Reviewer))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
ids := map[string]bool{}
|
|
for rows.Next() {
|
|
id := ""
|
|
err = rows.Scan(&id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids[id] = true
|
|
}
|
|
return ids, rows.Err()
|
|
}
|
|
|
|
func (s *Store) FirstTaskMessage(ctx context.Context, taskID string) (string, error) {
|
|
text := ""
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT coalesce(json_extract(payload, '$.text'), '') FROM events
|
|
WHERE task_id = ? AND kind = 'user.message'
|
|
AND agent_session_id IN (SELECT id FROM agent_sessions WHERE task_id = ? AND role = ?)
|
|
ORDER BY seq LIMIT 1
|
|
`, taskID, taskID, string(api.Implementer)).Scan(&text)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return text, err
|
|
}
|
|
|
|
func emitReview(ctx context.Context, tx *sql.Tx, emit emitFunc, kind, id, actor string) error {
|
|
r, err := scanReview(tx.QueryRowContext(ctx, `SELECT `+reviewColumns+` FROM reviews WHERE id = ?`, id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
projectID := ""
|
|
err = tx.QueryRowContext(ctx, `SELECT project_id FROM tasks WHERE id = ?`, r.TaskId).Scan(&projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return emit(api.Event{Kind: kind, ProjectId: &projectID, TaskId: &r.TaskId, Actor: actor, Payload: ToPayload(r)})
|
|
}
|
|
|
|
func scanReviews(rows *sql.Rows) ([]api.Review, error) {
|
|
defer rows.Close()
|
|
|
|
reviews := []api.Review{}
|
|
for rows.Next() {
|
|
r, err := scanReview(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reviews = append(reviews, r)
|
|
}
|
|
return reviews, rows.Err()
|
|
}
|
|
|
|
func scanReview(row scanner) (api.Review, error) {
|
|
r := api.Review{}
|
|
state := ""
|
|
result := ""
|
|
createdAt := ""
|
|
finishedAt := ""
|
|
findingStates := ""
|
|
findingResponses := ""
|
|
err := row.Scan(&r.Id, &r.TaskId, &r.SnapshotN, &r.Stage, &r.AgentSessionId, &r.Vendor, &r.Model, &state, &result, &r.Error, &createdAt, &finishedAt, &findingStates, &findingResponses)
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
r.State = api.ReviewState(state)
|
|
r.FindingStates = map[string]api.FindingState{}
|
|
err = json.Unmarshal([]byte(findingStates), &r.FindingStates)
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
r.FindingResponses = map[string]api.FindingResponse{}
|
|
err = json.Unmarshal([]byte(findingResponses), &r.FindingResponses)
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
if result != "" {
|
|
res := map[string]any{}
|
|
err = json.Unmarshal([]byte(result), &res)
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
r.Result = &res
|
|
}
|
|
r.CreatedAt, err = parseTime(createdAt)
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
if finishedAt != "" {
|
|
var t time.Time
|
|
t, err = parseTime(finishedAt)
|
|
r.FinishedAt = &t
|
|
}
|
|
return r, err
|
|
}
|