From 68ccff3f03e37980e3ddb3bd485a04ed06befbc4 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 23 Oct 2026 18:25:56 -0700 Subject: [PATCH] Simplify codebase per audit of 0139564 --- internal/agents/afterreview.go | 2 +- internal/agents/antigravity.go | 8 +++- internal/agents/clients.go | 2 +- internal/agents/codex.go | 2 +- internal/agents/guidance.go | 2 +- internal/agents/inbox.go | 17 -------- internal/agents/leadtools.go | 4 +- internal/agents/pause.go | 4 +- internal/agents/review.go | 2 +- internal/agents/sign.go | 5 +-- internal/agents/subtasks.go | 8 +++- internal/agents/usage.go | 9 ++-- internal/clients/clients.go | 70 +++++++++++++----------------- internal/server/clients.go | 20 ++++----- internal/server/server.go | 33 ++++++-------- internal/store/store.go | 60 +++++-------------------- mac/Sources/Byre/API.swift | 4 ++ mac/Sources/Byre/Chat.swift | 7 +-- mac/Sources/Byre/Composer.swift | 14 +++--- mac/Sources/Byre/MergeDialog.swift | 10 ++++- mac/Sources/Byre/Panes.swift | 15 +------ mac/Sources/Byre/Sheet.swift | 2 +- mac/Sources/Byre/Store.swift | 10 +++-- mac/Sources/Byre/Switcher.swift | 2 +- 24 files changed, 122 insertions(+), 190 deletions(-) diff --git a/internal/agents/afterreview.go b/internal/agents/afterreview.go index 3042525..d7d746d 100644 --- a/internal/agents/afterreview.go +++ b/internal/agents/afterreview.go @@ -21,7 +21,7 @@ func (m *Manager) MergeAfterReview(ctx context.Context, task api.Task, user api. if err != nil { return api.Task{}, err } - if target == api.MergeTargetParent && (task.ParentTaskId == nil || task.WorkflowStage != nil) { + if target == api.MergeTargetParent && !isSubtask(task) { return api.Task{}, fmt.Errorf("%w: task %s is not a subtask", ErrInvalid, task.Id) } if target == api.MergeTargetMain { diff --git a/internal/agents/antigravity.go b/internal/agents/antigravity.go index f7dcce6..66d198e 100644 --- a/internal/agents/antigravity.go +++ b/internal/agents/antigravity.go @@ -221,9 +221,13 @@ func (m *Manager) agyBeforeModel(l *live) hookcmd.Func { func (m *Manager) agyBeforeTool(l *live) hookcmd.Func { return func(ctx context.Context, input json.RawMessage) (map[string]any, error) { - if !m.heldBeforeTool(ctx, l, "") || !m.midTurnWaiting(ctx, l) { + if !m.resumedFromPause(ctx, l, "") { return map[string]any{"decision": "allow"}, nil } - return map[string]any{"decision": "deny", "reason": pausedCallNotice}, nil + mt := m.deliverMidTurn(ctx, l) + if mt.text == "" { + return map[string]any{"decision": "allow"}, nil + } + return map[string]any{"decision": "deny", "reason": pausedCallNotice + "\n\n" + mt.text}, nil } } diff --git a/internal/agents/clients.go b/internal/agents/clients.go index 8bd81b6..9bb569a 100644 --- a/internal/agents/clients.go +++ b/internal/agents/clients.go @@ -278,7 +278,7 @@ func (m *Manager) listClients() string { now := time.Now() b := &strings.Builder{} for i, r := range rows { - fmt.Fprintf(b, "- %s (%s, %s, id %s): ", r.Host, r.Device, r.Login, r.ID) + fmt.Fprintf(b, "- %s (%s, %s, id %s): ", r.Host, r.Device, r.Login, r.Id) if r.LastActionAt == nil { b.WriteString("no action since byre started") } else { diff --git a/internal/agents/codex.go b/internal/agents/codex.go index 8f1b85f..14cf3ff 100644 --- a/internal/agents/codex.go +++ b/internal/agents/codex.go @@ -210,7 +210,7 @@ func (m *Manager) steerMidTurn(ctx context.Context, l *live, s *codex.Session) { func (m *Manager) codexAfterTool(l *live) hookcmd.Func { return func(ctx context.Context, input json.RawMessage) (map[string]any, error) { - if m.holdWhilePaused(ctx, l, "") && ctx.Err() == nil { + if m.resumedFromPause(ctx, l, "") { m.steerMidTurn(context.WithoutCancel(ctx), l, l.sess.(codexProcess).Session) } return map[string]any{}, nil diff --git a/internal/agents/guidance.go b/internal/agents/guidance.go index 3159002..79dfead 100644 --- a/internal/agents/guidance.go +++ b/internal/agents/guidance.go @@ -109,7 +109,7 @@ func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, erro g += fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.", *task.TodoItem, todo.File, todo.File) } - if task.ParentTaskId != nil && task.WorkflowStage == nil { + if isSubtask(task) { parent, err := m.store.GetTask(ctx, *task.ParentTaskId) if err != nil { return "", err diff --git a/internal/agents/inbox.go b/internal/agents/inbox.go index 4613893..a31677a 100644 --- a/internal/agents/inbox.go +++ b/internal/agents/inbox.go @@ -418,23 +418,6 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn { return mt } -func (m *Manager) midTurnWaiting(ctx context.Context, l *live) bool { - inputs, err := m.store.TaskInputs(ctx, l.taskID, l.member) - if err != nil { - slog.Error("list queued messages", "session", l.id, "err", err) - return false - } - if len(inputs) > 0 { - return true - } - pending, err := m.store.Undelivered(ctx, l.id) - if err != nil { - slog.Error("list undelivered responses", "session", l.id, "err", err) - return false - } - return len(pending) > 0 -} - func (m *Manager) restoreMidTurn(ctx context.Context, l *live, mt midTurn) { if len(mt.taskInputs) > 0 { err := m.store.RestoreTaskInputs(ctx, mt.taskInputs) diff --git a/internal/agents/leadtools.go b/internal/agents/leadtools.go index 442351f..45945d3 100644 --- a/internal/agents/leadtools.go +++ b/internal/agents/leadtools.go @@ -392,7 +392,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { return "", err } case "parent": - if task.ParentTaskId != nil { + if isSubtask(task) { err = m.checkTaskNotStopped(ctx, *task.ParentTaskId) if err != nil { return "", err @@ -878,7 +878,7 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) { if t.ForkedFrom != nil { fmt.Fprintf(b, "; %s", forkOrigin(t, *t.ForkedFrom)) } - if t.ParentTaskId != nil && t.WorkflowStage == nil { + if isSubtask(t) { fmt.Fprintf(b, "; subtask of %s", *t.ParentTaskId) if t.MergingIntoParent { b.WriteString(", merging into it") diff --git a/internal/agents/pause.go b/internal/agents/pause.go index 0f425ac..39dc05d 100644 --- a/internal/agents/pause.go +++ b/internal/agents/pause.go @@ -166,13 +166,13 @@ func (m *Manager) holdWhilePaused(ctx context.Context, l *live, toolUseID string } } -func (m *Manager) heldBeforeTool(ctx context.Context, l *live, toolUseID string) bool { +func (m *Manager) resumedFromPause(ctx context.Context, l *live, toolUseID string) bool { return m.holdWhilePaused(ctx, l, toolUseID) && ctx.Err() == nil } func (m *Manager) beforeTool(l *live) hookcmd.Func { return func(ctx context.Context, input json.RawMessage) (map[string]any, error) { - if !m.heldBeforeTool(ctx, l, hookToolUseID(input)) { + if !m.resumedFromPause(ctx, l, hookToolUseID(input)) { return map[string]any{}, nil } mt := m.deliverMidTurn(ctx, l) diff --git a/internal/agents/review.go b/internal/agents/review.go index 8ff9ef3..63d7c64 100644 --- a/internal/agents/review.go +++ b/internal/agents/review.go @@ -271,7 +271,7 @@ func (m *Manager) reviewPrompt(ctx context.Context, task api.Task, project api.P } func (m *Manager) reviewBase(ctx context.Context, task api.Task, project api.Project) (string, error) { - if task.ParentTaskId == nil || task.WorkflowStage != nil { + if !isSubtask(task) { return project.DefaultBranch, nil } parent, err := m.store.GetTask(ctx, *task.ParentTaskId) diff --git a/internal/agents/sign.go b/internal/agents/sign.go index 639315f..55612b9 100644 --- a/internal/agents/sign.go +++ b/internal/agents/sign.go @@ -7,7 +7,6 @@ import ( "time" "g.fc.run/ai/byre/internal/api" - "g.fc.run/ai/byre/internal/clients" ) const ( @@ -17,7 +16,7 @@ const ( func (m *Manager) signer(ctx context.Context, user api.User) func(payload []byte) ([]byte, error) { return func(payload []byte) ([]byte, error) { - mine := []clients.Client{} + mine := []api.ConnectedClient{} for _, c := range m.clients.RecentMacs() { if c.Login == user.Login { mine = append(mine, c) @@ -33,7 +32,7 @@ func (m *Manager) signer(ctx context.Context, user api.User) func(payload []byte ms := int(signTimeout.Milliseconds()) cctx, cancel := context.WithTimeout(ctx, signTimeout+replyGrace) defer cancel() - reply, err := m.clients.Call(cctx, client.ID, api.ClientRequest{Op: api.Sign, Data: &payload, Ident: &ident, TimeoutMs: &ms}) + reply, err := m.clients.Call(cctx, client.Id, api.ClientRequest{Op: api.Sign, Data: &payload, Ident: &ident, TimeoutMs: &ms}) if err != nil { return nil, err } diff --git a/internal/agents/subtasks.go b/internal/agents/subtasks.go index 8c63fee..4e1eb8d 100644 --- a/internal/agents/subtasks.go +++ b/internal/agents/subtasks.go @@ -274,8 +274,12 @@ func (m *Manager) messageTask(ctx context.Context, task api.Task, user api.User, return err } +func isSubtask(task api.Task) bool { + return task.ParentTaskId != nil && task.WorkflowStage == nil +} + func (m *Manager) MergeIntoParent(ctx context.Context, task api.Task, user api.User) (api.Task, error) { - if task.ParentTaskId == nil || task.WorkflowStage != nil { + if !isSubtask(task) { return api.Task{}, fmt.Errorf("%w: task %s is not a subtask", ErrInvalid, task.Id) } err := m.checkNoSubtasks(ctx, task) @@ -384,7 +388,7 @@ func (m *Manager) reportToParent(taskID, result string) { slog.Error("load subtask to report", "task", taskID, "err", err) return } - if task.ParentTaskId == nil || task.WorkflowStage != nil || task.MergingIntoParent { + if !isSubtask(task) || task.MergingIntoParent { return } parent, err := m.store.GetTask(ctx, *task.ParentTaskId) diff --git a/internal/agents/usage.go b/internal/agents/usage.go index 52de091..21e00a3 100644 --- a/internal/agents/usage.go +++ b/internal/agents/usage.go @@ -27,7 +27,6 @@ func (m *Manager) loadUsage() { m.mu.Lock() m.usageCommands = cmds m.mu.Unlock() - go m.readCodexRateLimit() for _, v := range []api.AgentSessionVendor{api.Claude, api.Codex, api.Antigravity} { go m.readUsage(v) } @@ -67,9 +66,6 @@ func (m *Manager) SetUsageCommands(ctx context.Context, actor string, c api.Usag m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}}) for _, v := range changed { - if v == api.Codex && cmds[v] == "" { - go m.readCodexRateLimit() - } go m.readUsage(v) } return nil @@ -84,7 +80,10 @@ func (m *Manager) readUsage(vendor api.AgentSessionVendor) { cmdline := m.usageCommands[vendor] m.mu.Unlock() if cmdline == "" { - if vendor == api.Antigravity { + switch vendor { + case api.Codex: + m.readCodexRateLimit() + case api.Antigravity: m.readAgyRateLimit() } return diff --git a/internal/clients/clients.go b/internal/clients/clients.go index ba6774e..ad741af 100644 --- a/internal/clients/clients.go +++ b/internal/clients/clients.go @@ -28,18 +28,6 @@ const ( var ErrUnavailable = errors.New("client unavailable") -type Client struct { - ID string - Login string - Host string - Commit string - Build string - Device string - ConnectedAt time.Time - LastActionAt *time.Time - Sync *api.ClientSync -} - type Transfer struct { Dir string Name string @@ -54,7 +42,7 @@ type conn struct { } type entry struct { - Client + api.ConnectedClient conn *conn seen time.Time notified time.Time @@ -71,33 +59,34 @@ func New(changed func()) *Registry { return &Registry{clients: map[string]*entry{}, transfers: map[string]Transfer{}, changed: changed} } -func (r *Registry) entry(id, login, host, commit, build string, now time.Time) *entry { +func (r *Registry) entry(from api.ConnectedClient, now time.Time) *entry { for k, e := range r.clients { if e.conn == nil && now.Sub(e.seen) > expireAfter { delete(r.clients, k) } } - e := r.clients[id] + e := r.clients[from.Id] if e == nil { - e = &entry{Client: Client{ID: id}} - r.clients[id] = e + e = &entry{ConnectedClient: api.ConnectedClient{Id: from.Id}} + r.clients[from.Id] = e } - e.Login = login - e.Host = host - e.Commit = commit - e.Build = build + e.Login = from.Login + e.Host = from.Host + e.Device = from.Device + e.Commit = from.Commit + e.Build = from.Build e.seen = now return e } -func (r *Registry) Acted(id, login, host, commit, build string) bool { - if id == "" { +func (r *Registry) Acted(from api.ConnectedClient) bool { + if from.Id == "" { return false } r.mu.Lock() defer r.mu.Unlock() now := time.Now() - e := r.entry(id, login, host, commit, build, now) + e := r.entry(from, now) e.LastActionAt = &now if now.Sub(e.notified) < time.Second { return false @@ -106,28 +95,27 @@ func (r *Registry) Acted(id, login, host, commit, build string) bool { return true } -func (r *Registry) Synced(id, login, host, commit, build string, sync api.ClientSync) { +func (r *Registry) Synced(from api.ConnectedClient, sync api.ClientSync) { r.mu.Lock() defer r.mu.Unlock() - r.entry(id, login, host, commit, build, time.Now()).Sync = &sync + r.entry(from, time.Now()).Sync = &sync } -func (r *Registry) Serve(ctx context.Context, ws *websocket.Conn, id, login, host, commit, build, device string) { +func (r *Registry) Serve(ctx context.Context, ws *websocket.Conn, from api.ConnectedClient) { ws.SetReadLimit(readLimit) c := &conn{ws: ws, done: make(chan struct{}), pending: map[string]chan api.ClientReply{}} r.mu.Lock() now := time.Now().UTC() - e := r.entry(id, login, host, commit, build, now) + e := r.entry(from, now) old := e.conn e.conn = c - e.Device = device e.ConnectedAt = now r.mu.Unlock() if old != nil { old.ws.Close(websocket.StatusGoingAway, "replaced by a newer connection from this client") } - slog.Info("client connected", "id", id, "host", host, "device", device, "login", login, "build", build, "commit", commit) + slog.Info("client connected", "id", from.Id, "host", from.Host, "device", from.Device, "login", from.Login, "build", from.Build, "commit", from.Commit) r.changed() defer func() { @@ -138,7 +126,7 @@ func (r *Registry) Serve(ctx context.Context, ws *websocket.Conn, id, login, hos } r.mu.Unlock() close(c.done) - slog.Info("client disconnected", "id", id, "host", host, "login", login) + slog.Info("client disconnected", "id", from.Id, "host", from.Host, "login", from.Login) r.changed() }() @@ -165,7 +153,7 @@ func (r *Registry) Serve(ctx context.Context, ws *websocket.Conn, id, login, hos } c.mu.Unlock() if ch == nil { - slog.Warn("client reply for no request", "host", host, "id", reply.Id) + slog.Warn("client reply for no request", "host", from.Host, "id", reply.Id) continue } ch <- reply @@ -201,20 +189,20 @@ func (r *Registry) Host(id string) string { return e.Host } -func (r *Registry) List() []Client { +func (r *Registry) List() []api.ConnectedClient { r.mu.Lock() defer r.mu.Unlock() - out := []Client{} + out := []api.ConnectedClient{} for _, e := range r.clients { if e.conn != nil { - out = append(out, e.Client) + out = append(out, e.ConnectedClient) } } sort.Slice(out, func(i, j int) bool { return out[i].ConnectedAt.Before(out[j].ConnectedAt) }) return out } -func (r *Registry) Recent() []Client { +func (r *Registry) Recent() []api.ConnectedClient { rows := r.List() sort.SliceStable(rows, func(i, j int) bool { a, b := rows[i].LastActionAt, rows[j].LastActionAt @@ -223,8 +211,8 @@ func (r *Registry) Recent() []Client { return rows } -func (r *Registry) RecentMacs() []Client { - out := []Client{} +func (r *Registry) RecentMacs() []api.ConnectedClient { + out := []api.ConnectedClient{} for _, c := range r.Recent() { if c.Device == "Mac" { out = append(out, c) @@ -241,8 +229,8 @@ func (r *Registry) Call(ctx context.Context, name string, req api.ClientRequest) if e.conn == nil { continue } - connected = append(connected, fmt.Sprintf("%s (%s)", e.Host, e.ID)) - if e.ID == name || e.Host == name { + connected = append(connected, fmt.Sprintf("%s (%s)", e.Host, e.Id)) + if e.Id == name || e.Host == name { matches = append(matches, *e) } } @@ -254,7 +242,7 @@ func (r *Registry) Call(ctx context.Context, name string, req api.ClientRequest) if len(matches) > 1 { matched := make([]string, len(matches)) for i, e := range matches { - matched[i] = e.ID + matched[i] = e.Id } slices.Sort(matched) return api.ClientReply{}, fmt.Errorf("%d clients on %s are connected; name one by its id: %s", len(matches), name, strings.Join(matched, ", ")) diff --git a/internal/server/clients.go b/internal/server/clients.go index 8dcad69..980e713 100644 --- a/internal/server/clients.go +++ b/internal/server/clients.go @@ -21,27 +21,25 @@ import ( ) func (s *Server) ConnectClient(w http.ResponseWriter, r *http.Request) { - id := r.Header.Get("Byre-Client") - host := clientHost(r) - if id == "" || host == "" { + c := requestClient(r, actor(r)) + if c.Id == "" || c.Host == "" { writeError(w, http.StatusBadRequest, errors.New("missing Byre-Client or Byre-Host, or Byre-Host isn't percent-encoded")) return } ws, err := websocket.Accept(w, r, nil) if err != nil { - slog.Warn("accept client websocket", "host", host, "err", err) + slog.Warn("accept client websocket", "host", c.Host, "err", err) return } defer ws.CloseNow() - s.clients.Serve(r.Context(), ws, id, actor(r), host, r.Header.Get("Byre-Commit"), r.Header.Get("Byre-Build"), r.Header.Get("Byre-Device")) + s.clients.Serve(r.Context(), ws, c) } func (s *Server) SetClientSync(w http.ResponseWriter, r *http.Request) { - id := r.Header.Get("Byre-Client") - host := clientHost(r) - if id == "" || host == "" { + c := requestClient(r, actor(r)) + if c.Id == "" || c.Host == "" { writeError(w, http.StatusBadRequest, errors.New("missing Byre-Client or Byre-Host, or Byre-Host isn't percent-encoded")) return } @@ -56,9 +54,9 @@ func (s *Server) SetClientSync(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, errors.New("a failed sync needs an error")) return } - slog.Warn("client icloud sync failing", "id", id, "host", host, "error", *sync.Error) + slog.Warn("client icloud sync failing", "id", c.Id, "host", c.Host, "error", *sync.Error) } - s.clients.Synced(id, actor(r), host, r.Header.Get("Byre-Commit"), r.Header.Get("Byre-Build"), sync) + s.clients.Synced(c, sync) s.clientsChanged() w.WriteHeader(http.StatusNoContent) } @@ -86,7 +84,7 @@ func (s *Server) ExecClient(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, errors.New("no client Macs are connected")) return } - name = recent[0].ID + name = recent[0].Id } timeout := time.Duration(req.TimeoutMs) * time.Millisecond diff --git a/internal/server/server.go b/internal/server/server.go index f3454fb..0f53dd8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -203,11 +203,22 @@ func (s *Server) identify(next http.Handler) http.Handler { } func (s *Server) acted(login string, r *http.Request) { - if s.clients.Acted(r.Header.Get("Byre-Client"), login, clientHost(r), r.Header.Get("Byre-Commit"), r.Header.Get("Byre-Build")) { + if s.clients.Acted(requestClient(r, login)) { s.clientsChanged() } } +func requestClient(r *http.Request, login string) api.ConnectedClient { + return api.ConnectedClient{ + Id: r.Header.Get("Byre-Client"), + Login: login, + Host: clientHost(r), + Device: r.Header.Get("Byre-Device"), + Commit: r.Header.Get("Byre-Commit"), + Build: r.Header.Get("Byre-Build"), + } +} + func clientHost(r *http.Request) string { host, err := url.PathUnescape(r.Header.Get("Byre-Host")) if err != nil { @@ -216,24 +227,6 @@ func clientHost(r *http.Request) string { return host } -func (s *Server) connectedClients() []api.ConnectedClient { - out := []api.ConnectedClient{} - for _, c := range s.clients.List() { - out = append(out, api.ConnectedClient{ - Id: c.ID, - Login: c.Login, - Host: c.Host, - Device: c.Device, - Commit: c.Commit, - Build: c.Build, - ConnectedAt: c.ConnectedAt, - LastActionAt: c.LastActionAt, - Sync: c.Sync, - }) - } - return out -} - func (s *Server) clientsChanged() { s.store.Broadcast(api.Event{Kind: "admin.clients", Actor: "byre", Payload: map[string]any{}}) } @@ -720,7 +713,7 @@ func (s *Server) status(ctx context.Context) (api.Status, error) { Projects: c.Projects, ActiveTasks: c.ActiveTasks, SseClients: s.hub.count(), - Clients: s.connectedClients(), + Clients: s.clients.List(), LiveAgents: s.agents.LiveCount(), RunningTurns: usage.Running, RateLimits: usage.RateLimits, diff --git a/internal/store/store.go b/internal/store/store.go index 7136f2d..173def1 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -629,9 +629,7 @@ func (s *Store) ParentMergeBy(ctx context.Context, id string) (string, error) { func (s *Store) ReserveMergeTurn(ctx context.Context, id, sessionID string) (bool, error) { reserved := false err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - res, err := tx.ExecContext(ctx, `UPDATE tasks SET merge_state = 'turn' WHERE id = ? AND merge_state = 'claimed' - AND NOT EXISTS (SELECT 1 FROM agent_sessions s WHERE s.task_id = tasks.id AND s.role = ? AND s.state = ?)`, - id, string(api.Implementer), string(api.AgentSessionStateRunning)) + res, err := tx.ExecContext(ctx, `UPDATE tasks SET merge_state = 'turn' WHERE id = ? AND merge_state = 'claimed' AND NOT `+implementerRunning, id) if err != nil { return err } @@ -1339,28 +1337,18 @@ func (s *Store) PausedSessions(ctx context.Context) ([]api.AgentSession, error) } func (s *Store) SetSessionPaused(ctx context.Context, id string, paused bool, actor string) error { - return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - taskID := "" - projectID := "" - owner := "" - err := tx.QueryRowContext(ctx, ` - SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ? - `, id).Scan(&taskID, &projectID, &owner) - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET paused = ? WHERE id = ?`, paused, id) - if err != nil { - return err - } - return emit(sessionEvent("session.paused", projectID, taskID, owner, id, actor, map[string]any{"paused": paused})) - }) + return s.updateSession(ctx, id, actor, "session.paused", map[string]any{"paused": paused}, `paused = ?`, paused) } func (s *Store) SetSubagents(ctx context.Context, id string, n int, actor string) error { + return s.updateSession(ctx, id, actor, "session.subagents", map[string]any{"subagents": n}, `subagents = ?`, n) +} + +func (s *Store) SetSessionModel(ctx context.Context, id, model, effort, actor string) error { + return s.updateSession(ctx, id, actor, "session.model", map[string]any{"model": model, "effort": effort}, `model = ?, effort = ?`, model, effort) +} + +func (s *Store) updateSession(ctx context.Context, id, actor, kind string, payload map[string]any, set string, args ...any) error { return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { taskID := "" projectID := "" @@ -1374,11 +1362,11 @@ func (s *Store) SetSubagents(ctx context.Context, id string, n int, actor string if err != nil { return err } - _, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET subagents = ? WHERE id = ?`, n, id) + _, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET `+set+` WHERE id = ?`, append(args, id)...) if err != nil { return err } - return emit(sessionEvent("session.subagents", projectID, taskID, owner, id, actor, map[string]any{"subagents": n})) + return emit(sessionEvent(kind, projectID, taskID, owner, id, actor, payload)) }) } @@ -1465,30 +1453,6 @@ func (s *Store) SetVendorSessionID(ctx context.Context, id, vendorSessionID stri }) } -func (s *Store) SetSessionModel(ctx context.Context, id, model, effort, actor string) error { - return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - taskID := "" - projectID := "" - owner := "" - err := tx.QueryRowContext(ctx, ` - SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ? - `, id).Scan(&taskID, &projectID, &owner) - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - - _, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET model = ?, effort = ? WHERE id = ?`, model, effort, id) - if err != nil { - return err - } - - return emit(sessionEvent("session.model", projectID, taskID, owner, id, actor, map[string]any{"model": model, "effort": effort})) - }) -} - type Counts struct { LastEventSeq int64 Projects int diff --git a/mac/Sources/Byre/API.swift b/mac/Sources/Byre/API.swift index 02630e1..6013ee1 100644 --- a/mac/Sources/Byre/API.swift +++ b/mac/Sources/Byre/API.swift @@ -25,6 +25,10 @@ extension ByreTask { var name: String { title.isEmpty ? "New task" : title } + + var isSubtask: Bool { + parentTaskId != nil && workflowStage == nil + } } extension ByreEvent { diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index a8a1c59..49b3926 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -104,10 +104,7 @@ final class ChatModel { } var hasUnmerged: Bool { - guard let f = taskId.flatMap(store.findTask)?.mergeFacts else { - return false - } - return f.unmergedCommits > 0 || f.unmergedFiles > 0 + taskId.flatMap(store.findTask)?.mergeFacts.hasChanges ?? false } private var feed: Feed? { @@ -744,7 +741,7 @@ private struct MergeButton: View { ? "Waiting for the merge in progress in this project to finish" : task.mergeAfterReview ? "Merges when the running review finishes with no findings; open to merge now instead (⌘S)" - : task.parentTaskId != nil && task.workflowStage == nil + : task.isSubtask ? "Merge this subtask into its parent task's branch, or into \(target) (⌘S)" : "The agent merges \(target) in, then byre squashes into one commit on \(target) and pushes it to origin; held while another task in the project is merging (⌘S)" Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : keepOpen == false ? "Merge & complete" : "Merge") { diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index ef427ff..64a6f17 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -83,18 +83,20 @@ var modelChoices: [String: String] = [:] @MainActor var effortChoices: [String: String] = [:] -func vendorOf(_ model: String) -> String { +typealias Vendor = Components.Schemas.AgentSession.VendorPayload + +func vendorOf(_ model: String) -> Vendor { if model.hasPrefix("gpt-") { - return "codex" + return .codex } if model.hasPrefix("gemini-") { - return "antigravity" + return .antigravity } - return "claude" + return .claude } func efforts(_ model: String) -> [String] { - vendorOf(model) == "codex" ? ["", "low", "medium", "high", "xhigh"] : ["", "low", "medium", "high", "xhigh", "max"] + vendorOf(model) == .codex ? ["", "low", "medium", "high", "xhigh"] : ["", "low", "medium", "high", "xhigh", "max"] } func effortLabel(_ effort: String) -> String { @@ -755,7 +757,7 @@ struct ComposerView: View { let live = chat.closed ? chat.session : chat.liveSession let current = live.map { $0.model.isEmpty ? chat.builder.context[$0.id]?.model ?? "" : $0.model } ?? "" let currentEffort = live?.effort ?? "" - let offered = live.map { s in models.filter { vendorOf($0) == s.vendor.rawValue } } ?? models + let offered = live.map { s in models.filter { vendorOf($0) == s.vendor } } ?? models let options = !current.isEmpty && !offered.contains(current) ? [current] + offered : offered let modelKey = "message:\(live?.id ?? key)" let fresh = live == nil ? store.defaultModel : options[0] diff --git a/mac/Sources/Byre/MergeDialog.swift b/mac/Sources/Byre/MergeDialog.swift index 533677a..2f7fdd0 100644 --- a/mac/Sources/Byre/MergeDialog.swift +++ b/mac/Sources/Byre/MergeDialog.swift @@ -4,6 +4,12 @@ import SwiftUI typealias Participant = Components.Schemas.Participant typealias MergeFacts = Components.Schemas.MergeFacts +extension MergeFacts { + var hasChanges: Bool { + unmergedCommits > 0 || unmergedFiles > 0 + } +} + @MainActor private var mergeDrafts: [String: String] = [:] @@ -67,11 +73,11 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays, keepOp return } let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch" - if task.mergeFacts.unmergedCommits == 0 && task.mergeFacts.unmergedFiles == 0 { + if !task.mergeFacts.hasChanges { overlays.showAlert("Nothing to merge: no commits or uncommitted files differ from \(target)") return } - let parent = task.workflowStage == nil ? task.parentTaskId.flatMap { store.findTask($0) } : nil + let parent = task.isSubtask ? task.parentTaskId.flatMap { store.findTask($0) } : nil let title = mergeDrafts[taskId] ?? task.mergeTitle mergeDrafts[taskId] = nil let model = MergeModel(taskId: taskId, target: target, keepOpen: keepOpen, parentTitle: !keepOpen && parent?.status == .active ? parent?.name : nil, title: title) diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 574221d..2b54f0e 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -1,17 +1,6 @@ import ByreAPI import SwiftUI -let tabLabels: [TaskTab: String] = [ - .transcript: "Transcript", - .board: "Board", - .changes: "Changes", - .files: "Files", - .page: "Page", - .captures: "Captures", - .terminal: "Terminal", - .workflow: "Workflow", -] - #if os(macOS) struct HalfSplit: Layout { func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { @@ -362,7 +351,7 @@ struct TabStrip: View { } let run = store.findTask(taskId)?.workflow != nil let team = !store.team(taskId).isEmpty - let order = taskTabs.filter { t in + let order = TaskTab.allCases.filter { t in if run { return t == .workflow } @@ -382,7 +371,7 @@ struct TabStrip: View { var body: some View { let info = info() - FoldingTabs(tabs: info.order.map { ($0, tabLabels[$0]!) }, active: info.tab) { t in + FoldingTabs(tabs: info.order.map { ($0, $0.label) }, active: info.tab) { t in store.openTab(t) } .padding(.horizontal, 12) diff --git a/mac/Sources/Byre/Sheet.swift b/mac/Sources/Byre/Sheet.swift index a272f8f..36f4866 100644 --- a/mac/Sources/Byre/Sheet.swift +++ b/mac/Sources/Byre/Sheet.swift @@ -125,7 +125,7 @@ private struct SheetPanel: View { private func title(_ task: ByreTask?) -> String { let label = switch sheet.view { - case .tab(let t): tabLabels[t]! + case .tab(let t): t.label case .image: "Image" case .diagram: "Diagram" } diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index b5f4b43..85176bf 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -2,11 +2,13 @@ import Foundation import Observation import SwiftData -enum TaskTab: String { +enum TaskTab: String, CaseIterable { case transcript, board, changes, files, page, captures, terminal, workflow -} -let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .page, .captures, .terminal, .workflow] + var label: String { + rawValue.capitalized + } +} enum JumpTarget: Equatable { case question(String) @@ -614,7 +616,7 @@ final class Store { let kind = m?[1] ?? nil let id = m?[2] ?? nil let name = m?[3] ?? nil - let tab = taskTabs.first { $0.rawValue == name } + let tab = name.flatMap(TaskTab.init(rawValue:)) if kind == "task", let id { reveals += 1 if let tab, tab != .transcript, tab != .workflow { diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 002294a..e8e3609 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -38,7 +38,7 @@ func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { break } Task { - if let task = store.findTask(taskId), task.status == .active, task.state == .discardSuggested || (task.mergeFacts.unmergedCommits == 0 && task.mergeFacts.unmergedFiles == 0) { + if let task = store.findTask(taskId), task.status == .active, task.state == .discardSuggested || !task.mergeFacts.hasChanges { await confirmDiscard(task, store, overlays) return }