Add review workflows, tree-shaped workflows and a workflow editor

This commit is contained in:
Ian Gulliver
2026-10-20 18:52:04 -07:00
parent 9869fad988
commit f2188c53eb
31 changed files with 2179 additions and 863 deletions
+258
View File
@@ -0,0 +1,258 @@
package store
import (
"database/sql"
"encoding/json"
"fmt"
"maps"
"slices"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/workflow"
)
type oldStage struct {
Name string `json:"name"`
After *[]string `json:"after"`
Base *string `json:"base"`
Model string `json:"model"`
Prompt string `json:"prompt"`
Agents *[]api.TeamMember `json:"agents"`
}
type oldDefinition struct {
Description string `json:"description"`
Stages []oldStage `json:"stages"`
Steps *json.RawMessage `json:"steps"`
}
func migrateWorkflowSteps(tx *sql.Tx) error {
err := convertColumn(tx, "workflows", "name", "definition")
if err != nil {
return err
}
return convertColumn(tx, "tasks", "id", "workflow_definition")
}
func convertColumn(tx *sql.Tx, table, key, column string) error {
rows, err := tx.Query(fmt.Sprintf(`SELECT %s, %s FROM %s WHERE %s != ''`, key, column, table, column))
if err != nil {
return err
}
type row struct{ key, def string }
all := []row{}
for rows.Next() {
r := row{}
err = rows.Scan(&r.key, &r.def)
if err != nil {
rows.Close()
return err
}
all = append(all, r)
}
rows.Close()
err = rows.Err()
if err != nil {
return err
}
for _, r := range all {
old := oldDefinition{}
err = json.Unmarshal([]byte(r.def), &old)
if err != nil {
return fmt.Errorf("%s %s: %w", table, r.key, err)
}
if old.Steps != nil {
continue
}
def, err := toSteps(old)
if err != nil {
return fmt.Errorf("%s %s: %w", table, r.key, err)
}
j, err := json.Marshal(def)
if err != nil {
return err
}
_, err = tx.Exec(fmt.Sprintf(`UPDATE %s SET %s = ? WHERE %s = ?`, table, column, key), string(j), r.key)
if err != nil {
return err
}
}
return nil
}
func toSteps(old oldDefinition) (api.WorkflowDefinition, error) {
after := map[string][]string{}
byName := map[string]oldStage{}
for i, s := range old.Stages {
if s.Base != nil && *s.Base != "parent" {
return api.WorkflowDefinition{}, fmt.Errorf("stage %q has base %q, which steps can't express", s.Name, *s.Base)
}
byName[s.Name] = s
switch {
case s.After != nil:
after[s.Name] = *s.After
case i == 0:
after[s.Name] = []string{}
default:
after[s.Name] = []string{old.Stages[i-1].Name}
}
}
pending := func(names []string, done map[string]bool) []string {
out := []string{}
for _, n := range names {
if !done[n] {
out = append(out, n)
}
}
return out
}
var closure func(name string, done map[string]bool) map[string]bool
closure = func(name string, done map[string]bool) map[string]bool {
c := map[string]bool{name: true}
for _, a := range pending(after[name], done) {
for n := range closure(a, done) {
c[n] = true
}
}
return c
}
sequence := func(steps ...api.WorkflowStep) api.WorkflowStep {
order := []api.WorkflowStep{}
for _, s := range steps {
if s.Order != nil {
order = append(order, *s.Order...)
continue
}
order = append(order, s)
}
return api.WorkflowStep{Order: &order}
}
var tree func(ends []string, done map[string]bool) (api.WorkflowStep, error)
tree = func(ends []string, done map[string]bool) (api.WorkflowStep, error) {
groups := [][]string{}
for _, e := range ends {
merged := []string{e}
kept := [][]string{}
for _, g := range groups {
if slices.ContainsFunc(g, func(other string) bool { return shareAny(closure(e, done), closure(other, done)) }) {
merged = append(g, merged...)
continue
}
kept = append(kept, g)
}
groups = append(kept, merged)
}
if len(groups) > 1 {
branches := []api.WorkflowStep{}
for _, g := range groups {
b, err := tree(g, done)
if err != nil {
return api.WorkflowStep{}, err
}
branches = append(branches, b)
}
return api.WorkflowStep{Parallel: &branches}, nil
}
if len(ends) > 1 {
common := closure(ends[0], done)
for _, e := range ends[1:] {
c := closure(e, done)
for n := range common {
if !c[n] {
delete(common, n)
}
}
}
heads := []string{}
for n := range common {
later := false
for m := range common {
later = later || (m != n && closure(m, done)[n])
}
if !later {
heads = append(heads, n)
}
}
if len(heads) == 0 {
return api.WorkflowStep{}, fmt.Errorf("stages %v overlap in earlier stages without sharing all of them, which steps can't express", ends)
}
slices.Sort(heads)
prefix, err := tree(heads, done)
if err != nil {
return api.WorkflowStep{}, err
}
next := maps.Clone(done)
for n := range common {
next[n] = true
}
rest, err := tree(ends, next)
if err != nil {
return api.WorkflowStep{}, err
}
return sequence(prefix, rest), nil
}
s := byName[ends[0]]
stage := api.WorkflowStep{Stage: s.Name, Model: s.Model, Prompt: s.Prompt, Agents: s.Agents}
preds := pending(after[s.Name], done)
if len(preds) == 0 {
return stage, nil
}
before, err := tree(preds, done)
if err != nil {
return api.WorkflowStep{}, err
}
return sequence(before, stage), nil
}
finals := []string{}
for _, s := range old.Stages {
next := false
for _, a := range after {
next = next || slices.Contains(a, s.Name)
}
if !next {
finals = append(finals, s.Name)
}
}
top, err := tree(finals, map[string]bool{})
if err != nil {
return api.WorkflowDefinition{}, err
}
steps := []api.WorkflowStep{top}
if top.Order != nil {
steps = *top.Order
}
def := api.WorkflowDefinition{Description: old.Description, Steps: steps}
if len(workflow.Stages(def)) != len(old.Stages) {
return api.WorkflowDefinition{}, fmt.Errorf("converted to %d stages from %d", len(workflow.Stages(def)), len(old.Stages))
}
for _, s := range old.Stages {
got := workflow.After(def, s.Name)
want := after[s.Name]
if !slices.Equal(sorted(got), sorted(want)) {
return api.WorkflowDefinition{}, fmt.Errorf("stage %q converted to come after %v, not %v", s.Name, got, want)
}
}
return def, nil
}
func shareAny(a, b map[string]bool) bool {
for n := range a {
if b[n] {
return true
}
}
return false
}
func sorted(names []string) []string {
out := slices.Clone(names)
slices.Sort(out)
return out
}
@@ -0,0 +1,33 @@
ALTER TABLE projects ADD COLUMN review_workflow TEXT NOT NULL DEFAULT '';
ALTER TABLE reviews ADD COLUMN stage TEXT NOT NULL DEFAULT '';
ALTER TABLE reviews ADD COLUMN definition TEXT NOT NULL DEFAULT '';
UPDATE reviews SET stage = 'Review' WHERE state != 'skipped' AND stage = '';
UPDATE events SET payload = json_set(payload, '$.stage', CASE json_extract(payload, '$.state') WHEN 'skipped' THEN '' ELSE 'Review' END)
WHERE kind IN ('review.started', 'review.finished', 'review.finding') AND json_extract(payload, '$.stage') IS NULL;
UPDATE events SET payload = json_set(payload, '$.input.reviewer', 'Review')
WHERE kind = 'agent.tool_call' AND json_extract(payload, '$.name') LIKE '%respond_to_finding'
AND json_extract(payload, '$.input.reviewer') IS NULL;
INSERT OR IGNORE INTO workflows (name, definition)
SELECT 'Review', json_object(
'description', 'One reviewer looks over all of the task''s work.',
'kind', 'review',
'steps', json_array(json_object(
'stage', 'Review',
'model', u.review_model,
'prompt', 'Look for bugs, security problems, missing or wrong tests, and anything that doesn''t do what the task asked.'
))
)
FROM projects p JOIN users u ON u.login = p.created_by
ORDER BY p.created_at
LIMIT 1;
UPDATE projects SET review_workflow = 'Review'
WHERE review_workflow = '' AND EXISTS (SELECT 1 FROM workflows WHERE name = 'Review');
ALTER TABLE users DROP COLUMN review_model;
ALTER TABLE users DROP COLUMN second_review_model;
-14
View File
@@ -53,17 +53,3 @@ func (s *Store) UpdateAway(ctx context.Context, login string, away bool) (api.Us
}
return s.GetUser(ctx, login)
}
func (s *Store) UpdateReviewModels(ctx context.Context, login, model, second string) (api.User, error) {
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `UPDATE users SET review_model = ?, second_review_model = ? WHERE login = ?`, model, second, login)
if err != nil {
return err
}
return emit(api.Event{Kind: "user.updated", Actor: login, Payload: map[string]any{"login": login}})
})
if err != nil {
return api.User{}, err
}
return s.GetUser(ctx, login)
}
+55 -7
View File
@@ -10,18 +10,26 @@ import (
"g.fc.run/ai/byre/internal/api"
)
const reviewColumns = `id, task_id, snapshot_n, coalesce(agent_session_id, ''), vendor, model, state, result, error, created_at, finished_at, finding_states, finding_responses`
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) error {
func (s *Store) CreateReview(ctx context.Context, r api.Review, def *api.WorkflowDefinition) error {
finishedAt := ""
if r.FinishedAt != nil {
finishedAt = formatTime(*r.FinishedAt)
}
defText := ""
if def != nil {
b, err := json.Marshal(def)
if err != nil {
return err
}
defText = string(b)
}
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO reviews (id, task_id, snapshot_n, agent_session_id, vendor, model, state, created_at, finished_at)
VALUES (?, ?, ?, nullif(?, ''), ?, ?, ?, ?, ?)
`, r.Id, r.TaskId, r.SnapshotN, r.AgentSessionId, r.Vendor, r.Model, string(r.State), formatTime(r.CreatedAt), finishedAt)
INSERT INTO reviews (id, task_id, snapshot_n, stage, definition, agent_session_id, vendor, model, state, error, created_at, finished_at)
VALUES (?, ?, ?, ?, ?, nullif(?, ''), ?, ?, ?, ?, ?, ?)
`, r.Id, r.TaskId, r.SnapshotN, r.Stage, defText, r.AgentSessionId, r.Vendor, r.Model, string(r.State), r.Error, formatTime(r.CreatedAt), finishedAt)
if err != nil {
return err
}
@@ -32,6 +40,23 @@ func (s *Store) CreateReview(ctx context.Context, r api.Review) error {
})
}
func (s *Store) ReviewDefinition(ctx context.Context, id string) (api.WorkflowDefinition, error) {
text := ""
err := s.db.QueryRowContext(ctx, `SELECT definition FROM reviews WHERE id = ?`, id).Scan(&text)
if errors.Is(err, sql.ErrNoRows) {
return api.WorkflowDefinition{}, ErrNotFound
}
if err != nil {
return api.WorkflowDefinition{}, err
}
def := api.WorkflowDefinition{}
if text == "" {
return def, nil
}
err = json.Unmarshal([]byte(text), &def)
return def, err
}
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) {
@@ -49,7 +74,7 @@ func (s *Store) ReviewBySession(ctx context.Context, sessionID string) (api.Revi
}
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`, taskID)
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
}
@@ -64,6 +89,29 @@ func (s *Store) RunningReviews(ctx context.Context) ([]api.Review, error) {
return scanReviews(rows)
}
func (s *Store) LatestDoneStages(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT r.id FROM reviews r JOIN tasks t ON t.id = r.task_id
WHERE t.status = ? AND r.state = ? AND r.definition != ''
AND r.snapshot_n = (SELECT max(snapshot_n) FROM reviews WHERE task_id = r.task_id)
ORDER BY r.created_at`, string(api.TaskStatusActive), string(api.ReviewStateDone))
if err != nil {
return nil, err
}
defer rows.Close()
ids := []string{}
for rows.Next() {
id := ""
err = rows.Scan(&id)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Store) FinishReview(ctx context.Context, id string, state api.ReviewState, result map[string]any, errText string) error {
text := ""
if result != nil {
@@ -205,7 +253,7 @@ func scanReview(row scanner) (api.Review, error) {
finishedAt := ""
findingStates := ""
findingResponses := ""
err := row.Scan(&r.Id, &r.TaskId, &r.SnapshotN, &r.AgentSessionId, &r.Vendor, &r.Model, &state, &result, &r.Error, &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
}
+58 -14
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"sync"
@@ -25,8 +26,14 @@ var ErrNotFound = errors.New("not found")
var migrationFiles embed.FS
type migration struct {
key string
name string
sql string
run func(*sql.Tx) error
}
var goMigrations = map[string]func(*sql.Tx) error{
"071-workflow-steps": migrateWorkflowSteps,
}
func loadMigrations() ([]migration, error) {
@@ -37,17 +44,25 @@ func loadMigrations() ([]migration, error) {
migrations := []migration{}
for _, e := range entries {
num, name, ok := strings.Cut(strings.TrimSuffix(e.Name(), ".sql"), "-")
_, err = strconv.Atoi(num)
if !ok || len(num) != 3 || err != nil {
return nil, fmt.Errorf("migration file %s: want NNN-name.sql", e.Name())
}
key := strings.TrimSuffix(e.Name(), ".sql")
sql, err := migrationFiles.ReadFile("migrations/" + e.Name())
if err != nil {
return nil, err
}
migrations = append(migrations, migration{name: name, sql: string(sql)})
migrations = append(migrations, migration{key: key, sql: string(sql)})
}
for key, run := range goMigrations {
migrations = append(migrations, migration{key: key, run: run})
}
slices.SortFunc(migrations, func(a, b migration) int { return strings.Compare(a.key, b.key) })
for i, m := range migrations {
num, name, ok := strings.Cut(m.key, "-")
_, err = strconv.Atoi(num)
if !ok || len(num) != 3 || err != nil {
return nil, fmt.Errorf("migration %s: want NNN-name", m.key)
}
migrations[i].name = name
}
return migrations, nil
}
@@ -151,7 +166,11 @@ func applyMigration(db *sql.DB, m migration) error {
}
defer tx.Rollback()
_, err = tx.Exec(m.sql)
if m.run != nil {
err = m.run(tx)
} else {
_, err = tx.Exec(m.sql)
}
if err != nil {
return err
}
@@ -218,8 +237,8 @@ func (s *Store) EnsureUser(ctx context.Context, login, displayName string) (api.
func (s *Store) GetUser(ctx context.Context, login string) (api.User, error) {
u := api.User{}
prefs := ""
err := s.db.QueryRowContext(ctx, `SELECT login, display_name, git_name, git_email, notification_prefs, default_model, review_model, second_review_model, away FROM users WHERE login = ?`, login).
Scan(&u.Login, &u.DisplayName, &u.GitName, &u.GitEmail, &prefs, &u.DefaultModel, &u.ReviewModel, &u.SecondReviewModel, &u.Away)
err := s.db.QueryRowContext(ctx, `SELECT login, display_name, git_name, git_email, notification_prefs, default_model, away FROM users WHERE login = ?`, login).
Scan(&u.Login, &u.DisplayName, &u.GitName, &u.GitEmail, &prefs, &u.DefaultModel, &u.Away)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
@@ -275,6 +294,31 @@ func (s *Store) ListProjects(ctx context.Context) ([]api.Project, error) {
return projects, rows.Err()
}
func (s *Store) SetReviewWorkflow(ctx context.Context, id, workflow, actor string) (api.Project, error) {
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
res, err := tx.ExecContext(ctx, `UPDATE projects SET review_workflow = ? WHERE id = ?`, workflow, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
p, err := scanProject(tx.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id))
if err != nil {
return err
}
return emit(api.Event{Kind: "project.updated", ProjectId: &id, Actor: actor, Payload: ToPayload(p)})
})
if err != nil {
return api.Project{}, err
}
return s.GetProject(ctx, id)
}
func (s *Store) GetProject(ctx context.Context, id string) (api.Project, error) {
p, err := scanProject(s.db.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
@@ -724,7 +768,7 @@ func (s *Store) FinishedMergeAfterReviews(ctx context.Context) ([]string, error)
return ids, rows.Err()
}
func (s *Store) TakeMergeAfterReview(ctx context.Context, id, reviewID string) (MergeAfterReview, error) {
func (s *Store) TakeMergeAfterReview(ctx context.Context, id string, reviewIDs []string) (MergeAfterReview, error) {
mar := MergeAfterReview{}
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID, target := "", ""
@@ -737,7 +781,7 @@ func (s *Store) TakeMergeAfterReview(ctx context.Context, id, reviewID string) (
return err
}
mar.Target = api.MergeTaskJSONBodyTarget(target)
if mar.By == "" || (reviewID != "" && mar.ReviewID != reviewID) {
if mar.By == "" || (reviewIDs != nil && !slices.Contains(reviewIDs, mar.ReviewID)) {
mar = MergeAfterReview{}
return nil
}
@@ -1638,7 +1682,7 @@ func (s *Store) ClearReviewRequests(ctx context.Context) error {
})
}
const projectColumns = `id, name, repo_path, default_branch, remote, created_by, created_at`
const projectColumns = `id, name, repo_path, default_branch, remote, created_by, created_at, review_workflow`
const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, closed_at, merge_commit, todo_item, forked_from, forked_at, merge_queued_at, workflow_name, workflow_run, workflow_stage, parent_task_id, parent_merge_by, merge_after_review_by != '', state, merging_by != '' OR finalizing != '', discard_state != '' AND status = 'active', (SELECT coalesce(max(seq), 0) FROM events WHERE events.task_id = tasks.id AND kind = 'turn.ended' AND json_extract(payload, '$.summary') = 1)`
@@ -1651,7 +1695,7 @@ type scanner interface {
func scanProject(row scanner) (api.Project, error) {
p := api.Project{}
createdAt := ""
err := row.Scan(&p.Id, &p.Name, &p.RepoPath, &p.DefaultBranch, &p.Remote, &p.CreatedBy, &createdAt)
err := row.Scan(&p.Id, &p.Name, &p.RepoPath, &p.DefaultBranch, &p.Remote, &p.CreatedBy, &createdAt, &p.ReviewWorkflow)
if err != nil {
return p, err
}
+2 -2
View File
@@ -18,7 +18,7 @@ type FrozenWorkflow struct {
}
func (s *Store) ListWorkflows(ctx context.Context) ([]api.WorkflowSummary, error) {
rows, err := s.db.QueryContext(ctx, `SELECT name, coalesce(json_extract(definition, '$.description'), '') FROM workflows ORDER BY name`)
rows, err := s.db.QueryContext(ctx, `SELECT name, coalesce(json_extract(definition, '$.description'), ''), coalesce(nullif(json_extract(definition, '$.kind'), ''), 'task') FROM workflows ORDER BY name`)
if err != nil {
return nil, err
}
@@ -27,7 +27,7 @@ func (s *Store) ListWorkflows(ctx context.Context) ([]api.WorkflowSummary, error
workflows := []api.WorkflowSummary{}
for rows.Next() {
w := api.WorkflowSummary{}
err = rows.Scan(&w.Name, &w.Description)
err = rows.Scan(&w.Name, &w.Description, &w.Kind)
if err != nil {
return nil, err
}