Add a per-user lead agent and make the right pane task-focused

This commit is contained in:
Ian Gulliver
2026-10-15 09:40:30 -07:00
parent d212590cdc
commit bdd1ae48f0
68 changed files with 3332 additions and 1364 deletions
+2 -156
View File
@@ -1,14 +1,7 @@
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"slices"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
@@ -43,163 +36,16 @@ func (s *Server) GetInboxItem(w http.ResponseWriter, r *http.Request, itemID str
writeJSON(w, http.StatusOK, item)
}
type httpError struct {
status int
err error
}
func (s *Server) RespondInbox(w http.ResponseWriter, r *http.Request) {
req := api.InboxRespond{}
if !readJSON(w, r, &req) {
return
}
if len(req.Responses) == 0 {
writeError(w, http.StatusBadRequest, errors.New("responses is empty"))
return
}
resolutions := []store.Resolution{}
sessions := []string{}
seen := map[string]bool{}
for _, create := range req.Responses {
if seen[create.ItemId] {
writeError(w, http.StatusBadRequest, fmt.Errorf("item %s answered twice in one submission", create.ItemId))
return
}
seen[create.ItemId] = true
item, err := s.store.GetInboxItem(r.Context(), create.ItemId)
if err != nil {
writeStoreError(w, err)
return
}
state, herr := checkResponse(item, create)
if herr != nil {
writeError(w, herr.status, herr.err)
return
}
attachments := []api.Attachment{}
if create.Attachments != nil {
attachments = *create.Attachments
}
resolutions = append(resolutions, store.Resolution{
State: state,
Response: api.InboxResponse{
Id: store.NewID(),
ItemId: item.Id,
User: actor(r),
Type: create.Type,
Options: create.Options,
Text: create.Text,
Attachments: attachments,
CreatedAt: time.Now().UTC(),
},
})
if !slices.Contains(sessions, item.AgentSessionId) {
sessions = append(sessions, item.AgentSessionId)
}
}
err := s.store.Respond(r.Context(), actor(r), resolutions)
items, err := s.agents.Respond(r.Context(), actor(r), req.Responses)
if err != nil {
writeStoreError(w, err)
writeAgentError(w, err)
return
}
for _, id := range sessions {
go func() {
err := s.agents.Deliver(context.Background(), id)
if err != nil {
slog.Error("deliver inbox responses", "session", id, "err", err)
}
}()
}
items := []api.InboxItem{}
for _, res := range resolutions {
item, err := s.store.GetInboxItem(r.Context(), res.Response.ItemId)
if err != nil {
writeStoreError(w, err)
return
}
items = append(items, item)
}
writeJSON(w, http.StatusOK, items)
}
func checkResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxState, *httpError) {
if item.State != api.Open {
return "", &httpError{http.StatusConflict, fmt.Errorf("item %s is %s", item.Id, item.State)}
}
text := ""
if c.Text != nil {
text = strings.TrimSpace(*c.Text)
}
question := item.Kind == api.Question
bad := func(format string, args ...any) (api.InboxState, *httpError) {
return "", &httpError{http.StatusBadRequest, fmt.Errorf("item %s: "+format, append([]any{item.Id}, args...)...)}
}
switch c.Type {
case api.Option:
if !question {
return bad("only questions have options")
}
if c.Options == nil || len(*c.Options) == 0 {
return bad("option needs the chosen options")
}
if item.Payload["multiple"] != true && len(*c.Options) != 1 {
return bad("a pick-one question takes exactly one option")
}
seen := map[string]bool{}
for _, o := range *c.Options {
if !hasOption(item, o) {
return bad("%q is not one of the question's options", o)
}
if seen[o] {
return bad("%q chosen twice", o)
}
seen[o] = true
}
if text != "" && item.Payload["multiple"] != true {
return bad("a pick-one option takes no text; use other")
}
return api.Answered, nil
case api.Other:
if item.Kind == api.Report {
return bad("reports are only dismissed")
}
if text == "" {
return bad("other needs text")
}
return api.Answered, nil
case api.Dismiss:
if question {
return bad("questions need an option or other, not a dismissal")
}
return api.Dismissed, nil
}
return bad("unknown response type %q", c.Type)
}
func hasOption(item api.InboxItem, option string) bool {
options, ok := item.Payload["options"].([]any)
if !ok {
return false
}
for _, o := range options {
if o == option {
return true
}
}
return false
}
-77
View File
@@ -1,77 +0,0 @@
package server
import (
"net/http"
"testing"
"g.fc.run/misc/byre/internal/api"
)
func TestCheckResponse(t *testing.T) {
question := api.InboxItem{
Id: "q1",
Kind: api.Question,
State: api.Open,
Payload: map[string]any{"options": []any{"red", "blue"}},
}
checklist := api.InboxItem{
Id: "q2",
Kind: api.Question,
State: api.Open,
Payload: map[string]any{"options": []any{"tests", "docs", "lint"}, "multiple": true},
}
report := api.InboxItem{Id: "r1", Kind: api.Report, State: api.Open, Payload: map[string]any{}}
failure := api.InboxItem{Id: "e1", Kind: api.SessionError, State: api.Open, Payload: map[string]any{}}
closed := question
closed.State = api.Answered
str := func(s string) *string { return &s }
opts := func(s ...string) *[]string { return &s }
cases := []struct {
name string
item api.InboxItem
create api.InboxResponseCreate
state api.InboxState
status int
}{
{"option", question, api.InboxResponseCreate{Type: api.Option, Options: opts("blue")}, api.Answered, 0},
{"option with text", question, api.InboxResponseCreate{Type: api.Option, Options: opts("red"), Text: str("but darker")}, "", http.StatusBadRequest},
{"unknown option", question, api.InboxResponseCreate{Type: api.Option, Options: opts("green")}, "", http.StatusBadRequest},
{"missing option", question, api.InboxResponseCreate{Type: api.Option}, "", http.StatusBadRequest},
{"no options", question, api.InboxResponseCreate{Type: api.Option, Options: opts()}, "", http.StatusBadRequest},
{"two picks on pick-one", question, api.InboxResponseCreate{Type: api.Option, Options: opts("red", "blue")}, "", http.StatusBadRequest},
{"checked", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("tests", "lint")}, api.Answered, 0},
{"checked one", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("docs")}, api.Answered, 0},
{"checked with text", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("tests", "lint"), Text: str("also run go vet")}, api.Answered, 0},
{"checked twice", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("docs", "docs")}, "", http.StatusBadRequest},
{"checked unknown", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("docs", "deploy")}, "", http.StatusBadRequest},
{"other", question, api.InboxResponseCreate{Type: api.Other, Text: str("green")}, api.Answered, 0},
{"empty other", question, api.InboxResponseCreate{Type: api.Other, Text: str(" ")}, "", http.StatusBadRequest},
{"other on error", failure, api.InboxResponseCreate{Type: api.Other, Text: str("retry")}, api.Answered, 0},
{"other on report", report, api.InboxResponseCreate{Type: api.Other, Text: str("stop")}, "", http.StatusBadRequest},
{"snooze", question, api.InboxResponseCreate{Type: "snooze"}, "", http.StatusBadRequest},
{"dismiss report", report, api.InboxResponseCreate{Type: api.Dismiss}, api.Dismissed, 0},
{"dismiss question", question, api.InboxResponseCreate{Type: api.Dismiss}, "", http.StatusBadRequest},
{"option on report", report, api.InboxResponseCreate{Type: api.Option, Options: opts("red")}, "", http.StatusBadRequest},
{"already answered", closed, api.InboxResponseCreate{Type: api.Option, Options: opts("red")}, "", http.StatusConflict},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
state, herr := checkResponse(c.item, c.create)
if c.status != 0 {
if herr == nil || herr.status != c.status {
t.Fatalf("want status %d, got %+v", c.status, herr)
}
return
}
if herr != nil {
t.Fatalf("unexpected error %v", herr.err)
}
if state != c.state {
t.Fatalf("want state %s, got %s", c.state, state)
}
})
}
}
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"errors"
"net/http"
"strings"
"g.fc.run/misc/byre/internal/api"
)
func (s *Server) ListLeadSessions(w http.ResponseWriter, r *http.Request) {
sessions, err := s.agents.LeadSessions(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, sessions)
}
func (s *Server) MessageLead(w http.ResponseWriter, r *http.Request) {
req := api.MessageCreate{}
if !readJSON(w, r, &req) {
return
}
images, ok := s.images(w, r, req.Images)
if !ok {
return
}
if strings.TrimSpace(req.Text) == "" && len(images) == 0 {
writeError(w, http.StatusBadRequest, errors.New("text or images are required"))
return
}
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
model := ""
if req.Model != nil {
model = *req.Model
}
sess, err := s.agents.MessageLead(r.Context(), u, req.Text, model, images)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, sess)
}
func (s *Server) GetFocus(w http.ResponseWriter, r *http.Request) {
f, err := s.store.GetFocus(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, f)
}
func (s *Server) SetFocus(w http.ResponseWriter, r *http.Request) {
f := api.Focus{}
if !readJSON(w, r, &f) {
return
}
err := s.agents.SetFocus(r.Context(), actor(r), f, actor(r))
if err != nil {
writeAgentError(w, err)
return
}
f, err = s.store.GetFocus(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, f)
}
+4 -9
View File
@@ -61,7 +61,7 @@ func New(dataDir string, spa fs.FS) (*Server, error) {
return nil, fmt.Errorf("start push notifier: %w", err)
}
s.agents = agents.New(s.store, agents.DefaultLimits, filepath.Join(dataDir, "worktrees"))
s.agents = agents.New(s.store, agents.DefaultLimits, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"))
err = s.agents.Recover(context.Background())
if err != nil {
s.notifier.Close()
@@ -78,6 +78,7 @@ const pushWindow = 3 * time.Second
func (s *Server) publish(ev api.Event) {
s.hub.publish(ev)
s.notifier.Observe(ev)
s.agents.Observe(ev)
}
func (s *Server) Close() error {
@@ -414,13 +415,7 @@ func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID stri
return
}
err := s.agents.RemoveTask(r.Context(), t, p, actor(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
t, err = s.store.DiscardTask(r.Context(), taskID, actor(r))
t, err := s.agents.Discard(r.Context(), t, p, actor(r))
if err != nil {
writeStoreError(w, err)
return
@@ -652,7 +647,7 @@ func writeAgentError(w http.ResponseWriter, err error) {
writeError(w, http.StatusNotFound, err)
case errors.Is(err, agents.ErrConflict):
writeError(w, http.StatusConflict, err)
case errors.Is(err, agents.ErrNoGitIdentity):
case errors.Is(err, agents.ErrInvalid), errors.Is(err, agents.ErrNoGitIdentity):
writeError(w, http.StatusBadRequest, err)
default:
writeError(w, http.StatusInternalServerError, err)
+1 -1
View File
@@ -125,7 +125,7 @@ func TestServesEmbeddedSPA(t *testing.T) {
"/src/app.ts": "",
"/dist/inbox.js": "text/javascript",
"/dist/api.gen.js": "text/javascript",
"/dist/tasks.js": "text/javascript",
"/dist/lead.js": "text/javascript",
"/dist/navbar.js": "text/javascript",
"/dist/panes.js": "text/javascript",
"/dist/task.js": "text/javascript",
+19 -4
View File
@@ -19,6 +19,8 @@ func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api
if params.LastEventID != nil {
last = *params.LastEventID
}
viewer := actor(r)
lead := params.Lead != nil && *params.Lead
taskID := ""
if params.TaskId != nil {
taskID = *params.TaskId
@@ -29,10 +31,23 @@ func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api
}
}
backfill := func(after int64) ([]api.Event, error) {
if taskID == "" {
return s.store.EventsAfter(r.Context(), after, backfillPage)
switch {
case lead:
return s.store.LeadEvents(r.Context(), viewer, after, backfillPage)
case taskID != "":
return s.store.TaskEvents(r.Context(), taskID, after, backfillPage)
}
return s.store.TaskEvents(r.Context(), taskID, after, backfillPage)
return s.store.EventsAfter(r.Context(), viewer, after, backfillPage)
}
wanted := func(ev api.Event) bool {
owned := ev.Owner != nil && *ev.Owner == viewer
switch {
case lead:
return owned && ev.AgentSessionId != nil
case taskID != "":
return ev.TaskId != nil && *ev.TaskId == taskID
}
return ev.Owner == nil || owned
}
rc := http.NewResponseController(w)
@@ -75,7 +90,7 @@ func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api
if ev.Seq != 0 && ev.Seq <= last {
continue
}
if taskID != "" && (ev.TaskId == nil || *ev.TaskId != taskID) {
if !wanted(ev) {
continue
}
if !writeEvent(w, ev) {