Track fixed and ignored review findings

This commit is contained in:
Ian Gulliver
2026-10-16 10:44:27 -07:00
parent 0f2415570c
commit 8200ffd17e
20 changed files with 569 additions and 243 deletions
+38 -1
View File
@@ -570,6 +570,27 @@ paths:
items: { $ref: "#/components/schemas/Review" }
"404": { $ref: "#/components/responses/Error" }
/api/reviews/{reviewId}/findings/{index}:
parameters:
- { name: reviewId, in: path, required: true, schema: { type: string } }
- { name: index, in: path, required: true, schema: { type: integer }, description: "The finding's position in the review's findings, from 0." }
put:
operationId: setFindingState
description: Records that the human addressed a finding of a done review, by sending it to the agent to fix or by ignoring it.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/FindingStateUpdate" }
responses:
"200":
description: The review.
content:
application/json:
schema: { $ref: "#/components/schemas/Review" }
"400": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
/api/tasks/{taskId}/snapshots/{n}/fork:
parameters:
- { name: taskId, in: path, required: true, schema: { type: string } }
@@ -1147,7 +1168,7 @@ components:
Review:
type: object
description: An agent session on the vendor the task's agent doesn't use, reviewing one snapshot's change.
required: [id, task_id, snapshot_n, agent_session_id, vendor, model, state, error, created_at]
required: [id, task_id, snapshot_n, agent_session_id, vendor, model, state, error, created_at, finding_states]
properties:
id: { type: string }
task_id: { type: string }
@@ -1164,6 +1185,22 @@ components:
error: { type: string, description: "failed: why." }
created_at: { type: string, format: date-time }
finished_at: { type: string, format: date-time }
finding_states:
type: object
description: "How the human addressed each finding, keyed by its index in result.findings; a finding not listed is open."
additionalProperties: { $ref: "#/components/schemas/FindingState" }
FindingState:
type: string
enum: [fixed, ignored]
x-enum-varnames: [FindingFixed, FindingIgnored]
description: "fixed: sent to the task's agent to fix. ignored: dismissed, and left out of the Merge warning."
FindingStateUpdate:
type: object
required: [state]
properties:
state: { $ref: "#/components/schemas/FindingState" }
AgentSession:
type: object
+3 -3
View File
@@ -29,7 +29,7 @@ Every task worktree is made by `createTask` (`internal/agents/tasks.go`): new ta
## Closing a task
A task ends `merged` or `discarded`; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record, summary and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a confirmation, opened just below the button like the task picker's switcher (centred when ⌘S opens Merge's from a tab without the button), and are disabled while the task's session is `running`. Merge is also disabled until `unmerged` reports a commit or a changed file; the transcript fetches it when the task is opened and again shortly after each tool result or turn end. Merge's confirmation (`confirmMerge` in `web/src/task.ts`) is kept brief: the commit title as text with a ✎ button that swaps in an input, a collapsed "N co-authors" line holding the trailers, and one warning line for each thing worth stopping for. Those are the latest snapshot's review still running, failed, missing or with findings (counted by severity); the task's open inbox questions, which merging dismisses; the worktree differing from the latest snapshot (`changes?since=` its commit), or no snapshot at all; and "Review status unavailable" when those fetches fail. Earlier snapshots' reviews aren't mentioned. Warnings never block Merge. The review and question warnings link to the Reviews tab at that snapshot or the Inbox tab; following one closes the dialog and keeps the title in the page, so the next Merge opens with it. While open, the dialog refetches on the task's `snapshot.created` and `review.*` events and redraws on inbox changes.
A task ends `merged` or `discarded`; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record, summary and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a confirmation, opened just below the button like the task picker's switcher (centred when ⌘S opens Merge's from a tab without the button), and are disabled while the task's session is `running`. Merge is also disabled until `unmerged` reports a commit or a changed file; the transcript fetches it when the task is opened and again shortly after each tool result or turn end. Merge's confirmation (`confirmMerge` in `web/src/task.ts`) is kept brief: the commit title as text with a ✎ button that swaps in an input, a collapsed "N co-authors" line holding the trailers, and one warning line for each thing worth stopping for. Those are the latest snapshot's review still running, failed, missing or with findings that weren't ignored (counted by severity); the task's open inbox questions, which merging dismisses; the worktree differing from the latest snapshot (`changes?since=` its commit), or no snapshot at all; and "Review status unavailable" when those fetches fail. Earlier snapshots' reviews aren't mentioned. Warnings never block Merge. The review and question warnings link to the Reviews tab at that snapshot or the Inbox tab; following one closes the dialog and keeps the title in the page, so the next Merge opens with it. While open, the dialog refetches on the task's `snapshot.created` and `review.*` events and redraws on inbox changes.
A task's summary is what its agent writes, before the task closes, about the whole task: what it set out to do, what was built or tried, the key decisions and why, and what was left out. The agent sets it with the `set_task_summary` MCP tool (`internal/agents/summary.go`), any time while the task is active; each call replaces `tasks.summary` and records a `task.summary` event carrying it, which the transcript shows as a note. The Merge turn's prompt and the discard turn's prompt (`summaryRequest`) both ask for it. Turn summaries don't stand in for it: each covers only its own turn, and a merged task's last one is always the Merge turn's. Tasks closed before the column existed have none. `sqlite3 byre.db "SELECT id, status, summary FROM tasks WHERE status != 'active' ORDER BY closed_at DESC LIMIT 5"` shows recent ones.
@@ -285,8 +285,8 @@ Every new snapshot of a task gets a review: a separate agent session on the vend
- **Running.** Reviewer sessions get `reviewGuidance` and only the `submit_review` tool, whose arguments (`reviewSchema`: `change`, a few sentences of Markdown on what the change does, required and non-empty; a Markdown `summary`, which the guidance and schema require to be exactly "No significant findings." when there are no findings and otherwise a bulleted list with one line per finding; `findings`, each with `severity`, `category`, `title`, `detail`, `path`, `line`, `end_line` and `images`; and top-level `images`) become the review's `result` and mark it `done`. The guidance encourages, without requiring, Mermaid blocks for a data flow, call graph, state machine or schema the change alters, and screenshots when the change alters something visible.
- **Screenshots.** Reviewer sessions also get the Playwright MCP server (`playwrightMCP` in `internal/agents/review.go`: `npx @playwright/mcp` at a pinned version, headless Chrome, isolated profile, absolute file paths), passed to Claude as a `stdio` entry in `--mcp-config` and to Codex as `mcp_servers` in the thread's `config`. Its automatically named files go to `worktrees/<project-id>/review-<review-id>-media`, removed with the checkout. Each `images` entry is `{path, caption}`, the path absolute or relative to the checkout; at `submit_review` byre reads every one, refuses the whole submission (nothing recorded) for a missing file or one that isn't one of `store.ImageTypes` within `store.MaxBlobBytes`, then stores each as a blob and replaces its `path` with `blob`, since the files are deleted with the checkout. They run without the Stop-hook bounce, mid-turn delivery, end-of-turn commit, snapshots or turn summaries, and their `user.message` events carry no `head`. When the reviewer's turn ends byre completes its session, kills its process and removes the worktree; a review still `running` then fails with why (no submission, with the final message; an error; an interrupt). A process that exits mid-turn fails the review the same way. Merging or discarding the task fails its running reviews ("the task was closed") and ends their reviewers. On startup, a running reviewer resumes like any session, and a running review whose reviewer isn't `running` fails.
- **Questions.** A question's `reviews` is the review of the first snapshot the task took after the question was filed, the one at the end of the turn that asked it, computed when the item is read; the Inbox tab shows it under the question once it starts.
- **Events.** `review.started` and `review.finished` (state `done`, `failed` or `skipped`; a skipped review emits only `review.finished`) carry the whole review, with the task's ids and no `agent_session_id`. A reviewer's own events carry its session id and the task's ids: the transcript skips every event of a session whose `session.created` has `role` `reviewer`, and draws each `review.finished` as a note with its outcome; the lead's `read_transcript` leaves the reviewer's events out and shows each `review.finished` with its summary and findings (`reviewLine`). `GET /api/tasks/{taskId}/sessions` and everything that looks for the task's agent (`ListSessions`) see only implementer sessions.
- **UI.** The **Reviews** tab (`web/src/reviews.ts`) shows one review at a time, picked by snapshot (see "Snapshots" for the picker): its snapshot, state or severity counts, error, summary, screenshots and findings. The `change` field is not shown; it goes to the lead and into notes. A finding with `path` and `line` shows, under its title, the lines `line` to `end_line` plus 3 either side from that file's diff for the snapshot's step (`changes?snapshot=<n>&step=true`, the diff the reviewer was given), with new-file line numbers and the finding's own lines marked; it shows nothing when those lines aren't in the diff, and "diff not available" with the error once the task is closed. Screenshots link to the full image. Each finding has a **fix** button (also on review cards in the inbox) that sends the finding's severity, title, location and detail to the task's agent as a message from the user: through `POST /api/sessions/{id}/messages` to its latest live session (queued like any message while the agent is mid-turn, see "Agent sessions"), or starting a session when it has none. Once sent, the right pane switches to that task's Transcript tab. It loads when it opens, when the task changes, and on each `review.*` event in the task's stream. The tab follows the latest `review.*` event of the highest-numbered snapshot: while that review runs, the tab label (and its entry in the More menu) carries a spinner in place of any earlier review's count; once it finishes with findings, the tab (or the More menu holding it) is highlighted with a badge of the finding count. Each snapshot row's **review** button carries a mark over its top-right corner from that snapshot's latest `review.*` event: a spinner while the review runs, a grey ● when it was skipped, a green ✓ when it finished with no findings, and, when it has findings, the button highlighted with the count in a badge. A failed review leaves the button unmarked. A Task's `reviewing` is true while any of its reviews is `running`, and the global stream's `review.*` events reload that project's tasks; the task switcher and the Project tab's task list show **reviewing**, with the running dot, in place of an `idle` session state (`taskState` in `web/src/state.ts`). `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones.
- **Events.** `review.started`, `review.finished` (state `done`, `failed` or `skipped`; a skipped review emits only `review.finished`) and `review.finding` (a finding's state set, with the user as actor) carry the whole review, with the task's ids and no `agent_session_id`. A reviewer's own events carry its session id and the task's ids: the transcript skips every event of a session whose `session.created` has `role` `reviewer`, and draws each `review.finished` as a note with its outcome; the lead's `read_transcript` leaves the reviewer's events out and shows each `review.finished` with its summary and findings (`reviewLine`). `GET /api/tasks/{taskId}/sessions` and everything that looks for the task's agent (`ListSessions`) see only implementer sessions.
- **UI.** The **Reviews** tab (`web/src/reviews.ts`) shows one review at a time, picked by snapshot (see "Snapshots" for the picker): its snapshot, state or severity counts, error, summary, screenshots and findings. The `change` field is not shown; it goes to the lead and into notes. A finding with `path` and `line` shows, under its title, the lines `line` to `end_line` plus 3 either side from that file's diff for the snapshot's step (`changes?snapshot=<n>&step=true`, the diff the reviewer was given), with new-file line numbers and the finding's own lines marked; it shows nothing when those lines aren't in the diff, and "diff not available" with the error once the task is closed. Screenshots link to the full image. Each open finding has **fix** and **ignore** buttons (also on review cards in the inbox). **fix** sends the finding's severity, title, location and detail to the task's agent as a message from the user: through `POST /api/sessions/{id}/messages` to its latest live session (queued like any message while the agent is mid-turn, see "Agent sessions"), or starting a session when it has none; the view stays where it is, so several findings can be sent in a row. Then it, or **ignore** on its own, records the finding's state (`fixed` or `ignored`) with `PUT /api/reviews/{reviewId}/findings/{index}`, the index being the finding's position in `result.findings`; the review's `finding_states` (the `reviews.finding_states` column, a JSON object from index to state) holds them, and the buttons give way to "fix sent" or "ignored". Only a `done` review's findings take a state, and there is no way to clear one. The tab loads when it opens, when the task changes, and on each `review.*` event in the task's stream. The tab follows the latest `review.*` event of the highest-numbered snapshot: while that review runs, the tab label (and its entry in the More menu) carries a spinner in place of any earlier review's count; once it finishes with open findings (those with no state), the tab (or the More menu holding it) is highlighted with a badge of their count. Each snapshot row's **review** button carries a mark over its top-right corner from that snapshot's latest `review.*` event: a spinner while the review runs, a grey ● when it was skipped, a green ✓ when it finished with no open findings, and, when it has open findings, the button highlighted with their count in a badge. A failed review leaves the button unmarked. A Task's `reviewing` is true while any of its reviews is `running`, and the global stream's `review.*` events reload that project's tasks; the task switcher and the Project tab's task list show **reviewing**, with the running dot, in place of an `idle` session state (`taskState` in `web/src/state.ts`). `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones.
## Workflows
+262 -185
View File
@@ -183,6 +183,24 @@ func (e FileChangeStatus) Valid() bool {
}
}
// Defines values for FindingState.
const (
FindingFixed FindingState = "fixed"
FindingIgnored FindingState = "ignored"
)
// Valid indicates whether the value is a known member of the FindingState enum.
func (e FindingState) Valid() bool {
switch e {
case FindingFixed:
return true
case FindingIgnored:
return true
default:
return false
}
}
// Defines values for InboxItemKind.
const (
Question InboxItemKind = "question"
@@ -583,6 +601,15 @@ type FileDiff struct {
Path string `json:"path"`
}
// FindingState fixed: sent to the task's agent to fix. ignored: dismissed, and left out of the Merge warning.
type FindingState string
// FindingStateUpdate defines model for FindingStateUpdate.
type FindingStateUpdate struct {
// State fixed: sent to the task's agent to fix. ignored: dismissed, and left out of the Merge warning.
State FindingState `json:"state"`
}
// Focus defines model for Focus.
type Focus struct {
// ProjectId The project the right pane shows; the task's project when task_id is set
@@ -760,9 +787,12 @@ type Review struct {
CreatedAt time.Time `json:"created_at"`
// Error failed: why.
Error string `json:"error"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Id string `json:"id"`
Error string `json:"error"`
// FindingStates How the human addressed each finding, keyed by its index in result.findings; a finding not listed is open.
FindingStates map[string]FindingState `json:"finding_states"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Id string `json:"id"`
// Model Empty when skipped.
Model string `json:"model"`
@@ -1156,6 +1186,9 @@ type CreateTaskJSONRequestBody = TaskCreate
// StartTodoItemJSONRequestBody defines body for StartTodoItem for application/json ContentType.
type StartTodoItemJSONRequestBody = TodoStart
// SetFindingStateJSONRequestBody defines body for SetFindingState for application/json ContentType.
type SetFindingStateJSONRequestBody = FindingStateUpdate
// SendMessageJSONRequestBody defines body for SendMessage for application/json ContentType.
type SendMessageJSONRequestBody = MessageCreate
@@ -1234,6 +1267,9 @@ type ServerInterface interface {
// (POST /api/projects/{projectId}/todo/tasks)
StartTodoItem(w http.ResponseWriter, r *http.Request, projectId string)
// (PUT /api/reviews/{reviewId}/findings/{index})
SetFindingState(w http.ResponseWriter, r *http.Request, reviewId string, index int)
// (GET /api/sessions/{sessionId})
GetSession(w http.ResponseWriter, r *http.Request, sessionId string)
@@ -1831,6 +1867,41 @@ func (siw *ServerInterfaceWrapper) StartTodoItem(w http.ResponseWriter, r *http.
handler.ServeHTTP(w, r)
}
// SetFindingState operation middleware
func (siw *ServerInterfaceWrapper) SetFindingState(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "reviewId" -------------
var reviewId string
err = runtime.BindStyledParameterWithOptions("simple", "reviewId", r.PathValue("reviewId"), &reviewId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "reviewId", Err: err})
return
}
// ------------- Path parameter "index" -------------
var index int
err = runtime.BindStyledParameterWithOptions("simple", "index", r.PathValue("index"), &index, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "index", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.SetFindingState(w, r, reviewId, index)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetSession operation middleware
func (siw *ServerInterfaceWrapper) GetSession(w http.ResponseWriter, r *http.Request) {
@@ -2911,6 +2982,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/previews", wrapper.ListPreviews)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots", wrapper.ListSnapshots)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/reviews", wrapper.ListReviews)
m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/reviews/{reviewId}/findings/{index}", wrapper.SetFindingState)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots/{n}/fork", wrapper.ForkSnapshot)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.CreateSession)
@@ -2938,188 +3010,193 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
"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",
"GXubU/QY8p2KG3/69H8DAAD//w==",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+30
View File
@@ -1,7 +1,10 @@
package server
import (
"errors"
"net/http"
"g.fc.run/misc/byre/internal/api"
)
func (s *Server) ListReviews(w http.ResponseWriter, r *http.Request, taskID string) {
@@ -18,3 +21,30 @@ func (s *Server) ListReviews(w http.ResponseWriter, r *http.Request, taskID stri
}
writeJSON(w, http.StatusOK, reviews)
}
func (s *Server) SetFindingState(w http.ResponseWriter, r *http.Request, reviewID string, index int) {
req := api.FindingStateUpdate{}
if !readJSON(w, r, &req) {
return
}
if !req.State.Valid() {
writeError(w, http.StatusBadRequest, errors.New("state must be fixed or ignored"))
return
}
if index < 0 {
writeError(w, http.StatusBadRequest, errors.New("index must not be negative"))
return
}
err := s.store.SetFindingState(r.Context(), reviewID, index, req.State, actor(r))
if err != nil {
writeStoreError(w, err)
return
}
review, err := s.store.GetReview(r.Context(), reviewID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, review)
}
+33 -7
View File
@@ -10,7 +10,7 @@ import (
"g.fc.run/misc/byre/internal/api"
)
const reviewColumns = `id, task_id, snapshot_n, coalesce(agent_session_id, ''), vendor, model, state, result, error, created_at, finished_at`
const reviewColumns = `id, task_id, snapshot_n, coalesce(agent_session_id, ''), vendor, model, state, result, error, created_at, finished_at, finding_states`
func (s *Store) CreateReview(ctx context.Context, r api.Review) error {
finishedAt := ""
@@ -26,9 +26,9 @@ func (s *Store) CreateReview(ctx context.Context, r api.Review) error {
return err
}
if r.State == api.ReviewStateRunning {
return emitReview(ctx, tx, emit, "review.started", r.Id)
return emitReview(ctx, tx, emit, "review.started", r.Id, "byre")
}
return emitReview(ctx, tx, emit, "review.finished", r.Id)
return emitReview(ctx, tx, emit, "review.finished", r.Id, "byre")
})
}
@@ -86,7 +86,27 @@ func (s *Store) FinishReview(ctx context.Context, id string, state api.ReviewSta
if n == 0 {
return ErrNotFound
}
return emitReview(ctx, tx, emit, "review.finished", id)
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)
})
}
@@ -123,7 +143,7 @@ func (s *Store) FirstTaskMessage(ctx context.Context, taskID string) (string, er
return text, err
}
func emitReview(ctx context.Context, tx *sql.Tx, emit emitFunc, kind, id string) error {
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
@@ -133,7 +153,7 @@ func emitReview(ctx context.Context, tx *sql.Tx, emit emitFunc, kind, id string)
if err != nil {
return err
}
return emit(api.Event{Kind: kind, ProjectId: &projectID, TaskId: &r.TaskId, Actor: "byre", Payload: ToPayload(r)})
return emit(api.Event{Kind: kind, ProjectId: &projectID, TaskId: &r.TaskId, Actor: actor, Payload: ToPayload(r)})
}
func questionReviews(ctx context.Context, q querier, item api.InboxItem) ([]api.Review, error) {
@@ -171,11 +191,17 @@ func scanReview(row scanner) (api.Review, error) {
result := ""
createdAt := ""
finishedAt := ""
err := row.Scan(&r.Id, &r.TaskId, &r.SnapshotN, &r.AgentSessionId, &r.Vendor, &r.Model, &state, &result, &r.Error, &createdAt, &finishedAt)
findingStates := ""
err := row.Scan(&r.Id, &r.TaskId, &r.SnapshotN, &r.AgentSessionId, &r.Vendor, &r.Model, &state, &result, &r.Error, &createdAt, &finishedAt, &findingStates)
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
}
if result != "" {
res := map[string]any{}
err = json.Unmarshal([]byte(result), &res)
+4
View File
@@ -425,6 +425,10 @@ UPDATE tasks SET discard_state = 'held' WHERE discarding_by != '' AND status = '
ALTER TABLE tasks ADD COLUMN parent_task_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN parent_merge_by TEXT NOT NULL DEFAULT '';
CREATE INDEX tasks_parent ON tasks(parent_task_id) WHERE parent_task_id != '';
`}, {"add-review-finding-states", `
ALTER TABLE reviews ADD COLUMN finding_states TEXT NOT NULL DEFAULT '{}';
UPDATE events SET payload = json_set(payload, '$.finding_states', json('{}'))
WHERE kind IN ('review.started', 'review.finished') AND json_type(payload, '$.finding_states') IS NULL;
`}}
const (
+1 -1
View File
@@ -115,7 +115,7 @@ export function taskPane() {
if (t === "reviews" && reviewing) {
return h("span", { class: "spinner", title: "review in progress" });
}
return count(t) > 0 && h("span", { class: "badge", title: t === "inbox" ? "inbox items" : "findings in the latest review" }, count(t));
return count(t) > 0 && h("span", { class: "badge", title: t === "inbox" ? "inbox items" : "open findings in the latest review" }, count(t));
};
const layout = () => {
for (const { button } of buttons) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+45 -14
View File
@@ -1,7 +1,7 @@
import { get, post } from "./api.js";
import { get, post, put } from "./api.js";
import { ago, errorText, h, replace, showAlert } from "./dom.js";
import { markdown } from "./markdown.js";
import { focusTask, onEach, pickReview, state } from "./state.js";
import { onEach, pickReview, state } from "./state.js";
const severities = ["critical", "high", "medium", "low", "info"];
export function isReviewEvent(ev) {
return ev.kind.startsWith("review.");
@@ -11,14 +11,19 @@ function findingsOf(r) {
return Array.isArray(res["findings"]) ? res["findings"] : [];
}
export function findingCount(r) {
return r.state === "done" ? findingsOf(r).length : 0;
return r.state === "done" ? findingsOf(r).filter((_, i) => !r.finding_states[String(i)]).length : 0;
}
export function unignoredFindings(r) {
return r.state === "done" ? findingsOf(r).filter((_, i) => r.finding_states[String(i)] !== "ignored") : [];
}
export function latestReview(events) {
const all = events.filter(isReviewEvent).map((ev) => ev.payload);
return all.reduce((best, r) => (!best || r.snapshot_n >= best.snapshot_n ? r : best), undefined);
}
export function reviewCounts(r) {
const findings = findingsOf(r);
return severityCounts(findingsOf(r));
}
export function severityCounts(findings) {
const parts = severities.map((s) => [s, findings.filter((f) => f["severity"] === s).length]).filter(([, n]) => n > 0);
return parts.length > 0 ? parts.map(([s, n]) => `${n} ${s}`).join(", ") : "no findings";
}
@@ -42,13 +47,13 @@ function reviewResult(r, taskId) {
const findings = findingsOf(r);
const md = { taskId };
return h("div", { class: "review-result" }, markdown(String(res["summary"] ?? ""), "md review-summary", md), reviewImages(res["images"]), findings.length > 0
? h("ol", { class: "findings" }, ...findings.map((f) => {
? h("ol", { class: "findings" }, ...findings.map((f, i) => {
const severity = String(f["severity"] ?? "");
const path = typeof f["path"] === "string" ? f["path"] : "";
const line = typeof f["line"] === "number" && f["line"] > 0 ? f["line"] : 0;
const end = typeof f["end_line"] === "number" && f["end_line"] > line ? f["end_line"] : line;
const where = path ? `${path}${line ? `:${line}${end > line ? `–${end}` : ""}` : ""}` : "";
return h("li", { class: `finding ${severity}` }, h("div", { class: "finding-head" }, h("span", { class: `severity ${severity}` }, severity), typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null, h("span", { class: "finding-title" }, String(f["title"] ?? "")), where ? h("code", { class: "finding-where" }, where) : null, taskId ? fixButton(taskId, r, f, where) : null), taskId && path && line ? findingDiff(taskId, r.snapshot_n, path, line, end) : null, markdown(String(f["detail"] ?? ""), "md finding-detail", md), reviewImages(f["images"]));
return h("li", { class: `finding ${severity}` }, h("div", { class: "finding-head" }, h("span", { class: `severity ${severity}` }, severity), typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null, h("span", { class: "finding-title" }, String(f["title"] ?? "")), where ? h("code", { class: "finding-where" }, where) : null, taskId ? findingActions(taskId, r, i, f, where) : null), taskId && path && line ? findingDiff(taskId, r.snapshot_n, path, line, end) : null, markdown(String(f["detail"] ?? ""), "md finding-detail", md), reviewImages(f["images"]));
}))
: null);
}
@@ -139,11 +144,21 @@ function findingDiff(taskId, n, path, line, end) {
}, (err) => replace(box, h("div", { class: "hint" }, `diff not available: ${errorText(err)}`)));
return box;
}
function fixButton(taskId, r, f, where) {
const button = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
button.onclick = async () => {
function findingActions(taskId, r, index, f, where) {
const box = h("span", { class: "finding-actions" });
const done = (s) => replace(box, h("span", { class: "finding-done" }, s === "fixed" ? "fix sent" : "ignored"));
const current = r.finding_states[String(index)];
if (current) {
done(current);
return box;
}
const mark = (s) => put("/api/reviews/{reviewId}/findings/{index}", { path: { reviewId: r.id, index }, body: { state: s } });
const fix = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
const ignore = h("button", { type: "button", class: "finding-ignore", title: "Mark this finding done without fixing it; Merge won't warn about it" }, "ignore");
fix.onclick = async () => {
const text = `Fix this finding from the review of snapshot #${r.snapshot_n}:\n\n**[${String(f["severity"] ?? "")}] ${String(f["title"] ?? "")}**${where ? ` (\`${where}\`)` : ""}\n\n${String(f["detail"] ?? "")}`;
button.disabled = true;
fix.disabled = true;
ignore.disabled = true;
try {
const sessions = await get("/api/tasks/{taskId}/sessions", { path: { taskId } });
const live = sessions.findLast((s) => s.state !== "completed");
@@ -153,15 +168,31 @@ function fixButton(taskId, r, f, where) {
else {
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: text } });
}
replace(button, "sent");
focusTask(taskId);
}
catch (err) {
button.disabled = false;
fix.disabled = false;
ignore.disabled = false;
void showAlert(errorText(err));
return;
}
done("fixed");
await mark("fixed").catch((err) => showAlert(`fix sent, but not recorded: ${errorText(err)}`));
};
ignore.onclick = async () => {
fix.disabled = true;
ignore.disabled = true;
try {
await mark("ignored");
done("ignored");
}
catch (err) {
fix.disabled = false;
ignore.disabled = false;
void showAlert(errorText(err));
}
};
return button;
replace(box, fix, ignore);
return box;
}
export function mountReviews(root) {
const head = h("header", { class: "reviews-header" });
+1 -1
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -292,6 +292,11 @@ export function handleEvent(ev) {
void loadInbox();
}
break;
case "review.finding":
if (state.inbox.some((i) => i.task_id === ev.task_id && i.kind === "question")) {
void loadInbox();
}
break;
case "focus.updated":
if (ev.actor !== state.me?.login) {
applyFocus(ev.payload);
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -2,7 +2,7 @@ import { get, post } from "./api.js";
import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts } from "./reviews.js";
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { currentSession, findTask, focusTab, focusTask, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state } from "./state.js";
import { taskStyle } from "./taskcolor.js";
@@ -377,7 +377,7 @@ export function mountChat(root, src, makeHead) {
put(snapRow(p, ctx));
continue;
}
if (ev.kind === "review.started" || ev.kind === "review.finished") {
if (ev.kind === "review.started" || ev.kind === "review.finished" || ev.kind === "review.finding") {
const r = p;
const button = ctx.reviewButtons.get(r.snapshot_n);
if (button) {
@@ -797,10 +797,10 @@ function markReview(button, r) {
return;
}
if (n === 0) {
replace(button, "review", h("span", { class: "review-mark clean", title: "no findings" }, "✓"));
replace(button, "review", h("span", { class: "review-mark clean", title: Object.keys(r.finding_states).length > 0 ? "every finding addressed" : "no findings" }, "✓"));
return;
}
replace(button, "review", h("span", { class: "badge", title: `${n} findings` }, n));
replace(button, "review", h("span", { class: "badge", title: `${n} open findings` }, n));
}
function snapRow(snap, ctx) {
const taskId = ctx.taskId;
@@ -1142,8 +1142,8 @@ async function reviewWarnings(taskId, leave) {
else if (r.state === "failed") {
items.push(mergeWarning("Review of the latest snapshot failed", open));
}
else if (findingCount(r) > 0) {
items.push(mergeWarning(`Review of the latest snapshot found ${reviewCounts(r)}`, open));
else if (unignoredFindings(r).length > 0) {
items.push(mergeWarning(`Review of the latest snapshot found ${severityCounts(unignoredFindings(r))}`, open));
}
const changes = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { since: latest.commit_sha } });
if (changes.files.length > 0) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+63
View File
@@ -518,6 +518,27 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/reviews/{reviewId}/findings/{index}": {
parameters: {
query?: never;
header?: never;
path: {
reviewId: string;
/** @description The finding's position in the review's findings, from 0. */
index: number;
};
cookie?: never;
};
get?: never;
/** @description Records that the human addressed a finding of a done review, by sending it to the agent to fix or by ignoring it. */
put: operations["setFindingState"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/snapshots/{n}/fork": {
parameters: {
query?: never;
@@ -1061,6 +1082,18 @@ export interface components {
created_at: string;
/** Format: date-time */
finished_at?: string;
/** @description How the human addressed each finding, keyed by its index in result.findings; a finding not listed is open. */
finding_states: {
[key: string]: components["schemas"]["FindingState"];
};
};
/**
* @description fixed: sent to the task's agent to fix. ignored: dismissed, and left out of the Merge warning.
* @enum {string}
*/
FindingState: "fixed" | "ignored";
FindingStateUpdate: {
state: components["schemas"]["FindingState"];
};
AgentSession: {
id: string;
@@ -2115,6 +2148,36 @@ export interface operations {
404: components["responses"]["Error"];
};
};
setFindingState: {
parameters: {
query?: never;
header?: never;
path: {
reviewId: string;
/** @description The finding's position in the review's findings, from 0. */
index: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["FindingStateUpdate"];
};
};
responses: {
/** @description The review. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Review"];
};
};
400: components["responses"]["Error"];
404: components["responses"]["Error"];
};
};
forkSnapshot: {
parameters: {
query?: never;
+1 -1
View File
@@ -133,7 +133,7 @@ export function taskPane(): HTMLElement {
if (t === "reviews" && reviewing) {
return h("span", { class: "spinner", title: "review in progress" });
}
return count(t) > 0 && h("span", { class: "badge", title: t === "inbox" ? "inbox items" : "findings in the latest review" }, count(t));
return count(t) > 0 && h("span", { class: "badge", title: t === "inbox" ? "inbox items" : "open findings in the latest review" }, count(t));
};
const layout = () => {
+47 -14
View File
@@ -1,9 +1,10 @@
import { get, post, type ByreEvent, type Schemas } from "./api.js";
import { get, post, put, type ByreEvent, type Schemas } from "./api.js";
import { ago, errorText, h, replace, showAlert } from "./dom.js";
import { markdown } from "./markdown.js";
import { focusTask, onEach, pickReview, state } from "./state.js";
import { onEach, pickReview, state } from "./state.js";
type Review = Schemas["Review"];
type FindingState = Schemas["FindingState"];
const severities = ["critical", "high", "medium", "low", "info"];
@@ -17,7 +18,11 @@ function findingsOf(r: Review): Record<string, unknown>[] {
}
export function findingCount(r: Review): number {
return r.state === "done" ? findingsOf(r).length : 0;
return r.state === "done" ? findingsOf(r).filter((_, i) => !r.finding_states[String(i)]).length : 0;
}
export function unignoredFindings(r: Review): Record<string, unknown>[] {
return r.state === "done" ? findingsOf(r).filter((_, i) => r.finding_states[String(i)] !== "ignored") : [];
}
export function latestReview(events: ByreEvent[]): Review | undefined {
@@ -26,7 +31,10 @@ export function latestReview(events: ByreEvent[]): Review | undefined {
}
export function reviewCounts(r: Review): string {
const findings = findingsOf(r);
return severityCounts(findingsOf(r));
}
export function severityCounts(findings: Record<string, unknown>[]): string {
const parts = severities.map((s) => [s, findings.filter((f) => f["severity"] === s).length] as const).filter(([, n]) => n > 0);
return parts.length > 0 ? parts.map(([s, n]) => `${n} ${s}`).join(", ") : "no findings";
}
@@ -73,7 +81,7 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
? h(
"ol",
{ class: "findings" },
...findings.map((f) => {
...findings.map((f, i) => {
const severity = String(f["severity"] ?? "");
const path = typeof f["path"] === "string" ? f["path"] : "";
const line = typeof f["line"] === "number" && f["line"] > 0 ? f["line"] : 0;
@@ -89,7 +97,7 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null,
h("span", { class: "finding-title" }, String(f["title"] ?? "")),
where ? h("code", { class: "finding-where" }, where) : null,
taskId ? fixButton(taskId, r, f, where) : null,
taskId ? findingActions(taskId, r, i, f, where) : null,
),
taskId && path && line ? findingDiff(taskId, r.snapshot_n, path, line, end) : null,
markdown(String(f["detail"] ?? ""), "md finding-detail", md),
@@ -205,11 +213,21 @@ function findingDiff(taskId: string, n: number, path: string, line: number, end:
return box;
}
function fixButton(taskId: string, r: Review, f: Record<string, unknown>, where: string): HTMLElement {
const button = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
button.onclick = async () => {
function findingActions(taskId: string, r: Review, index: number, f: Record<string, unknown>, where: string): HTMLElement {
const box = h("span", { class: "finding-actions" });
const done = (s: FindingState) => replace(box, h("span", { class: "finding-done" }, s === "fixed" ? "fix sent" : "ignored"));
const current = r.finding_states[String(index)];
if (current) {
done(current);
return box;
}
const mark = (s: FindingState) => put("/api/reviews/{reviewId}/findings/{index}", { path: { reviewId: r.id, index }, body: { state: s } });
const fix = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
const ignore = h("button", { type: "button", class: "finding-ignore", title: "Mark this finding done without fixing it; Merge won't warn about it" }, "ignore");
fix.onclick = async () => {
const text = `Fix this finding from the review of snapshot #${r.snapshot_n}:\n\n**[${String(f["severity"] ?? "")}] ${String(f["title"] ?? "")}**${where ? ` (\`${where}\`)` : ""}\n\n${String(f["detail"] ?? "")}`;
button.disabled = true;
fix.disabled = true;
ignore.disabled = true;
try {
const sessions = await get("/api/tasks/{taskId}/sessions", { path: { taskId } });
const live = sessions.findLast((s) => s.state !== "completed");
@@ -218,14 +236,29 @@ function fixButton(taskId: string, r: Review, f: Record<string, unknown>, where:
} else {
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: text } });
}
replace(button, "sent");
focusTask(taskId);
} catch (err) {
button.disabled = false;
fix.disabled = false;
ignore.disabled = false;
void showAlert(errorText(err));
return;
}
done("fixed");
await mark("fixed").catch((err: unknown) => showAlert(`fix sent, but not recorded: ${errorText(err)}`));
};
ignore.onclick = async () => {
fix.disabled = true;
ignore.disabled = true;
try {
await mark("ignored");
done("ignored");
} catch (err) {
fix.disabled = false;
ignore.disabled = false;
void showAlert(errorText(err));
}
};
return button;
replace(box, fix, ignore);
return box;
}
export function mountReviews(root: HTMLElement): () => void {
+5
View File
@@ -351,6 +351,11 @@ export function handleEvent(ev: ByreEvent): void {
void loadInbox();
}
break;
case "review.finding":
if (state.inbox.some((i) => i.task_id === ev.task_id && i.kind === "question")) {
void loadInbox();
}
break;
case "focus.updated":
if (ev.actor !== state.me?.login) {
applyFocus(ev.payload as unknown as Focus);
+6 -6
View File
@@ -2,7 +2,7 @@ import { get, post, type AgentSession, type ByreEvent, type Participant, type Sc
import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts } from "./reviews.js";
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { currentSession, findTask, focusTab, focusTask, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
import { taskStyle } from "./taskcolor.js";
@@ -458,7 +458,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
put(snapRow(p as unknown as Snapshot, ctx));
continue;
}
if (ev.kind === "review.started" || ev.kind === "review.finished") {
if (ev.kind === "review.started" || ev.kind === "review.finished" || ev.kind === "review.finding") {
const r = p as unknown as Review;
const button = ctx.reviewButtons.get(r.snapshot_n);
if (button) {
@@ -932,10 +932,10 @@ function markReview(button: HTMLElement, r: Review): void {
return;
}
if (n === 0) {
replace(button, "review", h("span", { class: "review-mark clean", title: "no findings" }, "✓"));
replace(button, "review", h("span", { class: "review-mark clean", title: Object.keys(r.finding_states).length > 0 ? "every finding addressed" : "no findings" }, "✓"));
return;
}
replace(button, "review", h("span", { class: "badge", title: `${n} findings` }, n));
replace(button, "review", h("span", { class: "badge", title: `${n} open findings` }, n));
}
function snapRow(snap: Snapshot, ctx: Ctx): HTMLElement {
@@ -1341,8 +1341,8 @@ async function reviewWarnings(taskId: string, leave: (go: () => void) => void):
items.push(mergeWarning("Review of the latest snapshot is still running", open));
} else if (r.state === "failed") {
items.push(mergeWarning("Review of the latest snapshot failed", open));
} else if (findingCount(r) > 0) {
items.push(mergeWarning(`Review of the latest snapshot found ${reviewCounts(r)}`, open));
} else if (unignoredFindings(r).length > 0) {
items.push(mergeWarning(`Review of the latest snapshot found ${severityCounts(unignoredFindings(r))}`, open));
}
const changes = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { since: latest.commit_sha } });
if (changes.files.length > 0) {
+16 -1
View File
@@ -1924,8 +1924,14 @@ pre.diff .mark {
color: var(--muted);
}
.finding-fix {
.finding-actions {
margin-left: auto;
display: flex;
gap: 6px;
align-items: baseline;
}
.finding-actions button {
padding: 0 8px;
border-radius: 4px;
font-size: 11px;
@@ -1933,6 +1939,15 @@ pre.diff .mark {
background: transparent;
}
.finding-actions button.finding-ignore {
color: var(--muted);
}
.finding-done {
font-size: 11px;
color: var(--muted);
}
.severity {
font-size: 11px;
font-weight: 700;