diff --git a/api/openapi.yaml b/api/openapi.yaml index 75eea4e..51865f4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -78,23 +78,6 @@ paths: schema: { $ref: "#/components/schemas/User" } "400": { $ref: "#/components/responses/Error" } - /api/me/review-models: - put: - operationId: updateReviewModels - description: Sets the models that review the calling user's tasks. - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/ReviewModels" } - responses: - "200": - description: The updated user. - content: - application/json: - schema: { $ref: "#/components/schemas/User" } - "400": { $ref: "#/components/responses/Error" } - /api/me/lead/sessions: get: operationId: listLeadSessions @@ -256,6 +239,26 @@ paths: schema: { $ref: "#/components/schemas/Project" } "404": { $ref: "#/components/responses/Error" } + /api/projects/{projectId}/review-workflow: + parameters: + - { name: projectId, in: path, required: true, schema: { type: string } } + put: + operationId: setReviewWorkflow + description: Sets the review workflow that reviews the project's snapshots from the next one on; empty turns reviews off. Records project.updated. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ReviewWorkflow" } + responses: + "200": + description: The updated project. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /api/projects/{projectId}/tasks: parameters: - { name: projectId, in: path, required: true, schema: { type: string } } @@ -1210,21 +1213,27 @@ paths: operationId: getWorkflow responses: "200": - description: "The workflow's file: its WorkflowDefinition as YAML, as an attachment named after it." + description: > + The workflow's WorkflowDefinition: as JSON when the request accepts only application/json, else its file as + YAML, as an attachment named after it. content: application/yaml: schema: { type: string } + application/json: + schema: { $ref: "#/components/schemas/WorkflowDefinition" } "404": { $ref: "#/components/responses/Error" } put: operationId: putWorkflow description: > - Creates or replaces the workflow from its file, a WorkflowDefinition as YAML (or JSON). The name is one line - of 1 to 100 characters. Tasks that already froze the workflow keep their copy. + Creates or replaces the workflow from its file, a WorkflowDefinition as YAML or JSON. The name is one line of + 1 to 100 characters. Tasks that already froze the workflow keep their copy. requestBody: required: true content: application/yaml: schema: { type: string } + application/json: + schema: { $ref: "#/components/schemas/WorkflowDefinition" } responses: "200": description: The stored workflow. @@ -1256,7 +1265,7 @@ components: User: type: object - required: [login, display_name, git_name, git_email, notification_prefs, default_model, review_model, second_review_model, away] + required: [login, display_name, git_name, git_email, notification_prefs, default_model, away] properties: login: { type: string } display_name: { type: string } @@ -1264,8 +1273,6 @@ components: git_email: { type: string } notification_prefs: { $ref: "#/components/schemas/NotificationPrefs" } default_model: { type: string, description: The model the app starts new tasks on. } - review_model: { type: string, description: The model that reviews this user's tasks, unless it wrote the work under review. } - second_review_model: { type: string, description: The model that reviews this user's tasks when review_model wrote the work under review. } away: { type: boolean, description: "While true, byre answers the questions agents ask in this user's tasks at once." } Away: @@ -1280,13 +1287,6 @@ components: properties: model: { type: string } - ReviewModels: - type: object - required: [review_model, second_review_model] - properties: - review_model: { type: string } - second_review_model: { type: string } - NotificationPrefs: type: object description: Which task events notify this user in the macOS app. @@ -1310,7 +1310,7 @@ components: Project: type: object - required: [id, name, repo_path, default_branch, remote, created_by, created_at] + required: [id, name, repo_path, default_branch, remote, created_by, created_at, review_workflow] properties: id: { type: string } name: { type: string } @@ -1319,6 +1319,13 @@ components: remote: { type: string } created_by: { type: string } created_at: { type: string, format: date-time } + review_workflow: { type: string, description: "The review workflow whose stages review every snapshot of the project's tasks; empty for no reviews." } + + ReviewWorkflow: + type: object + required: [workflow] + properties: + workflow: { type: string, description: "A saved workflow of kind review, or empty for no reviews." } ProjectCreate: type: object @@ -1430,51 +1437,56 @@ components: workflow: type: string description: > - The name of a saved workflow to run instead of creating one task with no agent: each stage with no after - stages becomes its own task, titled ": <stage>", with the title as the run's brief. The response is - the first of those tasks. + The name of a saved workflow to run instead of creating one task with no agent: each stage that starts it + becomes its own task, titled "<title>: <stage>", with the title as the run's brief. The response is the + first of those tasks. WorkflowSummary: type: object - required: [name, description] + required: [name, description, kind] properties: name: { type: string } description: { type: string } + kind: { type: string, enum: [task, review] } WorkflowDefinition: type: object description: > - A workflow's file, which holds everything but its name: its stages, each run as its own task once the stages - it comes after have finished. Sent and returned as YAML. - required: [stages] + A workflow's file, which holds everything but its name: its steps, run in order. Each stage runs as its own + task once the steps before it have finished. Sent and returned as YAML. + required: [steps] properties: description: { type: string, x-go-type-skip-optional-pointer: true } - stages: + kind: + type: string + enum: [task, review] + x-enum-varnames: [WorkflowKindTask, WorkflowKindReview] + x-go-type-skip-optional-pointer: true + description: > + task (the default): each stage runs as its own task. review: each stage is a reviewer of a snapshot, run + when a project names the workflow as its review workflow; a stage gets the earlier stages' reviews. + steps: type: array minItems: 1 - items: { $ref: "#/components/schemas/Stage" } + items: { $ref: "#/components/schemas/WorkflowStep" } - Stage: + WorkflowStep: type: object - required: [name, prompt] + description: > + Exactly one of stage (with its model or agents, and prompt), order or parallel. A stage gets the outputs of + the stages that end the step before it: the last stage of an order, every branch's last stage of a parallel. properties: - name: { type: string, description: "One line of 1 to 100 characters, unique within the workflow." } - after: - type: array - items: { type: string } - description: > - The stages whose outputs this stage gets, by name; it starts once all of them have finished. Absent means - the previous stage in the list (for the first stage, none); empty means it starts from the brief. - base: - type: string - enum: [parent, main] - x-enum-varnames: [StageBaseParent, StageBaseMain] - description: > - Where the stage's task branches from. parent (the default): the commit its one after stage finished at, or - for a stage with none, where the workflow was started from. main: the project's default branch. Refused on - a stage with more than one after stage, which always branches from the default branch. + stage: { type: string, x-go-type-skip-optional-pointer: true, description: "The stage's name, one line of 1 to 100 characters, unique within the workflow." } model: { type: string, x-go-type-skip-optional-pointer: true, description: "The stage agent's model, which also picks its vendor: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code. Required without agents, refused with them." } - prompt: { type: string, description: "The stage agent's instructions; with agents, the instructions every agent of the team gets before its own." } + prompt: { type: string, x-go-type-skip-optional-pointer: true, description: "The stage agent's instructions; with agents, the instructions every agent of the team gets before its own." } + order: + type: array + items: { $ref: "#/components/schemas/WorkflowStep" } + description: Steps run one after another, each getting the outputs of the one before. + parallel: + type: array + items: { $ref: "#/components/schemas/WorkflowStep" } + description: Branches run at the same time, each getting the outputs of the step before the parallel. agents: type: array items: { $ref: "#/components/schemas/TeamMember" } @@ -1520,7 +1532,7 @@ components: name: { type: string } after: type: array - description: The stages it comes after, with the definition's defaults applied. + description: The stages whose outputs it gets, from the steps' shape. items: { type: string } state: type: string @@ -1669,12 +1681,13 @@ 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, finding_states, finding_responses] + description: One stage of the project's review workflow, an agent session reviewing one snapshot's change. + required: [id, task_id, snapshot_n, stage, agent_session_id, vendor, model, state, error, created_at, finding_states, finding_responses] properties: id: { type: string } task_id: { type: string } snapshot_n: { type: integer } + stage: { type: string, description: "The review workflow's stage this reviewer ran; empty when skipped." } agent_session_id: { type: string, description: "The reviewer's session; empty when skipped." } vendor: { type: string, description: "Empty when skipped." } model: { type: string, description: "Empty when skipped." } diff --git a/docs/macos.md b/docs/macos.md index 6c0bc92..b170404 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -61,6 +61,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An | `Workflow.swift` | Stage graph and attempts | | `Project.swift` | Project tab and TODO items | | `Notes.swift`, `Workflows.swift`, `Status.swift`, `Settings.swift` | Left-pane tabs | +| `WorkflowEditor.swift` | The Workflows tab's editor: a workflow's steps as a tree, with each stage's model and prompt | | `Switcher.swift` | ⌘K overlay, the shortcuts help and the Go actions (`runGo`) | | `Drafts.swift` | Composer, answer and line-comment drafts | | `Notifications.swift` | `UNUserNotificationCenter` delivery | @@ -104,7 +105,7 @@ Connections belong to `Servers`, not to the window: every saved server's store a ## Views -- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls, byre prompts and subagents folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review (and stop while the review runs), question bubbles that follow their item's `inbox.updated` events, a finished review as its summary row followed by one row per finding, the agent's `respond_to_finding` calls as their own rows, and notes for the other event kinds. Rows that hold a review (`Row.review`) are kept per review id and updated by each later `review.*` event. It is incremental: a new batch extends the existing rows. The composer bar's status (`StatusButton`, from `ChatModel.status`) shows the task's closed state, its working state, or what needs the user (`ChatModel.attention`: open questions, findings of the latest review that neither the user nor the agent has answered, and the current ready-for-merge overview); a click on one of the latter scrolls to the next one, wrapping, and on any other to the end; and when one of those rows closes the transcript scrolls to the next remaining one (`ChatModel.closed`). Jumps from elsewhere (`Store.jumpTo` with a `JumpTarget`: a summary, a question or a snapshot's review) select the task's transcript and scroll there once the row exists. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up; while an attention row is held (`ChatModel.held`), being stuck instead keeps that row top-anchored, which the scroll view clamps to the bottom until the row reaches the top. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and its server's window is the key window of the active app (`PaneFocus.windowKey`, `markSummaryRead`), checked again whenever the app activates or a window becomes key. An active task's head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard; a closed task has no head, and its `task.merged` note is drawn with a ✓ in the ok colour. +- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls, byre prompts and subagents folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review (and stop while the review runs), question bubbles that follow their item's `inbox.updated` events, one folded row per reviewer session holding that reviewer's events as a subagent's, with its latest step while it runs and its outcome after, a finished review with findings as its summary row followed by one row per finding, the agent's `respond_to_finding` calls as their own rows, and notes for the other event kinds. Rows that hold a review (`Row.review`) are kept per review id and updated by each later `review.*` event. It is incremental: a new batch extends the existing rows. The composer bar's status (`StatusButton`, from `ChatModel.status`) shows the task's closed state, its working state, or what needs the user (`ChatModel.attention`: open questions, findings of the latest review that neither the user nor the agent has answered, and the current ready-for-merge overview); a click on one of the latter scrolls to the next one, wrapping, and on any other to the end; and when one of those rows closes the transcript scrolls to the next remaining one (`ChatModel.closed`). Jumps from elsewhere (`Store.jumpTo` with a `JumpTarget`: a summary, a question or a snapshot's review) select the task's transcript and scroll there once the row exists. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up; while an attention row is held (`ChatModel.held`), being stuck instead keeps that row top-anchored, which the scroll view clamps to the bottom until the row reaches the top. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and its server's window is the key window of the active app (`PaneFocus.windowKey`, `markSummaryRead`), checked again whenever the app activates or a window becomes key. An active task's head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard; a closed task has no head, and its `task.merged` note is drawn with a ✓ in the ok colour. - **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded to `POST /api/blobs` on send; any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error. The ⌄/⌃ button collapses the text box (with its thumbnails) into the bar and brings it back; it is collapsed by default while the task has an open question or is merged or discarded (`ChatModel.composerHiddenByDefault`), and the human's choice holds until that default changes or an explicit focus request (picking the task in the switcher, focusing the lead) opens it; the automatic focus of a task with no session yet only focuses a box that is shown. - **Questions.** An open question's bubble in the transcript holds its card, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once, with any Other… text as notes) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide and Other…. Arrow keys move between choices. A 409 shows its error. Once answered or dismissed, the bubble shrinks to the question and who answered what. - **Changes, Files, Workflow, Project, Status, Settings.** Including the pinned-snapshot stepper, open-diff memory, per-task place in Files, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. diff --git a/docs/operations.md b/docs/operations.md index 41ec9ad..9b8f453 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -21,7 +21,7 @@ State lives in the directory given by `byre serve --data`, by default the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `~/Library/Application Support/byre`): `byre.db`, `worktrees/<project-id>/<task-id>`, `worktrees/<project-id>/<task-id>-media` (the task's media folder, see "Screenshots from implementers"), `leads/<login>` (each lead agent's working directory, the login path-escaped), `hands/<session-id>/byre-hands` (each running implementer session's copy of `byre-hands`, see "Screen and app control"), `blobs/<blob-id>` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory). Task branches are `byre/<task-id>` in the project's repo. -Schema changes are the files in `internal/store/migrations/`, embedded in the binary, each named `NNN-name.sql`: a three-digit number, then a short kebab-case name saying what it does. On open, byre applies, in file name order, every file whose name (without the number) isn't in the `migrations` table, each in its own transaction that also records the name. The number only orders: a migration that needs another's tables or columns takes a higher number, and migrations with the same number must not depend on each other. Each runs with foreign keys off, so a migration can rebuild a table other tables reference, and `PRAGMA foreign_key_check` must come back empty before it commits. A migration's SQL is fixed once applied anywhere: write it out in full rather than sharing Go constants that later change. byre is deployed from task branches that each add their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied. +Schema changes are the files in `internal/store/migrations/`, embedded in the binary, each named `NNN-name.sql`: a three-digit number, then a short kebab-case name saying what it does. A data migration SQL can't express is a Go function in `goMigrations` (`internal/store/store.go`), keyed `NNN-name` the same way and ordered among the files; `071-workflow-steps` (`internal/store/migrateworkflows.go`) is one. On open, byre applies, in file name order, every file whose name (without the number) isn't in the `migrations` table, each in its own transaction that also records the name. The number only orders: a migration that needs another's tables or columns takes a higher number, and migrations with the same number must not depend on each other. Each runs with foreign keys off, so a migration can rebuild a table other tables reference, and `PRAGMA foreign_key_check` must come back empty before it commits. A migration's SQL is fixed once applied anywhere: write it out in full rather than sharing Go constants that later change. byre is deployed from task branches that each add their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied. ## Worktree setup and ports @@ -98,7 +98,7 @@ A task agent lands its work so far without closing the task through the `merge_t `POST /api/tasks/{id}/merge` with `keep_open: true` (the ready card's **Merge & continue**) has the task's agent do it (`Manager.MergeToMain` in `internal/agents/mergemain.go`): it sends the team's first agent a byre message (`landPrompt`) to merge the default branch in with `git merge --no-ff --no-commit`, resolve and check as a Sync does, and then call `merge_to_main` with the dialog's title, whose commit concludes the merge (the prompt avoids `mergeInMarker`, so the turn doesn't own the worktree's merge the way a Sync turn does, which would refuse `merge_to_main`; the hold below keeps teammates out instead, and `turnEnded` aborts the merge of a landing turn that ended unfinished, as it does for a Sync turn); or, when the resolution took substantive changes, to stop and describe them so the review sees them before anything lands. For that turn the task's other agents are held: `Manager.landing` (in memory) maps the task to the landing session and the requesting user, whom that session's `merge_to_main` uses in place of the session's creator for authorship, signing and co-authors, `startRunning` (which `turn` and `Compact` move a session to `running` through) refuses (409) to start any other session of the task, so answers and queued messages stay undelivered, and `Message` queues their messages as task inputs. The hold ends when the landing session's turn ends however it ends (`endLanding`, deferred in `turnEnded`), or when sending the prompt fails, and then byre delivers the queued messages and answers of each idle implementer other than the landing session (whose own turn end handles it) and other than one the human interrupted (`releaseTeam`, through `Deliver`). A restart drops the hold along with the turn. The request takes the session's delivery lock, records the hold, and only then checks that none of the task's implementer sessions is running, so a teammate starting at the same moment either sees the hold or is seen running (`startRunning` checks the hold again after moving the session to `running` and moves it back if held). It is a 400 with `target: parent` or `after_review`, and a 409 while any of the task's agents is running, while the task is merging, held, merging into its parent or being discarded, when it has no agent, or when it is already merging and continuing. Just before sending the prompt it clears a pending merge after review, as a direct Merge does; once the prompt is sent it clears a standing ready_for_merge or suggest_discard (`task.ready_cleared` with `reason: merge`, which the transcript draws no note for and the lead gets no signal for, since the landing prompt already shows the merge). -`POST /api/tasks/{id}/merge` with `after_review: true` (the dialog's **Merge & complete after review**) starts nothing (`MergeAfterReview` in `internal/agents/afterreview.go`). It checks what the chosen target's Merge would (git identity and title for `main`, a parent for `parent`, no subtasks, not already merging, held or merging into the parent), stores the title like Merge does, and records the requesting user, the running review of the latest snapshot and the target in `tasks.merge_after_review_by`, `merge_after_review_id` and `merge_after_review_target` (`merge_after_review` on the Task; a `task.merge_after_review` event, drawn as a note). It is a 409 when that review isn't running. Asking again replaces the request. Whenever a review finishes (submitted, failed, interrupted, or failed by a restart or the task closing), `reviewFinished` takes the request if it names that review (emitting `task.merge_after_review_ended`, not drawn) and makes the merge as that user, through the same `Merge` or `MergeIntoParent` as a direct request, when the review is `done` with no findings, its snapshot is still the latest, the task is active and not being discarded, and no turn is running. Otherwise, or when that merge is refused, it drops the request with a `task.merge_after_review_dropped` event carrying the reason, drawn as a note. On startup, `recoverMergeAfterReviews` runs the same check for every request whose review is no longer `running`, so one left by a restart between the review finishing and its request being taken still merges or is dropped. A direct Merge (either target) clears the request; so do Discard and closing the task. `DELETE /api/tasks/{id}/merge-after-review` withdraws it (404 when none is waiting), emitting `task.merge_after_review_cancelled`, drawn as a note naming who cancelled. The transcript's `task.merge_after_review` note carries a **Cancel** button that calls it; only the last such note in the transcript shows the button, and only while the task has `merge_after_review` (`NoteView` and `lastMergeAfterReview` in `mac/Sources/Byre/Chat.swift` and `TranscriptBuilder.swift`). The Merge button reads **Merge after review**, still enabled so the dialog can merge now, and the task state label is **Merge after review**. `sqlite3 byre.db "SELECT id, merge_after_review_by, merge_after_review_id FROM tasks WHERE merge_after_review_by != ''"` lists them. +`POST /api/tasks/{id}/merge` with `after_review: true` (the dialog's **Merge & complete after review**) starts nothing (`MergeAfterReview` in `internal/agents/afterreview.go`). It checks what the chosen target's Merge would (git identity and title for `main`, a parent for `parent`, no subtasks, not already merging, held or merging into the parent), stores the title like Merge does, and records the requesting user, a running review of the latest snapshot and the target in `tasks.merge_after_review_by`, `merge_after_review_id` and `merge_after_review_target` (`merge_after_review` on the Task; a `task.merge_after_review` event, drawn as a note). It is a 409 when no review of the latest snapshot is running. Asking again replaces the request. Whenever a review finishes (submitted, failed, interrupted, or failed by a restart or the task closing), `reviewFinished` first starts the review workflow's next stages (see "Reviews") and waits for them; once none of that snapshot's reviews is running, it takes the request if it names one of them (emitting `task.merge_after_review_ended`, not drawn) and makes the merge as that user, through the same `Merge` or `MergeIntoParent` as a direct request, when every review of the snapshot is `done` with no findings between them, the snapshot is still the latest, the task is active and not being discarded, and no turn is running. Otherwise, or when that merge is refused, it drops the request with a `task.merge_after_review_dropped` event carrying the reason, drawn as a note. On startup, `recoverMergeAfterReviews` runs the same check for every request whose review is no longer `running`, so one left by a restart between the review finishing and its request being taken still merges or is dropped. A direct Merge (either target) clears the request; so do Discard and closing the task. `DELETE /api/tasks/{id}/merge-after-review` withdraws it (404 when none is waiting), emitting `task.merge_after_review_cancelled`, drawn as a note naming who cancelled. The transcript's `task.merge_after_review` note carries a **Cancel** button that calls it; only the last such note in the transcript shows the button, and only while the task has `merge_after_review` (`NoteView` and `lastMergeAfterReview` in `mac/Sources/Byre/Chat.swift` and `TranscriptBuilder.swift`). The Merge button reads **Merge after review**, still enabled so the dialog can merge now, and the task state label is **Merge after review**. `sqlite3 byre.db "SELECT id, merge_after_review_by, merge_after_review_id FROM tasks WHERE merge_after_review_by != ''"` lists them. ## Subtasks @@ -296,7 +296,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie - **Sessions.** A lead session is an `agent_sessions` row with `owner` set to the user's login, `role` `lead` and no `task_id`. Its events carry `owner` and no project or task. It runs in `leads/<login>` with `leadGuidance` (passed and re-attached the same way as a task agent's guidance) and the lead tools instead of the task tools. It has no Stop-hook bounce, no snapshots, no end-of-turn commit and no turn summary; a turn that ends in error records the error on its `session.state` event, which the pane shows as a note. - **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs`; the lead's PostToolUse hook (Codex: `turn/steer` after each completed tool item; Antigravity: the `PreInvocation` hook) hands held messages over after its next tool call, like a task's queued messages (see "Agent sessions"), and any still held become its next turn. Signals are never handed over mid-turn. The composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints. - **Scrollback.** `GET /api/me/lead/events` returns a page of the caller's lead events in whole turns, `turns` of them (default 50): those after the `turns`+1'th `turn.ended` before `before` (or before the latest event, so the page also holds a turn still running), with `more` saying whether earlier events exist. The lead pane loads only the last page (see "Events"); when its top scrolls into view (an empty row kept first in the transcript), it fetches the page before its first event (`loadOlderLead` in `mac/Sources/Byre/Store.swift`), puts its rows in front (`TranscriptBuilder.prepend`) and scrolls back to the row that was first. A page starts just after a `turn.ended`, so rendering it from scratch matches how it drew as part of the whole history. Live events still append without limit until the app is relaunched. -- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation), `session.error` and `session.rate_limited` on a task, `inbox.updated` when a human answered a question (the signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), `task.created` (naming the TODO item it was started from, or a subtask's parent, if any), `task.merge_queued` ("<task>'s merge was requested by <login> and is held until the merge in progress in its project is done."), `task.merge_after_review` ("<task> was set by <login> to merge once the review of snapshot <n> finishes with no findings."), `task.merge_after_review_cancelled` ("<task>'s merge after review was cancelled by <actor>."), `task.merge_after_review_dropped` ("<task> didn't merge after the review: <reason>"), `task.merge_started` ("<task> is merging, started by <login>."), `task.merged`, `task.discarded`, `turn.ended` with `summary: true` ("<task> finished a turn:" with its `result` cut to 1500 characters), `task.workflow_stage` ("<task> started stage "<stage>" of workflow "<workflow>".") and `task.workflow_finished` ("<task> finished workflow "<workflow>":" with its output cut to 1500 characters), `review.started` ("<task> started a review of snapshot <n>."), `review.finished` ("<task> review of snapshot <n>:" with the review's summary, `change` and findings, or why it failed or was skipped; `reviewLine` in `internal/agents/review.go`), `user.message` on a task from a human (see "Pane lock"), and `task.state` ("<task> state: <from> → <to>.", or "<task> state: <to>." for a new task; see "Task state in the store"). The store hands `Observe` all of one transaction's events together, and they become one signal, its `task.state` lines first, so a turn's summary always arrives with the state its turn end left the task in. A batch whose lines are all quiet is dropped instead of held (`quietSignal` in `internal/agents/lead.go`): `task.state`, `review.started`, and `task.created`, `task.merge_started`, `task.merged` and `task.discarded` by a human, unless the line carries "Your right pane is free" (see "Pane lock"). Quiet lines still ride along in a batch with anything else, and the "Tasks now" list that follows every batch shows each task's current state. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)", followed by "Tasks now:" with the `list_tasks` text and a line saying what the lead's right pane shows (task or project, and selected tab) and whether the human is working in it (`leadStateText` in `internal/agents/lead.go`). If collecting that fails, for example because a project's repository is gone, the batch is still sent, ending instead with "The task list and right pane state are unavailable:" and the error, which is also logged as `lead state`. Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `mac/Sources/Byre/TranscriptBuilder.swift`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message is never drawn, the turn isn't opened, so the scanner stays off, its partial message stays hidden, and its tool calls, subagents and thinking are built into a held row list instead of the transcript. The turn is released, inserting those rows where the turn belongs, when it draws anything else: a real message, a note, or its own `turn.ended` when it ends without `NO_REPLY`. When it ends in `NO_REPLY` while still held, the held rows are dropped, so nothing of the turn ever appears; a turn that draws a real message anywhere in it keeps all of it, even when a later message is `NO_REPLY`. +- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation), `session.error` and `session.rate_limited` on a task, `inbox.updated` when a human answered a question (the signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), `task.created` (naming the TODO item it was started from, or a subtask's parent, if any), `task.merge_queued` ("<task>'s merge was requested by <login> and is held until the merge in progress in its project is done."), `task.merge_after_review` ("<task> was set by <login> to merge once the reviews of snapshot <n> finish with no findings."), `task.merge_after_review_cancelled` ("<task>'s merge after review was cancelled by <actor>."), `task.merge_after_review_dropped` ("<task> didn't merge after the review: <reason>"), `task.merge_started` ("<task> is merging, started by <login>."), `task.merged`, `task.discarded`, `turn.ended` with `summary: true` ("<task> finished a turn:" with its `result` cut to 1500 characters), `task.workflow_stage` ("<task> started stage "<stage>" of workflow "<workflow>".") and `task.workflow_finished` ("<task> finished workflow "<workflow>":" with its output cut to 1500 characters), `review.started` ("<task> started the "<stage>" review of snapshot <n>."), `review.finished` ("<task> "<stage>" review of snapshot <n>:" with the review's summary, `change` and findings, or why it failed or was skipped; `reviewLine` in `internal/agents/review.go`), `user.message` on a task from a human (see "Pane lock"), and `task.state` ("<task> state: <from> → <to>.", or "<task> state: <to>." for a new task; see "Task state in the store"). The store hands `Observe` all of one transaction's events together, and they become one signal, its `task.state` lines first, so a turn's summary always arrives with the state its turn end left the task in. A batch whose lines are all quiet is dropped instead of held (`quietSignal` in `internal/agents/lead.go`): `task.state`, `review.started`, and `task.created`, `task.merge_started`, `task.merged` and `task.discarded` by a human, unless the line carries "Your right pane is free" (see "Pane lock"). Quiet lines still ride along in a batch with anything else, and the "Tasks now" list that follows every batch shows each task's current state. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)", followed by "Tasks now:" with the `list_tasks` text and a line saying what the lead's right pane shows (task or project, and selected tab) and whether the human is working in it (`leadStateText` in `internal/agents/lead.go`). If collecting that fails, for example because a project's repository is gone, the batch is still sent, ending instead with "The task list and right pane state are unavailable:" and the error, which is also logged as `lead state`. Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `mac/Sources/Byre/TranscriptBuilder.swift`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message is never drawn, the turn isn't opened, so the scanner stays off, its partial message stays hidden, and its tool calls, subagents and thinking are built into a held row list instead of the transcript. The turn is released, inserting those rows where the turn belongs, when it draws anything else: a real message, a note, or its own `turn.ended` when it ends without `NO_REPLY`. When it ends in `NO_REPLY` while still held, the held rows are dropped, so nothing of the turn ever appears; a turn that draws a real message anywhere in it keeps all of it, even when a later message is `NO_REPLY`. - **Tools.** `list_tasks` (each project's active tasks with their state, each subtask noting its parent, a held merge (`merge_queued_at`) and a merge after review (`merge_after_review`) each noted, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`; closed tasks too), `search_closed_tasks` (see "Closing a task"), `read_inbox`, `read_status` (the `GET /api/admin/status` body as JSON, built by the same `Server.status`, which the server hands `agents.New`), `answer_question` (an `option` or `other` response recorded with the lead's actor as its `user`; `inbox_responses.actor` and `inbox_items.resolved_by` hold actors rather than logins, so they have no foreign key to `users`; through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; while it is mid-turn the message is queued through `Manager.Message` like the human's, see "Agent sessions", and the tool result says it was queued; refused while the agent is stopped after an interrupt or error, see "Agent sessions"; on a team task it posts to the board, see "Teams"), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run; `create_task` also takes `agents` to start a team), `create_project` (`name` and `repo_path`, created as the user by the same `Server.createProject` as `POST /api/projects`, which the server hands `agents.New`, so it publishes the same `project.created` event, and returned as JSON), `interrupt`, `compact_task`, `take_snapshot` (see "Snapshots"), `merge_task` (optional `target`: `main`, or `parent` for a subtask, see "Subtasks"), `discard_task`, `show`, `compact_self`, `read_knowledge` (see "Knowledge base"), the note tools (see "Notes") and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents and its answers have actor `<login> (lead)`, so they don't make the user a participant and nobody takes them for the user's; tasks, merges and discards are done as the user. Wherever that actor is shown, it reads `<login>'s lead agent` (`actorName` in `internal/agents/lead.go` and `mac/Sources/Byre/Format.swift`): in the agent's mid-turn and board headings, in answers delivered from the inbox, in decisions the notes are built from, in `read_transcript`, and on transcript bubbles, board posts and question cards in the app. A message from the lead that starts a turn reaches the agent headed "This message is from <login>'s lead agent, not from <login>:" (`agentText`), and a queued one mid-turn as "<login>'s lead agent (not <login>) sent you this message while you were working"; the recorded `user.message` keeps the text as the lead wrote it. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks. - **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id` and `tab` (the selected one). The tabs' order is fixed; neither the lead nor the human can change it. With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The app applies the focus at startup and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. - **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and changes nothing. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead "<actor> sent <task> a message; its agent is working on it:" followed by the message's full text, and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on <task>'s agent." Each `PUT /api/me/focus` from the user starts a 10-second timer (`endWatchHold`); when it fires, if the pane hasn't moved since, the app still reports the human in it, the user hasn't messaged the task since the move (that already freed it, above), and the pane is waiting that way, the lead gets the signal "Your right pane is free: the human has watched <task>'s agent work for 10 seconds without typing." The lead's own `show` starts no timer. When a `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> is merging." (or "is merged."/"is discarded."). The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo/<project_id>/<URL-encoded title>`; Markdown views draw that (`todo` in `mac/Sources/Byre/MarkdownView.swift`) as the title followed by `start` and `discuss` actions, as accent link text so the line stays selectable, that call `POST /api/projects/{id}/todo/tasks` with the user's default model, as the Project tab's do, or, once an active task was started from that item, the task's colour, dot and state, linking to it. When the lead suggests new work that isn't in TODO.md, its guidance tells it to write a link to `#/suggest/<project_id>/<URL-encoded title>/<URL-encoded brief>`, drawn by the same code with `start` and `discuss` buttons: `start` calls `POST /api/projects/{id}/tasks` with the title, the brief as `prompt` and the user's default model, which checks the user's git identity, creates the task and starts its agent from one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. The window reports to the server it shows. It sends false on deactivating, switching to another server, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. @@ -308,7 +308,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie Task agents get notes too. An implementer session's instructions end with `taskNotesGuidance` and `notesText` for the task's creator, limited to the every-project notes and the task's project's (`startLive` in `internal/agents/manager.go`), fixed when the agent process starts like the lead's. Reviewers get none. Implementers have a `suggest_note` tool (`text`, `evidence`) that queues a lead signal for the task's creator naming the task, the suggested text, the evidence and the project id, and refuses when the creator has no live lead session. The lead's guidance tells it to save a suggestion with `remember`, choosing its project, only when it holds and isn't already covered, and to tell the human nothing either way. A suggestion leaves no record of its own beyond the signal; a saved one shows as the lead's `lead.note_saved`. - **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input. -The UI is two panes filling the window, with no navbar. The left has six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "<name>" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`), so its headings name the Mac (`Host.current().localizedName`, as in `Byre-Host`): Server on <Mac> is On when launchd has the service loaded (`launchctl print gui/<uid>/run.fc.byre` succeeds), showing Turn off (`launchctl bootout`) and, from `GET /api/tailnet` at the `loopback` address `byre check` prints, the service's `origin` once its node is `Running`, or else the node's hostname and state, polling every 2 s until the node runs (a node that needs signing in takes the window to the sign-in screen); Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on <Mac> lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one: its command in a box with Copy and Run in Terminal, which writes the command to a `.command` file in the temporary directory, run by the user's login shell, and opens it in Terminal, so prompts like sudo's work and the list updates when the app becomes active again; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac leave the service running instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Below it, Usage commands has one field per vendor, empty meaning the vendor's own reports, loaded from and saved to that service's `/api/admin/usage-commands` (see "Usage" under "Status"). Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the status in the transcript's composer bar points at what needs the user there (see "Attention" under "Inbox"). The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled; while the task is `discard_suggested` it opens the Discard confirmation instead. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. +The UI is two panes filling the window, with no navbar. The left has six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "<name>" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. A workflow's edit button, and the tab's new button, replace the list with the editor (`mac/Sources/Byre/WorkflowEditor.swift`): it loads the definition with `GET /api/workflows/{name}` accepting only JSON, holds it as a tree of `StepNode`s, and draws one row per step with box-drawing guides, a stage as its name, model menu and an ellipsis menu (add a stage after, run in parallel with a new stage, group with a new stage in order, move up or down, remove), an `order` or `parallel` as "in order" or "in parallel" with add inside, switch kind and ungroup. Rows drag: dropping one on a stage moves it before that stage, on an order or parallel to its end. A stage's disclosure shows its prompt editor, and for a team, each agent's name, model and prompt. Save sends the definition as JSON to `PUT /api/workflows/{name}`, and shows the server's validation error under the description when it refuses; leaving with unsaved changes asks first. A new workflow's name is typed in the editor; an existing one's isn't editable there. A choice under the description sets its `kind`: runs on a brief, or reviews snapshots. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`), so its headings name the Mac (`Host.current().localizedName`, as in `Byre-Host`): Server on <Mac> is On when launchd has the service loaded (`launchctl print gui/<uid>/run.fc.byre` succeeds), showing Turn off (`launchctl bootout`) and, from `GET /api/tailnet` at the `loopback` address `byre check` prints, the service's `origin` once its node is `Running`, or else the node's hostname and state, polling every 2 s until the node runs (a node that needs signing in takes the window to the sign-in screen); Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on <Mac> lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one: its command in a box with Copy and Run in Terminal, which writes the command to a `.command` file in the temporary directory, run by the user's login shell, and opens it in Terminal, so prompts like sudo's work and the list updates when the app becomes active again; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac leave the service running instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Below it, Usage commands has one field per vendor, empty meaning the vendor's own reports, loaded from and saved to that service's `/api/admin/usage-commands` (see "Usage" under "Status"). Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the status in the transcript's composer bar points at what needs the user there (see "Attention" under "Inbox"). The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled; while the task is `discard_suggested` it opens the Discard confirmation instead. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. ## TODO @@ -356,7 +356,7 @@ The running macOS app posts notifications for events on its SSE stream, raised b - **Clearing.** A delivered notification leaves Notification Center once it is dealt with anywhere, on this Mac, another one or by the lead: an `inbox.updated` event whose item is no longer `open` removes the item's notification (`clearInbox`), a `summary.read` event removes that task's `turn-<seq>` notifications up to its `seq` (`clearTurns`), and `task.merged`, `task.discarded`, `snapshot.created` (unless its `trigger` is `answer`) and `inbox.created` remove the task's `task-<task id>` notification (`clearTask`); the last two are the events that clear the task's ready state on the server. - **Clicking** a notification activates the app, shows its window and points the right pane at the item's task. -The left pane's **Settings** tab asks macOS for notification permission and edits the preferences, and also holds the Default model menu (see "Agent sessions") and the two Review models menus (see "Reviews"). +The left pane's **Settings** tab asks macOS for notification permission and edits the preferences, and also holds the Default model menu (see "Agent sessions"). Which models review a project's tasks is its review workflow's (see "Reviews"). Each question shows in the task's transcript where it was asked, as a bubble (`QuestionBubble` in `mac/Sources/Byre/Chat.swift`). While it is open, the bubble, in the warn colours, holds the whole question card (`QuestionCard` in `mac/Sources/Byre/Questions.swift`), answered in place. Once it is answered or dismissed, the bubble shrinks, on a faint yellow background (`Palette.warnFaint`) that keeps past questions apart from the rest of the stream, to its `question` text and who chose which options or the first line of who answered what, "dismissed by" whoever dismissed it, or "withdrawn by the agent". Its ▸ disclosure expands it to the rest of the card (`QuestionDetails` in `mac/Sources/Byre/Questions.swift`): the context with its choices, chosen ones marked and none clickable, attachments and the response thread. An open card shows the context up to its first choice list, then the `question`, then the choice list and the rest of the context. The transcript builds it from the item's `inbox.created` event and updates it from each later `inbox.updated` event of the same item. @@ -434,7 +434,7 @@ The **Terminal** tab (`mac/Sources/Byre/Terminal.swift`) draws it with SwiftTerm byre snapshots each task's worktree at the end of every turn that has no subagents running (`internal/agents/snapshots.go`, `internal/git/snapshot.go`), so a snapshot always sits on a turn boundary, the only place a Codex conversation can be forked. A snapshot builds the tree the same way as the Changes view (a copy of the real index, `git add --all`, `git write-tree`), commits it with `git commit-tree` whose parent is the worktree's `HEAD`, authored as the session's creator with the message `byre snapshot <n> (<trigger>)`, and points `refs/byre/tasks/<task-id>/snap/<n>` at it (`git update-ref` with an empty old value, so an existing ref is never overwritten). A row in the `snapshots` table records id, task, session, `n` (from 1 per task), commit and tree shas, `event_seq` (the task's latest event when the snapshot started, at or after the turn's `turn.ended`, so the tree holds at least the work up to that event), the trigger and the time, and the same transaction records a `snapshot.created` event carrying the row. -The trigger is `turn_end`: the snapshot starts after byre's end-of-turn commit and the `turn.ended` event. A turn that ends while the session's subagent count (see "Subagent count") is above zero takes no snapshot and requests no review, since its work is half done; the CLI starts another turn when each background subagent finishes, and the first of those to end with none running takes the snapshot and its review. An interrupted turn likewise takes no snapshot and requests no review, so the next turn to end uninterrupted snapshots its work and has it reviewed even when that turn changed no files. The trigger `answer` is a snapshot taken just before answers from the human are handed to the agent (see "Inbox"); it is taken even when its tree is unchanged, is never reviewed (its transcript row has no review button), and doesn't wait in the queue below but runs at once under the task's mutex. The trigger `ask_human` is a snapshot taken the same way, inside an `ask_human` call, right after byre commits the worktree of a task that is behind the default branch (see "Inbox"); it is never reviewed either. Snapshots taken by earlier builds also carry `message` (mid-turn, when the agent wrote text after tool calls), `tool_call` and `timer`. A snapshot whose tree equals the task's latest snapshot's tree is skipped, so a turn that changed no files leaves none. Snapshots run one at a time per task: a trigger that arrives while one is running replaces any already waiting one, and the waiting one runs when the current one finishes. Failures are logged and dropped. +The trigger is `turn_end`: the snapshot starts after byre's end-of-turn commit and the `turn.ended` event. A turn that ends while the session's subagent count (see "Subagent count") is above zero takes no snapshot and requests no review, since its work is half done; the CLI starts another turn when each background subagent finishes, and the first of those to end with none running takes the snapshot and its review. An interrupted turn likewise takes no snapshot and requests no review, so the next turn to end uninterrupted snapshots its work and has it reviewed even when that turn changed no files. The trigger `answer` is a snapshot taken just before answers from the human are handed to the agent (see "Inbox"); it is taken even when its tree is unchanged, is never reviewed, and doesn't wait in the queue below but runs at once under the task's mutex. The trigger `ask_human` is a snapshot taken the same way, inside an `ask_human` call, right after byre commits the worktree of a task that is behind the default branch (see "Inbox"); it is never reviewed either. Snapshots taken by earlier builds also carry `message` (mid-turn, when the agent wrote text after tool calls), `tool_call` and `timer`. A snapshot whose tree equals the task's latest snapshot's tree is skipped, so a turn that changed no files leaves none. Snapshots run one at a time per task: a trigger that arrives while one is running replaces any already waiting one, and the waiting one runs when the current one finishes. Failures are logged and dropped. The `take_snapshot` MCP tool asks for a snapshot now (`RequestSnapshot` and `takeSnapshotTool` in `internal/agents/snapshots.go`): a task's agent calls it with no arguments for its own task, and the lead with a `task_id`. The snapshot has trigger `requested`, is taken even when its tree equals the latest snapshot's (so it retries a failed review of unchanged work), and is reviewed like any other (see "Reviews"); it counts as a review request (`BeginReviewRequest`) from the call until the reviewer starts. It is attributed to the task's latest implementer session, authored as that session's creator, and can be taken mid-turn, uncommitted work included. A requested snapshot that is replaced while waiting passes its trigger and its taken-even-when-unchanged to the one that replaces it. Refused for a closed task, while the task is merging or being discarded, and for a task with no agent session. @@ -442,7 +442,7 @@ Merging or discarding a task stops new snapshots for it, waits for one in progre `GET /api/tasks/{taskId}/changes?snapshot=<n>` reports a snapshot instead of the live worktree: its `tree` is the snapshot's tree, and `base` is the merge-base of the snapshot's commit and the default branch, or with `step=true` the previous snapshot's tree (for snapshot 1, the same merge-base: the snapshot commit's parent is the worktree's `HEAD`, which already holds whatever byre committed at the end of the turn). It reads the stored shas, so it works only while the task is active and its refs keep the objects; `since` with `snapshot` is a 400. `GET /api/tasks/{taskId}/diff` takes that `base` and `tree` as usual. -The transcript is the timeline. The UI learns a task's snapshots from the `snapshot.created` events in its transcript (`snapshots` in `mac/Sources/Byre/Store.swift`). A snapshot is a point between the agent's events, not part of one, so each gets its own row in the transcript, a divider reading `snapshot #n` (its trigger and time in the tooltip), placed where its `snapshot.created` event falls in the task's event order: just after the turn end it belongs to (after the triggering text for an old `message` snapshot). A snapshot row ends the current group of tool calls, so calls after it start a new group. The row's **changes** button pins the snapshot (`Store.pinned`, cleared when the selected task changes) and switches the right pane to the Changes tab; its **fork** button forks it (see "Forking") and points the right pane at the new task; its **review** button scrolls the transcript to that snapshot's review, and is disabled until the review has finished; while it runs, a **stop** button sits beside it. Pinned, the Changes header reads `snapshot <n> · <trigger> · <time>` with ← and → to step to the neighbouring snapshots, and the range buttons are **Since main** and **This step** (`step=true`, the default when pinning) plus **Live**, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes. +The transcript is the timeline. The UI learns a task's snapshots from the `snapshot.created` events in its transcript (`snapshots` in `mac/Sources/Byre/Store.swift`). A snapshot is a point between the agent's events, not part of one, so each gets its own row in the transcript, a divider reading `snapshot #n` (its trigger and time in the tooltip), placed where its `snapshot.created` event falls in the task's event order: just after the turn end it belongs to (after the triggering text for an old `message` snapshot). A snapshot row ends the current group of tool calls, so calls after it start a new group. The row's **changes** button pins the snapshot (`Store.pinned`, cleared when the selected task changes) and switches the right pane to the Changes tab; its **fork** button forks it (see "Forking") and points the right pane at the new task. Pinned, the Changes header reads `snapshot <n> · <trigger> · <time>` with ← and → to step to the neighbouring snapshots, and the range buttons are **Since main** and **This step** (`step=true`, the default when pinning) plus **Live**, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes. ## Forking @@ -460,30 +460,31 @@ An implementer on Claude Code forks its own task with the `fork` MCP tool (`fork ## Reviews -Every new snapshot of a task gets a review: a separate agent session on the vendor the task's agent doesn't use reads the change and reports on it (`internal/agents/review.go`, `internal/store/review.go`). A turn that changes no files makes no snapshot and so no review, an interrupted turn makes no snapshot (see "Snapshots"), and a snapshot whose change (the diff below) has no files runs no reviewer: it gets a `reviews` row in state `skipped`, with no session, vendor or model. The snapshot at the end of a Merge turn that finishes into the squash (the turn ended cleanly with `merging_by` still set, read by `turnEnded` before it asks for the snapshot) gets no review and no `reviews` row at all, since the task closes a moment later; a Merge turn that errors or calls `stop_merge` is reviewed as usual. Besides `take_snapshot` (see "Snapshots"), nothing else starts a review, and nothing waits for one: reviews ride along for the human to look at when they want. +Every new snapshot of a task is reviewed by its project's review workflow: each stage of it is a separate reviewer agent session that reads the change and reports on it (`internal/agents/review.go`, `internal/store/review.go`). A project with no review workflow isn't reviewed. A turn that changes no files makes no snapshot and so no review, an interrupted turn makes no snapshot (see "Snapshots"), and a snapshot whose change (the diff below) has no files runs no reviewer: it gets a `reviews` row in state `skipped`, with no session, vendor or model. The snapshot at the end of a Merge turn that finishes into the squash (the turn ended cleanly with `merging_by` still set, read by `turnEnded` before it asks for the snapshot) gets no review and no `reviews` row at all, since the task closes a moment later; a Merge turn that errors or calls `stop_merge` is reviewed as usual. Besides `take_snapshot` (see "Snapshots"), nothing else starts a review, and nothing waits for one: reviews ride along for the human to look at when they want. -- **Starting.** After `takeSnapshot` creates a snapshot, `afterSnapshot` runs `startReviewer`: it picks the model from the task creator's `users.review_model` and `users.second_review_model` (`reviewerModel`): the second review model when the task's latest implementer session runs exactly the review model, the review model otherwise, and the vendor follows from the model. They are returned as `review_model` and `second_review_model` on `GET /api/me`, set together with `PUT /api/me/review-models` from the Settings tab's Review models menus, and record `user.updated`. A model whose vendor's CLI isn't on the service's `PATH` fails the review with the start error. It then checks the snapshot's commit out detached at `worktrees/<project-id>/review-<review-id>`, creates an `agent_sessions` row with `role` `reviewer` on the task (created by the task's creator), a `reviews` row (`running`, one per snapshot), and sends `reviewPrompt` as its first turn (actor `byre`): all of the task's work as `git diff` from the merge-base of the snapshot and the default branch, with its changed files, and the task's first message to its agent. Every review covers the whole task against the default branch, not only what changed since the previous snapshot, and a snapshot with no changes against it is `skipped`. +- **Review workflows.** A project's review workflow is a saved workflow with `kind: review` (see "Workflows"), named by `projects.review_workflow` (`review_workflow` on the Project, empty for none). It is set with `PUT /api/projects/{projectId}/review-workflow` (`{workflow}`; empty turns reviews off, a task workflow or a missing one is refused), from the Project tab's reviews menu or the lead's `set_review_workflow`, and records `project.updated` with the project. Each stage of the workflow is one reviewer on the stage's `model` (fixed: it doesn't change with the model the task's agent runs), told the stage's `prompt`; a stage that comes after others starts once all of them are `done` and gets their summaries and findings under `## From reviewer "<stage>"`. A project's snapshots are reviewed with the workflow as it is when each review stage starts: each `reviews` row keeps the definition it ran from (`reviews.definition`), and later stages of that snapshot follow it. +- **Starting.** After `takeSnapshot` creates a snapshot, `afterSnapshot` runs `startReviews`: with no review workflow it does nothing; a snapshot with no changes against the default branch gets one `skipped` row; a review workflow that is missing or a task workflow gets one `failed` row naming it; otherwise it runs `startReviewer` for each stage the workflow starts with (`workflow.Starts`). When a review finishes `done`, `advanceReviews` starts each stage after it whose earlier stages are all `done` for that snapshot, unless the task closed or a later snapshot has reviews; a failed stage stops the stages after it. A stage that can't start (a model whose vendor's CLI isn't on the service's `PATH`, a checkout that fails) gets a `failed` row with the start error, so a snapshot's reviews never look complete with a stage missing; a failed row with no session shows in the transcript as an error note. `startReviewer` checks the snapshot's commit out detached at `worktrees/<project-id>/review-<review-id>`, creates an `agent_sessions` row with `role` `reviewer` on the task (created by the task's creator), a `reviews` row (`running`, one per stage per snapshot, its `stage` the stage's name), and sends `reviewPrompt` as its first turn (actor `byre`): the stage's prompt, all of the task's work as `git diff` from the merge-base of the snapshot and the default branch, with its changed files, the task's first message to its agent, and earlier stages' reviews. Every review covers the whole task against the default branch, not only what changed since the previous snapshot. - **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. -- **Forwarding.** When a submitted review has findings, byre sends them to the task's live agent as a `byre` message (`forwardFindings` and `findingsMessage` in `internal/agents/review.go`): each finding's number, severity, title, location and detail, under a preamble asking the agent to check each one against the code and its brief and, before changing anything, answer each with `respond_to_finding` (see "Agent responses"). It goes through `Manager.Message`, so it is queued like any message while the agent is mid-turn. A task with no live agent session gets nothing, and the log says `review findings not forwarded`. Forwarding doesn't set any finding's state; **fix** and **ignore** stay available until the agent or the user answers. -- **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. `StopReview` ends the reviewer the same way and fails the review; `Watch` calls it for a reviewer whose process the health check finds `stuck` (no stream event for `StuckAfter`), which is how a reviewer whose connection died while the machine slept gets cleared. Reviews are of all the task's work, so a newer one makes older ones moot: once a snapshot's review starts, or is skipped for having no diff, byre fails the task's running reviews of earlier snapshots ("superseded by snapshot N") and ends their reviewers (`failRunningReviews`). Each snapshot's review starts on its own goroutine under `reviewMu`, so they can run out of order; `afterSnapshot` starts no review for a snapshot when the task already has a review of a later one. 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. -- **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.** Reviews live in the task's transcript (`renderReview` in `mac/Sources/Byre/TranscriptBuilder.swift`, rows in `mac/Sources/Byre/Reviews.swift`). A finished review with findings, or a failed one, is a note at its `review.finished` event, "review of snapshot #n:" with its severity counts or error; one skipped or with no findings draws nothing, since the snapshot row's review badge says so. With findings, the note carries the summary and screenshots, and each finding follows as its own row, numbered from 1 as in the message to the agent. 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 against the default branch (`changes?snapshot=<n>`, 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. Clicking a screenshot opens it full size. A finding with no answer from the user or the agent has **fix** and **ignore** buttons. **fix** sends the finding's number, 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; the user can't clear one, and only the agent's answer clears a `fixed` one (below). -- **Agent responses.** The message forwarding findings to the agent numbers them and tells it to answer each with the task agent's `respond_to_finding` MCP tool (`respondToFindingTool` in `internal/agents/review.go`) before changing anything: `snapshot`, `finding` (from 1), `response` (`fixing` or `ignoring`) and a one-line `reason`. byre records it in the review's `finding_responses` (the `reviews.finding_responses` column, a JSON object from index to `{response, reason}`; a later call for the same finding replaces it) and emits `review.finding` with actor `agent`. The answer also clears the finding's `fixed` state, since it answers that fix request: an ignore after the user's **fix** brings **fix** back so the user can overrule it again. A user's `ignored` stays. A call naming a snapshot with no `done` review, or a finding it doesn't have, is refused. On the finding, an answer shows as "agent fixing: …" or "agent ignoring: …" and dims the finding's diff, detail and screenshots. Fixing takes away both buttons; ignoring keeps **fix**, so the user can overrule it, and drops **ignore**. The tool call itself is its own transcript row (not folded into a group of tool calls): what the agent answered for which finding, its title and reason, a **show** button that scrolls to the finding, and, for an ignore, the same **fix** button while the user hasn't answered the finding. A call that fails is hidden. The Merge confirmation's review warning counts findings the user hasn't ignored, whatever the agent answered. -- **Snapshot row.** Each snapshot row's **review** button shows that snapshot's latest `review.*` event: a border in the task's colour while the review runs, and otherwise a mark over its top-right corner: a grey ● when it was skipped, a green ✓ when it finished with no open findings (none, or each answered by the user or the agent), and, when it has open findings, the button highlighted with their count in a badge. A failed review leaves the button unmarked. While any of a task's reviews is `running` and its agent is idle, the task's state is `reviewing` (see "Task state in the store"), which the task switcher and the Project tab show as **Reviewing** (see "Task state"). `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. +- **Forwarding.** When a submitted review has findings, byre sends them to the task's live agent as a `byre` message (`forwardFindings` and `findingsMessage` in `internal/agents/review.go`): the reviewer's stage name, and each finding's number, severity, title, location and detail, under a preamble asking the agent to check each one against the code and its brief and, before changing anything, answer each with `respond_to_finding` (see "Agent responses"). Each reviewer's findings come in their own message. It goes through `Manager.Message`, so it is queued like any message while the agent is mid-turn. A task with no live agent session gets nothing, and the log says `review findings not forwarded`. Forwarding doesn't set any finding's state; **fix** and **ignore** stay available until the agent or the user answers. +- **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. `StopReview` ends the reviewer the same way and fails the review; `Watch` calls it for a reviewer whose process the health check finds `stuck` (no stream event for `StuckAfter`), which is how a reviewer whose connection died while the machine slept gets cleared. Reviews are of all the task's work, so a newer one makes older ones moot: once a snapshot's review starts, or is skipped for having no diff, byre fails the task's running reviews of earlier snapshots ("superseded by snapshot N") and ends their reviewers (`failRunningReviews`). Each snapshot's review starts on its own goroutine under `reviewMu`, so they can run out of order; `afterSnapshot` starts no review for a snapshot when the task already has a review of a later one. 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; then every `done` review of an active task's latest reviewed snapshot goes through `reviewFinished` again (`LatestDoneStages`), so a stage whose successors a restart cut off before they started still starts them. +- **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 draws them inside that reviewer's row (below), not as the task's agent's; 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.** Reviews live in the task's transcript (`reviewerEvent`, `reviewerState` and `renderReview` in `mac/Sources/Byre/TranscriptBuilder.swift`, rows in `mac/Sources/Byre/Reviews.swift`). Each reviewer session is a folded row at its `session.created` (role `reviewer`), keyed by session id so a snapshot can have several: "<stage> · <model>", then while it runs its latest step (its last tool call, or the first line of its last message) and "running" in the accent colour; once its review finishes, "superseded" or the severity counts ("no findings") muted, or "failed" in the danger colour with the error. Expanding it shows the reviewer's prompt, messages and tool calls read-only, drawn as a subagent's are (runs of tool calls fold into one row); its streamed text isn't shown. A skipped review has no session and no row. A finished review with findings is also a note at its `review.finished` event, "<stage> review of snapshot #n:" with its severity counts; the note carries the summary and screenshots, and each finding follows as its own row, numbered from 1 as in the message to the agent. 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 against the default branch (`changes?snapshot=<n>`, 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. Clicking a screenshot opens it full size. A finding with no answer from the user or the agent has **fix** and **ignore** buttons. **fix** sends the finding's number, 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; the user can't clear one, and only the agent's answer clears a `fixed` one (below). +- **Agent responses.** The message forwarding findings to the agent numbers them and tells it to answer each with the task agent's `respond_to_finding` MCP tool (`respondToFindingTool` in `internal/agents/review.go`) before changing anything: `snapshot`, `reviewer` (the stage name), `finding` (from 1 in that reviewer's review), `response` (`fixing` or `ignoring`) and a one-line `reason`. byre records it in the review's `finding_responses` (the `reviews.finding_responses` column, a JSON object from index to `{response, reason}`; a later call for the same finding replaces it) and emits `review.finding` with actor `agent`. The answer also clears the finding's `fixed` state, since it answers that fix request: an ignore after the user's **fix** brings **fix** back so the user can overrule it again. A user's `ignored` stays. A call naming a snapshot and reviewer with no `done` review, or a finding it doesn't have, is refused. On the finding, an answer shows as "agent fixing: …" or "agent ignoring: …" and dims the finding's diff, detail and screenshots. Fixing takes away both buttons; ignoring keeps **fix**, so the user can overrule it, and drops **ignore**. The tool call itself is its own transcript row (not folded into a group of tool calls): what the agent answered for which finding, its title and reason, a **show** button that scrolls to the finding, and, for an ignore, the same **fix** button while the user hasn't answered the finding. A call that fails is hidden. The Merge confirmation's review warning counts findings the user hasn't ignored, whatever the agent answered. +- **State.** Snapshot rows carry no review mark; the reviewer rows show each review. While any of a task's reviews is `running` and its agent is idle, the task's state is `reviewing` (see "Task state in the store"), which the task switcher and the Project tab show as **Reviewing** (see "Task state"). `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, stage, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones. ## Workflows -A workflow is a named graph of stages, each a stage name, the stages it comes after, where its branch starts, a model and the stage agent's prompt written out in full. Every stage runs as its own task, once all the stages it comes after have finished, so one stage's output can go to several stages (fan-out) and one stage can wait for several (fan-in). Workflows live in byre's database, in the `workflows` table (`name`, and the definition's JSON as `definition`), shared by every project and never in a repo. byre stores, validates, exports, imports and freezes them, and runs each run's frozen copy stage by stage (`internal/agents/workflows.go`; the graph helpers `After`, `Next`, `Starts` and `FromMain` are in `internal/workflow/workflow.go`). The human starts one from the task switcher (see "Starting"), or from the left pane's Workflows tab, which lists them and hands a run to the lead (see "UI"); there is no UI for managing them: the lead does that with its tools, and draws a workflow for the human as a Mermaid flowchart of its stage names, which its chat renders (see "Inbox"). A run's progress is shown read-only in the stage tasks' Workflow tab (see "Progress" below). +A workflow is a named tree of steps: stages, each a stage name, a model and the stage agent's prompt written out in full, nested in `order` and `parallel` blocks. Every stage runs as its own task, once the step before it has finished, so a stage's output can go to several branches of a `parallel` (fan-out) and the stage after a `parallel` waits for all its branches (fan-in). Workflows live in byre's database, in the `workflows` table (`name`, and the definition's JSON as `definition`), shared by every project and never in a repo. byre stores, validates, exports, imports and freezes them, and runs each run's frozen copy stage by stage (`internal/agents/workflows.go`; the graph helpers `After`, `Next`, `Starts` and `FromMain` are in `internal/workflow/workflow.go`). The human starts one from the task switcher (see "Starting"), or from the left pane's Workflows tab, which lists them and hands a run to the lead (see "UI"); the human edits them in that tab (see "UI"), and the lead with its tools, drawing a workflow for the human as a Mermaid flowchart of its stage names, which its chat renders (see "Inbox"). A run's progress is shown read-only in the stage tasks' Workflow tab (see "Progress" below). - **Names.** The name is the workflow's key and is shown to the human on buttons and pickers, so it is free text: one line of 1 to 100 characters, trimmed (`workflow.CleanName`). It isn't part of the definition, so the file doesn't carry it. In URLs it is a path-escaped segment. -- **File.** One workflow per file: the `WorkflowDefinition` (`api/openapi.yaml`) as YAML, an optional `description` and its `stages`. A stage's `after` lists the stages whose outputs it gets; absent, it means the previous stage in the list (none for the first), so a file without `after` is a chain; `[]` means the stage starts from the brief. Its `base` is `parent` (the default: the commit its one `after` stage finished at, or for a stage with none, where the run was started from) or `main` (the project's default branch); a stage with more than one `after` stage always starts from the default branch. In place of `model`, a stage may list `agents` (each `name`, `model` and `prompt`) to run as a team in its one task (see "Teams"): each agent gets the stage's message and then its own prompt, and the first agent calls `finish_stage`. `workflow.Format` writes it with multi-line prompts as literal blocks; `workflow.Parse` reads YAML or JSON and refuses unknown fields. The download is named after the workflow with `/\:*?"<>|` replaced by `_` (`workflow.Filename`). -- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed) with no stages, a stage name that isn't a valid one-line name or repeats another stage's, an empty model without `agents` or a model with them, `agents` that fail `workflow.TeamProblems`, an empty prompt, a `base` other than `parent` or `main`, a `base` on a stage with more than one `after` stage, an `after` naming the stage itself, a name twice or a name that isn't a stage, a cycle, or anything but exactly one final stage (one no other stage comes after). A stage's vendor follows from its model (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, `gemini-*` Antigravity, everything else Claude Code); a file with a `vendor` key is refused as an unknown field. -- **API.** `GET /api/workflows` lists every workflow's name and description. `GET /api/workflows/{name}` downloads its file; `PUT` takes a file as the body and creates or replaces the workflow; `DELETE` removes it. Each save or delete records a `workflow.updated` or `workflow.deleted` event carrying the name. `sqlite3 byre.db 'SELECT name FROM workflows'` lists them. Saves and reads go through the Manager (`internal/agents/workflows.go`) so the HTTP API and the lead's tools share them. +- **File.** One workflow per file: the `WorkflowDefinition` (`api/openapi.yaml`) as YAML, an optional `description`, an optional `kind` and its `steps`, run in order. `kind` is `task` (the default: run on a brief, each stage its own task) or `review` (a project's review workflow, each stage a reviewer of every snapshot, see "Reviews"); a review workflow's stages can't have `agents`, it may end with several stages, and starting it as a run is refused. `GET /api/workflows` gives each workflow's `kind`; the task switcher offers only task workflows, and the Workflows tab marks review ones "reviews snapshots" with no run button. A step (`WorkflowStep`) is exactly one of a `stage` (its name, with `model` or `agents`, and `prompt`), an `order` (steps one after another) or a `parallel` (branches at the same time). A stage gets the outputs of the stages that end the step before it (the last stage of an `order`, the last stage of each branch of a `parallel`; `workflow.After` derives them by walking the tree), and a stage with nothing before it starts from the brief. Where its task branches from follows from that: from the commit its one earlier stage finished at, from the default branch when it comes after several, and for a stage with none, from where the run was started. In place of `model`, a stage may list `agents` (each `name`, `model` and `prompt`) to run as a team in its one task (see "Teams"): each agent gets the stage's message and then its own prompt, and the first agent calls `finish_stage`. `workflow.Format` writes it with multi-line prompts as literal blocks; `workflow.Parse` reads YAML or JSON and refuses unknown fields. The download is named after the workflow with `/\:*?"<>|` replaced by `_` (`workflow.Filename`). +- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed, each located by its path such as `steps[0].parallel[1]`) with no steps, a step that isn't exactly one of `stage`, `order` and `parallel`, `model`, `prompt` or `agents` on an `order` or `parallel`, an empty `order` or `parallel`, a stage name that isn't a valid one-line name or repeats another stage's, an empty model without `agents` or a model with them, `agents` that fail `workflow.TeamProblems`, an empty prompt, or a tree that doesn't end with exactly one stage (the final stage, which ends the run). The tree can't express a cycle. A stage's vendor follows from its model (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, `gemini-*` Antigravity, everything else Claude Code); a file with a `vendor` key is refused as an unknown field. +- **API.** `GET /api/workflows` lists every workflow's name and description. `GET /api/workflows/{name}` downloads its file, or returns the definition as JSON when the request's `Accept` names `application/json` and no YAML type; `PUT` takes a file (YAML or JSON) as the body and creates or replaces the workflow; `DELETE` removes it. Each save or delete records a `workflow.updated` or `workflow.deleted` event carrying the name. `sqlite3 byre.db 'SELECT name FROM workflows'` lists them. Saves and reads go through the Manager (`internal/agents/workflows.go`) so the HTTP API and the lead's tools share them. - **Lead tools.** `list_workflows`, `read_workflow` (`name`, or `task_id` for a task's frozen copy), `save_workflow` (`name` and `definition`, the file as YAML) and `delete_workflow`, with the same checks and errors as the API. Task agents don't get them. -- **Starting.** The lead's `create_task` and `start_todo` take an optional `workflow` (404 when there is none of that name) and then start a run of it instead of an agent on the prompt (`Manager.StartWorkflow`); `model` together with `workflow` is refused, since each stage names its own. `POST /api/projects/{id}/tasks` takes the same optional `workflow`, with the title, required then, as the run's brief, and returns the run's first stage task. The task switcher (`mac/Sources/Byre/Switcher.swift`) loads `GET /api/workflows` when it opens, and when the message (after any project prefix) starts with a workflow's name, ignoring case, followed by a space or a colon and more text (the longest such name when several match), it offers a "Start <workflow> in …" row before each "Start in …" row: `deploy: byre` or `deploy byre` starts a run of `deploy` with `byre` as its title and brief, and opens its first stage task. Text that starts with no workflow's name gets no workflow rows. The switcher's box grows with what is typed (Shift+Enter adds a line) and takes pasted or dropped images; with images attached it lists only the "Start in …" rows, and "Start in …" uploads them as blobs and sends their ids as `POST /api/projects/{id}/tasks`'s `images`, which start the agent with or without a `prompt` (refused with `workflow`). Closing the switcher drops its images. A run gets a new id and creates a task for every stage with no `after` stages, branched from the default branch, or for a stage with `base: parent`, from the lead's `from_task_id` HEAD when given. Each stage task copies the workflow's name and definition as they are when the run starts into `tasks.workflow_name` and `tasks.workflow_definition`, so later saves or deletes don't change it; its brief into `tasks.workflow_brief` (the lead's `create_task` `prompt`, optional with a workflow and may be empty, or for `start_todo` the TODO item as `todoPrompt` words it); the run's title into `tasks.workflow_title`; the run id into `tasks.workflow_run`; and its stage's name into `tasks.workflow_stage`. Stage tasks are titled `<title>: <stage>`, with the run's title as their merge title, and carry the run's TODO item. The Task's `workflow`, `workflow_run` and `workflow_stage` are those; `GET /api/tasks/{id}/workflow` returns the copy as `{name, definition}` with the definition as YAML (404 for a task without one). A task agent's `create_subtask`, `POST /api/projects/{id}/tasks` without `workflow` and the Project tab's Start create tasks without one. A fork of a stage task copies its frozen workflow, brief, title, run and stage, so it is another attempt at the same stage of the same run. `sqlite3 byre.db "SELECT id, status, workflow_stage, stage_output != '' FROM tasks WHERE workflow_run = '<run>'"` lists a run's tasks and which have finished. +- **Starting.** The lead's `create_task` and `start_todo` take an optional `workflow` (404 when there is none of that name) and then start a run of it instead of an agent on the prompt (`Manager.StartWorkflow`); `model` together with `workflow` is refused, since each stage names its own. `POST /api/projects/{id}/tasks` takes the same optional `workflow`, with the title, required then, as the run's brief, and returns the run's first stage task. The task switcher (`mac/Sources/Byre/Switcher.swift`) loads `GET /api/workflows` when it opens, and when the message (after any project prefix) starts with a workflow's name, ignoring case, followed by a space or a colon and more text (the longest such name when several match), it offers a "Start <workflow> in …" row before each "Start in …" row: `deploy: byre` or `deploy byre` starts a run of `deploy` with `byre` as its title and brief, and opens its first stage task. Text that starts with no workflow's name gets no workflow rows. The switcher's box grows with what is typed (Shift+Enter adds a line) and takes pasted or dropped images; with images attached it lists only the "Start in …" rows, and "Start in …" uploads them as blobs and sends their ids as `POST /api/projects/{id}/tasks`'s `images`, which start the agent with or without a `prompt` (refused with `workflow`). Closing the switcher drops its images. A run gets a new id and creates a task for every stage with nothing before it (`workflow.Starts`), branched from the lead's `from_task_id` HEAD when given, else from the default branch. Each stage task copies the workflow's name and definition as they are when the run starts into `tasks.workflow_name` and `tasks.workflow_definition`, so later saves or deletes don't change it; its brief into `tasks.workflow_brief` (the lead's `create_task` `prompt`, optional with a workflow and may be empty, or for `start_todo` the TODO item as `todoPrompt` words it); the run's title into `tasks.workflow_title`; the run id into `tasks.workflow_run`; and its stage's name into `tasks.workflow_stage`. Stage tasks are titled `<title>: <stage>`, with the run's title as their merge title, and carry the run's TODO item. The Task's `workflow`, `workflow_run` and `workflow_stage` are those; `GET /api/tasks/{id}/workflow` returns the copy as `{name, definition}` with the definition as YAML (404 for a task without one). A task agent's `create_subtask`, `POST /api/projects/{id}/tasks` without `workflow` and the Project tab's Start create tasks without one. A fork of a stage task copies its frozen workflow, brief, title, run and stage, so it is another attempt at the same stage of the same run. `sqlite3 byre.db "SELECT id, status, workflow_stage, stage_output != '' FROM tasks WHERE workflow_run = '<run>'"` lists a run's tasks and which have finished. - **Stages.** A stage task's agent is an implementer session on the stage's model (for a team stage, one per agent, see "Teams"), created by the user who started the run; any later session of the task (a Compact handoff's, one started by a message) runs the same stage, since the stage belongs to the task. Starting a stage records a `task.workflow_stage` event on its task (`workflow`, `run`, `stage` the name, `after` the ids of the tasks whose outputs it gets; actor the lead for the stages a run starts with, `byre` after) and sends the session its first message, actor `byre`, so the transcript shows it collapsed under its first line: `Workflow "<name>", stage "<stage>"`, then the stage's prompt, a `## Brief` section when the brief isn't empty, a `## From stage "<stage>" (task <id>, branch <branch>)` section with each `after` stage's output, and a line saying where the task starts: from the default branch, with the branches above to `git merge` or read as it needs, or from the commit its one `after` stage finished at. Nothing else of earlier stages is passed on; their branches carry their work. -- **Advancing.** A stage session gets the `finish_stage` tool (`output`, required) and a guidance line naming the workflow, its stage and the stages after it. The call stores the output in `agent_sessions.stage_output`, replacing any earlier one; it is refused once the task's stage has finished. When a turn of that session ends normally (not an error or interrupt, not a handoff, not a Merge turn that squashes) with an output stored, byre sets `agent_sessions.stage_finished` before marking the session idle, and then advances (`nextStage`): it records the output and the worktree's HEAD in `tasks.stage_output` and `tasks.stage_commit`, then, holding `workflowMu` so two stages finishing at once can't both start the one after them, goes through each stage after this one. A stage starts once every stage it comes after has a finished task in the run (the latest one, when a fork made several); its task branches from that task's `stage_commit` for `base: parent` with one `after` stage, else from the default branch. Once every stage after it has been dealt with, byre clears `stage_output` and `stage_finished` on the session. The session stays idle in its task for more messages, and queued messages and held answers are delivered to it once advancing is done. When the final stage finishes, byre starts discarding every other active task of the run (as `byre`, through the same `Discard` as the button, so each gets its summary turn first and closes when that turn ends; one already merging or being discarded is skipped and logged as `stage task not discarded`), then records `task.workflow_finished` (`workflow`, `output`) on the final task and clears the flags in the same transaction, leaving the final task for the human to merge. A turn that ends without the call leaves the task in its stage. The output belongs to the turn that stored it: when that turn ends any other way (an error, an interrupt, a handoff, a Merge turn that squashes, an agent process that exits mid-turn, or a session that can't be resumed after a restart), byre clears `stage_output` before marking the session idle, so a later turn can't hand off a stale output; the agent calls `finish_stage` again. A turn cut off by a byre restart and resumed keeps it. If a stage can't start, byre logs `advance workflow stage` and records the error in the finishing task's transcript, and the flag stays set. On startup, before resuming running sessions, `recoverStages` advances every session with `stage_finished` set, skipping the steps already done: the recorded output isn't overwritten; a stage that already has a task in the run isn't created again, but when that task is active and has no session, its sessions are started, and each of its sessions that never got a message (`SessionStarted`) is sent the stage's first message (a team member's with its own prompt), and the restart notice isn't sent to them. `sqlite3 byre.db 'SELECT id, task_id FROM agent_sessions WHERE stage_finished = 1'` lists stages waiting to advance. `list_tasks` shows each stage task's workflow and `stage "<name>"`, with `(finished)` once its stage has finished and `workflow finished` once any task of the run has a `task.workflow_finished` event. Nothing reads or writes `agent_sessions.workflow_stage`; it stays in the schema so builds from older branches keep working against the same database. -- **Progress.** `GET /api/tasks/{id}/workflow/run` (`Manager.WorkflowRun`) returns the run of a stage task (404 for a task without one): the definition that task froze (typed, not YAML), name, title, brief, `finished` (any task of the run has `task.workflow_finished`), and per stage in definition order its resolved `after`, `waiting_on` (the `after` stages with no finished attempt), its `attempts` and a `state`. An attempt is every task of the run at that stage, closed ones included, oldest first (the first and its forks), with its `output` and `commit` once `tasks.stage_output` is set, `forked_from`, `discarded_by` (the `task.discarded` actor) and `inputs`: the tasks named in the `after` of its latest `task.workflow_stage` event, each with its stage, so it says which attempt fed it; a fork has no such event and takes its source's. An attempt's state is `merged`, `cleaned_up` (discarded after finishing; `byre` as `discarded_by` is the final stage's cleanup), `discarded` (before finishing), `finished`, `running` (a session is running) or `idle`. A stage is `waiting` with no attempts, else it takes the state of its latest finished attempt, or its latest attempt when none finished. The Workflow tab (`mac/Sources/Byre/Workflow.swift`) draws the graph natively with the flowchart layout from `Diagram.swift`, each stage's node coloured by its state (`stateColors`, from the palette's tint and accent pairs: waiting grey, running accent, idle warn, finished, merged and cleaned up ok, discarded danger) and redrawn whenever a refetch changes a state; then a card per stage with its state, its attempts linking to their tasks, each attempt's inputs ("started from") and the attempts it fed, and its output collapsed as Markdown. It refetches on any stored `task.*` or `session.*` event of a task in the run, and on a `task.created` whose payload carries the run, so a new stage or fork shows up. +- **Advancing.** A stage session gets the `finish_stage` tool (`output`, required) and a guidance line naming the workflow, its stage and the stages after it. The call stores the output in `agent_sessions.stage_output`, replacing any earlier one; it is refused once the task's stage has finished. When a turn of that session ends normally (not an error or interrupt, not a handoff, not a Merge turn that squashes) with an output stored, byre sets `agent_sessions.stage_finished` before marking the session idle, and then advances (`nextStage`): it records the output and the worktree's HEAD in `tasks.stage_output` and `tasks.stage_commit`, then, holding `workflowMu` so two stages finishing at once can't both start the one after them, goes through each stage after this one. A stage starts once every stage it comes after has a finished task in the run (the latest one, when a fork made several); its task branches from that task's `stage_commit` when it comes after one stage, else from the default branch. Once every stage after it has been dealt with, byre clears `stage_output` and `stage_finished` on the session. The session stays idle in its task for more messages, and queued messages and held answers are delivered to it once advancing is done. When the final stage finishes, byre starts discarding every other active task of the run (as `byre`, through the same `Discard` as the button, so each gets its summary turn first and closes when that turn ends; one already merging or being discarded is skipped and logged as `stage task not discarded`), then records `task.workflow_finished` (`workflow`, `output`) on the final task and clears the flags in the same transaction, leaving the final task for the human to merge. A turn that ends without the call leaves the task in its stage. The output belongs to the turn that stored it: when that turn ends any other way (an error, an interrupt, a handoff, a Merge turn that squashes, an agent process that exits mid-turn, or a session that can't be resumed after a restart), byre clears `stage_output` before marking the session idle, so a later turn can't hand off a stale output; the agent calls `finish_stage` again. A turn cut off by a byre restart and resumed keeps it. If a stage can't start, byre logs `advance workflow stage` and records the error in the finishing task's transcript, and the flag stays set. On startup, before resuming running sessions, `recoverStages` advances every session with `stage_finished` set, skipping the steps already done: the recorded output isn't overwritten; a stage that already has a task in the run isn't created again, but when that task is active and has no session, its sessions are started, and each of its sessions that never got a message (`SessionStarted`) is sent the stage's first message (a team member's with its own prompt), and the restart notice isn't sent to them. `sqlite3 byre.db 'SELECT id, task_id FROM agent_sessions WHERE stage_finished = 1'` lists stages waiting to advance. `list_tasks` shows each stage task's workflow and `stage "<name>"`, with `(finished)` once its stage has finished and `workflow finished` once any task of the run has a `task.workflow_finished` event. Nothing reads or writes `agent_sessions.workflow_stage`; it stays in the schema so builds from older branches keep working against the same database. +- **Progress.** `GET /api/tasks/{id}/workflow/run` (`Manager.WorkflowRun`) returns the run of a stage task (404 for a task without one): the definition that task froze (typed, not YAML), name, title, brief, `finished` (any task of the run has `task.workflow_finished`), and per stage in the tree's order its `after` (the stages whose outputs it gets, from `workflow.After`), `waiting_on` (the `after` stages with no finished attempt), its `attempts` and a `state`. An attempt is every task of the run at that stage, closed ones included, oldest first (the first and its forks), with its `output` and `commit` once `tasks.stage_output` is set, `forked_from`, `discarded_by` (the `task.discarded` actor) and `inputs`: the tasks named in the `after` of its latest `task.workflow_stage` event, each with its stage, so it says which attempt fed it; a fork has no such event and takes its source's. An attempt's state is `merged`, `cleaned_up` (discarded after finishing; `byre` as `discarded_by` is the final stage's cleanup), `discarded` (before finishing), `finished`, `running` (a session is running) or `idle`. A stage is `waiting` with no attempts, else it takes the state of its latest finished attempt, or its latest attempt when none finished. The Workflow tab (`mac/Sources/Byre/Workflow.swift`) draws the graph natively with the flowchart layout from `Diagram.swift`, each stage's node coloured by its state (`stateColors`, from the palette's tint and accent pairs: waiting grey, running accent, idle warn, finished, merged and cleaned up ok, discarded danger) and redrawn whenever a refetch changes a state; then a card per stage with its state, its attempts linking to their tasks, each attempt's inputs ("started from") and the attempts it fed, and its output collapsed as Markdown. It refetches on any stored `task.*` or `session.*` event of a task in the run, and on a `task.created` whose payload carries the run, so a new stage or fork shows up. ## Events diff --git a/internal/agents/afterreview.go b/internal/agents/afterreview.go index 8084bb9..aa354a8 100644 --- a/internal/agents/afterreview.go +++ b/internal/agents/afterreview.go @@ -72,7 +72,7 @@ func (m *Manager) MergeAfterReview(ctx context.Context, task api.Task, user api. } func (m *Manager) clearMergeAfterReview(ctx context.Context, taskID string) { - _, err := m.store.TakeMergeAfterReview(ctx, taskID, "") + _, err := m.store.TakeMergeAfterReview(ctx, taskID, nil) if err != nil { slog.Error("clear merge after review", "task", taskID, "err", err) } @@ -96,7 +96,27 @@ func (m *Manager) reviewFinished(reviewID string) { slog.Error("load finished review", "review", reviewID, "err", err) return } - mar, err := m.store.TakeMergeAfterReview(ctx, r.TaskId, r.Id) + if m.advanceReviews(ctx, r) { + return + } + reviews, err := m.snapshotReviews(ctx, r.TaskId, r.SnapshotN) + if err != nil { + slog.Error("list snapshot reviews", "task", r.TaskId, "err", err) + return + } + settled, err := m.reviewsSettled(ctx, reviews) + if err != nil { + slog.Error("check snapshot reviews settled", "task", r.TaskId, "err", err) + return + } + if !settled { + return + } + ids := []string{} + for _, sr := range reviews { + ids = append(ids, sr.Id) + } + mar, err := m.store.TakeMergeAfterReview(ctx, r.TaskId, ids) if err != nil { slog.Error("take merge after review", "task", r.TaskId, "err", err) return @@ -104,7 +124,7 @@ func (m *Manager) reviewFinished(reviewID string) { if mar.By == "" { return } - reason := m.startMergeAfterReview(ctx, r, mar) + reason := m.startMergeAfterReview(ctx, r, reviews, mar) if reason == "" { slog.Info("merge after review started", "task", r.TaskId, "review", r.Id, "by", mar.By) return @@ -121,16 +141,19 @@ func (m *Manager) reviewFinished(reviewID string) { } } -func (m *Manager) startMergeAfterReview(ctx context.Context, r api.Review, mar store.MergeAfterReview) string { - switch { - case r.State == api.ReviewStateFailed: - return "the review failed" - case r.State != api.ReviewStateDone: - return fmt.Sprintf("the review ended %s", r.State) - } +func (m *Manager) startMergeAfterReview(ctx context.Context, r api.Review, reviews []api.Review, mar store.MergeAfterReview) string { findings := []any{} - if r.Result != nil { - findings, _ = (*r.Result)["findings"].([]any) + for _, sr := range reviews { + switch { + case sr.State == api.ReviewStateFailed: + return fmt.Sprintf("the %q review failed", sr.Stage) + case sr.State != api.ReviewStateDone: + return fmt.Sprintf("the %q review ended %s", sr.Stage, sr.State) + } + if sr.Result != nil { + f, _ := (*sr.Result)["findings"].([]any) + findings = append(findings, f...) + } } switch len(findings) { case 0: diff --git a/internal/agents/kb/workflow-files.md b/internal/agents/kb/workflow-files.md index 42447ad..13875e2 100644 --- a/internal/agents/kb/workflow-files.md +++ b/internal/agents/kb/workflow-files.md @@ -1,36 +1,41 @@ -The YAML format of a workflow's file, which read_workflow returns and save_workflow takes: stages, after, base, model and teams. - # Workflow files -A workflow's file is YAML: an optional `description`, then `stages`. The workflow's name is not in the file. Unknown fields are refused. +A workflow's file is YAML: an optional `description`, an optional `kind`, then `steps`, which run in order. The workflow's name is not in the file. Unknown fields are refused. -| Field | Meaning | +`kind` is `task` (the default) or `review`. A task workflow runs on a brief, each stage as its own task. A review workflow is what a project names (with `set_review_workflow`) to review its tasks' snapshots: each stage is one reviewer agent on its `model`, given the task's change and its stage's `prompt` (what to look for); a stage after others also gets their reviews. Its stages can't use `agents`, and it may end with several stages. + +Each step is exactly one of: + +| Step | Meaning | +|---|---| +| `stage` | A stage, by its name: one line, unique in the workflow. It runs as its own task, with `model` or `agents`, and `prompt`. | +| `order` | A list of steps run one after another. | +| `parallel` | A list of branches run at the same time, each a step (an `order` for a branch of several). | + +A stage gets the outputs of the stages that end the step before it: the last stage of an `order`, or the last stage of every branch of a `parallel`. A stage with nothing before it starts from the brief. + +| Stage field | Meaning | |---|---| -| `name` | One line, unique in the workflow. | -| `after` | The names of the stages whose outputs it gets; it starts once all of them have finished. Absent means the previous stage in the list, and for the first stage none, so a file without `after` is a chain. `[]` means it starts from the brief. | -| `base` | `parent`, the default: branch from the commit its one `after` stage finished at, or for a stage with none, from where the workflow was started. `main`: branch from the default branch. Refused on a stage with more than one `after` stage, which always branches from the default branch and is told the branches it can merge. | | `model` | Also picks the vendor: `gpt-*` models run on Codex, `gemini-*` models on Antigravity, every other model on Claude Code. | | `prompt` | The stage agent's instructions. | | `agents` | In place of `model`: a list of at least two, each with `name`, `model` and `prompt`, that runs the stage as a team. They work in the stage's one task and worktree and talk through a shared board; each gets the stage's `prompt` and then its own, and the first agent finishes the stage. | -Every stage runs as its own task. Exactly one stage is final (no other stage comes after it), and cycles are refused. +Where a stage's task branches from follows from the shape. A stage after exactly one stage starts from the commit that stage finished at. A stage after a `parallel` starts from the default branch and is told the branches it can merge. A stage with nothing before it starts where the workflow was started. The workflow must end with one stage, which finishes it. An `order` or `parallel` can't be empty. ```yaml description: Build a change, review and test it in parallel, then fix what they found. -stages: - - name: Build +steps: + - stage: Build model: claude-opus-5-5 prompt: Implement the brief. - - name: Review - after: [Build] - model: gpt-6.1-sol - prompt: Review the Build stage's work and list the problems. - - name: Test - after: [Build] - model: gemini-4-argon-high - prompt: Exercise the change and report what fails. - - name: Fix - after: [Review, Test] + - parallel: + - stage: Review + model: gpt-6.1-sol + prompt: Review the Build stage's work and list the problems. + - stage: Test + model: gemini-4-argon-high + prompt: Exercise the change and report what fails. + - stage: Fix agents: - name: fixer model: claude-opus-5-5 diff --git a/internal/agents/lead.go b/internal/agents/lead.go index f5ebcfd..adcde0b 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -29,11 +29,11 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left - Questions from task agents: answer one yourself with answer_question only when the answer is clear, for example the agent's recommendation is plainly right given what the human has said, the human already decided it, or one of your notes settles it outright. Otherwise show it to the human with your recommendation and wait. When the human tells you an answer, give it with answer_question. Say which questions you answered and how, naming the note by its id when one decided it. - Notes: you learn how the human works and keep it with remember, forget and list_notes. byre adds every note to your instructions at the start of each lead session, so they outlast compaction; the human can read and delete them in the Settings tab. Write a note when the human shows a priority, a preference or the reasoning behind a choice that will recur: an answer that departs from the agent's recommendation (the answered signal shows both), a correction or overrule of you or a task agent, or a statement of how they want things done. Record the choice and the why, in their terms, general enough to apply to the next case and specific enough to act on, for example "Prefers replacing a state label over adding a tag beside it: keeps the picker uncluttered." File each note under the project it is about with project_id when it concerns that project's code, UI, docs or process, and with no project_id only when it holds for how the human works everywhere; apply a project's notes only to that project's tasks. Task agents start with their creator's every-project notes and their project's, and suggest new ones with suggest_note, which reaches you as a signal: save a suggestion only when it holds and your notes don't already cover it, choosing its project yourself, and otherwise drop it; neither needs a word to the human. Don't note one-off facts about a single task, and don't duplicate: update an existing note with remember and its id instead. When the human contradicts a note, update it or forget it. Before recommending anything, check your notes, and when one shaped the recommendation, cite its id. - Finished turns: read them and show the human only what matters: work finished and ready to merge, problems, decisions made. Stay quiet about routine progress. -- Reviews: after each new snapshot of a task, a reviewer agent reviews the change, on the human's review model, or their second review model when the review model wrote the work. The results are in the task's transcript, where its agent answers each finding with respond_to_finding (fixing, or ignoring with a reason), and read_transcript shows each review's findings. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding. +- Reviews: after each new snapshot of a task, the stages of its project's review workflow (a workflow of kind review, set per project with set_review_workflow) review the change, each a reviewer agent on its stage's model; a stage after others gets their reviews. A project with no review workflow isn't reviewed. The results are in the task's transcript, where its agent answers each finding with respond_to_finding (fixing, or ignoring with a reason), and read_transcript shows each review's findings. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding. - Dig into context with list_tasks, read_transcript, read_inbox and read_status (vendor rate limits and agent health), and with your own Read, Grep and Bash on a task's worktree path (from list_tasks). Don't edit files in task worktrees; ask the task's agent with send_message. - You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it. - Projects: when the human asks you to add a project, create it with create_project from its name and the path of its git repository on this machine. -- Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. Every stage of a workflow runs as its own task on the stage's model, once the stages it comes after have finished, so stages fan out (several stages after one) and fan in (one stage after several); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and when the final stage finishes byre discards the run's other stage tasks, leaving the final one to merge. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix". +- Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A workflow is a tree of steps run in order, each a stage, an order or a parallel; every stage runs as its own task on the stage's model once the step before it has finished, so stages fan out (a parallel after a stage) and fan in (a stage after a parallel); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and when the final stage finishes byre discards the run's other stage tasks, leaving the final one to merge. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix". - Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer or a finished turn, link to #/task/<task_id>/transcript. - Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line <details><summary>Title</summary>, the Markdown content, then a line </details>. - You are long-running. Your context window is large. byre sends you a signal once your context passes a quarter of its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; when the turn ends, byre replaces you with a fresh session that starts from your handoff. @@ -564,7 +564,7 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) return fmt.Sprintf("%s's merge was requested by %s and is held until the merge in progress in its project is done.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil case "task.merge_after_review": - return fmt.Sprintf("%s was set by %s to merge once the review of snapshot %v finishes with no findings.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, ev.Payload["snapshot_n"]), nil + return fmt.Sprintf("%s was set by %s to merge once the reviews of snapshot %v finish with no findings.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, ev.Payload["snapshot_n"]), nil case "task.merge_after_review_cancelled": return fmt.Sprintf("%s's merge after review was cancelled by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil @@ -613,7 +613,7 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) return "", err } if ev.Kind == "review.started" { - return fmt.Sprintf("%s started a review of snapshot %d.", m.taskLabel(ctx, r.TaskId), r.SnapshotN), nil + return fmt.Sprintf("%s started the %s.", m.taskLabel(ctx, r.TaskId), reviewName(r)), nil } return fmt.Sprintf("%s %s", m.taskLabel(ctx, r.TaskId), reviewLine(r)), nil diff --git a/internal/agents/notes.go b/internal/agents/notes.go index 79240e3..a1bfaf9 100644 --- a/internal/agents/notes.go +++ b/internal/agents/notes.go @@ -53,7 +53,7 @@ func (m *Manager) mergeNote(ctx context.Context, task api.Task, login string) (s if len(reviews) > 0 { b.WriteString("\n## Reviews\n") for _, r := range reviews { - fmt.Fprintf(b, "\n### Snapshot %d, %s %s: %s\n", r.SnapshotN, r.Vendor, r.Model, r.State) + fmt.Fprintf(b, "\n### Snapshot %d, reviewer %q, %s %s: %s\n", r.SnapshotN, r.Stage, r.Vendor, r.Model, r.State) if r.Error != "" { fmt.Fprintf(b, "\n%s\n", r.Error) } diff --git a/internal/agents/ready.go b/internal/agents/ready.go index ebf119a..648fb18 100644 --- a/internal/agents/ready.go +++ b/internal/agents/ready.go @@ -174,24 +174,30 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview if err != nil { return err } - reviews, err := m.store.ListReviews(ctx, taskID) + reviews, err := m.snapshotReviews(ctx, taskID, latest.N) if err != nil { return err } - var review *api.Review - for _, r := range reviews { - if r.SnapshotN == latest.N { - review = &r + if len(reviews) == 0 { + project, err := m.store.GetProject(ctx, task.ProjectId) + if err != nil || project.ReviewWorkflow != "" { + return err } } - if review == nil || (review.State != api.ReviewStateDone && review.State != api.ReviewStateSkipped) { - return nil + settled, err := m.reviewsSettled(ctx, reviews) + if err != nil || !settled { + return err } - if fromReview && review.State == api.ReviewStateDone && review.Result != nil { - findings, _ := (*review.Result)["findings"].([]any) - if len(findings) > 0 { + for _, r := range reviews { + if r.State != api.ReviewStateDone && r.State != api.ReviewStateSkipped { return nil } + if fromReview && r.Result != nil { + findings, _ := (*r.Result)["findings"].([]any) + if len(findings) > 0 { + return nil + } + } } due, err := m.store.ReadyPromptDue(ctx, taskID) if err != nil || !due { @@ -202,7 +208,7 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview return err } prompt := readyPrompt - if review.State == api.ReviewStateSkipped { + if len(reviews) == 1 && reviews[0].State == api.ReviewStateSkipped { prompt = readyPromptUnchanged } _, err = m.Send(ctx, sess.Id, "byre", prompt, "", "") diff --git a/internal/agents/review.go b/internal/agents/review.go index 90dbe5d..b5449cd 100644 --- a/internal/agents/review.go +++ b/internal/agents/review.go @@ -104,12 +104,61 @@ func (m *Manager) afterSnapshot(snap *api.Snapshot) { return } } - err = m.startReviewer(ctx, task, *snap) + err = m.startReviews(ctx, task, *snap) if err != nil { slog.Error("start review", "task", task.Id, "snapshot", snap.N, "err", err) } } +func (m *Manager) reviewsSettled(ctx context.Context, reviews []api.Review) (bool, error) { + byStage := map[string]api.Review{} + for _, r := range reviews { + if r.State == api.ReviewStateRunning { + return false, nil + } + byStage[r.Stage] = r + } + for _, r := range reviews { + if r.State != api.ReviewStateDone { + continue + } + def, err := m.store.ReviewDefinition(ctx, r.Id) + if err != nil { + return false, err + } + if len(def.Steps) == 0 { + continue + } + for _, next := range workflow.Next(def, r.Stage) { + if _, found := byStage[next]; found { + continue + } + ready := true + for _, a := range workflow.After(def, next) { + ready = ready && byStage[a].State == api.ReviewStateDone + } + if ready { + return false, nil + } + } + } + return true, nil +} + +func (m *Manager) snapshotReviews(ctx context.Context, taskID string, n int) ([]api.Review, error) { + reviews, err := m.store.ListReviews(ctx, taskID) + if err != nil { + return nil, err + } + out := []api.Review{} + for _, r := range reviews { + if r.SnapshotN == n { + out = append(out, r) + } + } + return out, nil +} + func (m *Manager) reviewDir(projectID, reviewID string) string { return filepath.Join(m.worktreeRoot, projectID, "review-"+reviewID) } @@ -126,54 +175,149 @@ func playwrightMCP(outputDir string) mcp.Stdio { } } -func (m *Manager) reviewerModel(ctx context.Context, task api.Task) (string, error) { - creator, err := m.store.GetUser(ctx, task.CreatedBy) - if err != nil { - return "", err - } - sessions, err := m.store.ListSessions(ctx, task.Id) - if err != nil { - return "", err - } - if len(sessions) > 0 && sessions[len(sessions)-1].Model == creator.ReviewModel { - return creator.SecondReviewModel, nil - } - return creator.ReviewModel, nil -} - -func (m *Manager) startReviewer(ctx context.Context, task api.Task, snap api.Snapshot) error { +func (m *Manager) startReviews(ctx context.Context, task api.Task, snap api.Snapshot) error { project, err := m.store.GetProject(ctx, task.ProjectId) if err != nil { return err } - model, err := m.reviewerModel(ctx, task) + defer m.failRunningReviews(ctx, task.Id, project, snap.N, fmt.Sprintf("superseded by snapshot %d", snap.N)) + if project.ReviewWorkflow == "" { + slog.Info("review not started, the project has no review workflow", "task", task.Id, "snapshot", snap.N) + return nil + } + changes, err := git.Compare(project.RepoPath, project.DefaultBranch, snap.CommitSha, snap.TreeSha, "") if err != nil { return err } - vendor := workflow.VendorOf(model) - prompt, err := m.reviewPrompt(ctx, task, project, snap) - if err != nil { - return err + now := time.Now().UTC() + if len(changes.Files) == 0 { + slog.Info("review skipped, snapshot has no diff", "task", task.Id, "snapshot", snap.N) + return m.store.CreateReview(ctx, api.Review{Id: store.NewID(), TaskId: task.Id, SnapshotN: snap.N, State: api.ReviewStateSkipped, CreatedAt: now, FinishedAt: &now}, nil) } - if prompt == "" { - now := time.Now().UTC() - err = m.store.CreateReview(ctx, api.Review{ + + def, err := m.store.GetWorkflow(ctx, project.ReviewWorkflow) + if err == nil && def.Kind != api.WorkflowKindReview { + err = fmt.Errorf("it is a task workflow, not a review workflow") + } + if err != nil { + return m.store.CreateReview(ctx, api.Review{ Id: store.NewID(), TaskId: task.Id, SnapshotN: snap.N, - State: api.ReviewStateSkipped, + Stage: project.ReviewWorkflow, + State: api.ReviewStateFailed, + Error: fmt.Sprintf("project %s reviews with workflow %q, which can't run: %v", project.Name, project.ReviewWorkflow, err), CreatedAt: now, FinishedAt: &now, - }) - if err != nil { - return err - } - slog.Info("review skipped, snapshot has no diff", "task", task.Id, "snapshot", snap.N) - m.failRunningReviews(ctx, task.Id, project, snap.N, fmt.Sprintf("superseded by snapshot %d", snap.N)) - return nil + }, nil) } + for _, stage := range workflow.Starts(def) { + err = m.startReviewer(ctx, task, project, snap, def, stage, nil) + if err != nil { + slog.Error("start review stage", "task", task.Id, "snapshot", snap.N, "stage", stage, "err", err) + } + } + return nil +} + +func (m *Manager) advanceReviews(ctx context.Context, r api.Review) bool { + if r.State != api.ReviewStateDone { + return false + } + m.reviewMu.Lock() + defer m.reviewMu.Unlock() + + def, err := m.store.ReviewDefinition(ctx, r.Id) + if err != nil { + slog.Error("load review definition", "review", r.Id, "err", err) + return false + } + if len(def.Steps) == 0 { + return false + } + task, project, err := m.activeTask(ctx, r.TaskId) + if err != nil { + return false + } + all, err := m.store.ListReviews(ctx, task.Id) + if err != nil { + slog.Error("list reviews to advance", "task", task.Id, "err", err) + return false + } + byStage := map[string]api.Review{} + for _, sr := range all { + if sr.SnapshotN > r.SnapshotN { + return false + } + if sr.SnapshotN == r.SnapshotN { + byStage[sr.Stage] = sr + } + } + snap, err := m.store.GetSnapshot(ctx, task.Id, r.SnapshotN) + if err != nil { + slog.Error("load snapshot to advance review", "task", task.Id, "snapshot", r.SnapshotN, "err", err) + return false + } + + started := false + for _, next := range workflow.Next(def, r.Stage) { + if _, found := byStage[next]; found { + continue + } + inputs := []api.Review{} + for _, a := range workflow.After(def, next) { + if in, found := byStage[a]; found && in.State == api.ReviewStateDone { + inputs = append(inputs, in) + } + } + if len(inputs) != len(workflow.After(def, next)) { + continue + } + err = m.startReviewer(ctx, task, project, snap, def, next, inputs) + if err != nil { + slog.Error("start review stage", "task", task.Id, "snapshot", r.SnapshotN, "stage", next, "err", err) + continue + } + started = true + } + return started +} + +func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, def api.WorkflowDefinition, stage string, inputs []api.Review) (err error) { + model := workflow.Get(def, stage).Model + vendor := workflow.VendorOf(model) reviewID := store.NewID() + recorded := false + defer func() { + if err == nil || recorded { + return + } + now := time.Now().UTC() + rerr := m.store.CreateReview(ctx, api.Review{ + Id: reviewID, + TaskId: task.Id, + SnapshotN: snap.N, + Stage: stage, + Vendor: string(vendor), + Model: model, + State: api.ReviewStateFailed, + Error: "the reviewer didn't start: " + err.Error(), + CreatedAt: now, + FinishedAt: &now, + }, &def) + if rerr != nil { + slog.Error("record review that didn't start", "task", task.Id, "stage", stage, "err", rerr) + return + } + go m.reviewFinished(reviewID) + }() + + prompt, err := m.reviewPrompt(ctx, task, project, snap, def, stage, inputs) + if err != nil { + return err + } + dir := m.reviewDir(project.Id, reviewID) err = git.AddDetachedWorktree(project.RepoPath, dir, snap.CommitSha) if err != nil { @@ -206,16 +350,18 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, snap api.Sna Id: reviewID, TaskId: task.Id, SnapshotN: snap.N, + Stage: stage, AgentSessionId: sess.Id, Vendor: string(vendor), Model: model, State: api.ReviewStateRunning, CreatedAt: time.Now().UTC(), - }) + }, &def) if err != nil { m.endReviewer(ctx, sess.Id, project, reviewID) return err } + recorded = true _, err = m.turn(ctx, sess, task, "byre", prompt, "", nil) if err != nil { @@ -223,26 +369,22 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, snap api.Sna m.failReview(ctx, reviewID, "the reviewer didn't start: "+err.Error()) return err } - slog.Info("review started", "review", reviewID, "task", task.Id, "snapshot", snap.N, "vendor", vendor, "model", model) - m.failRunningReviews(ctx, task.Id, project, snap.N, fmt.Sprintf("superseded by snapshot %d", snap.N)) + slog.Info("review started", "review", reviewID, "task", task.Id, "snapshot", snap.N, "stage", stage, "vendor", vendor, "model", model) return nil } -func (m *Manager) reviewPrompt(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot) (string, error) { +func (m *Manager) reviewPrompt(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, def api.WorkflowDefinition, stage string, inputs []api.Review) (string, error) { changes, err := git.Compare(project.RepoPath, project.DefaultBranch, snap.CommitSha, snap.TreeSha, "") if err != nil { return "", err } - if len(changes.Files) == 0 { - return "", nil - } brief, err := m.store.FirstTaskMessage(ctx, task.Id) if err != nil { return "", err } b := &strings.Builder{} - fmt.Fprintf(b, "Review the work of the agent on the byre task %q: look for bugs, security problems, missing or wrong tests, and anything that doesn't do what the task asked.\n\n", task.Title) + fmt.Fprintf(b, "Review the work of the agent on the byre task %q, as reviewer %q of the project's review workflow %q.\n\n%s\n\n", task.Title, stage, project.ReviewWorkflow, strings.TrimSpace(workflow.Get(def, stage).Prompt)) fmt.Fprintf(b, "It is snapshot %d of the task, commit %s, checked out in your working directory. The change is all of the task's work, `git diff %s %s` (from the merge-base with %s); %d files changed:\n", snap.N, snap.CommitSha, changes.Base, snap.CommitSha, project.DefaultBranch, len(changes.Files)) for i, f := range changes.Files { @@ -255,6 +397,9 @@ func (m *Manager) reviewPrompt(ctx context.Context, task api.Task, project api.P if brief != "" { fmt.Fprintf(b, "\nThe task's brief, the first message its agent got:\n\n%s\n", quote(clip(brief, 4000))) } + for _, in := range inputs { + fmt.Fprintf(b, "\n## From reviewer %q\n\n%s\n", in.Stage, reviewText(in)) + } b.WriteString("\nWhen you're done, call submit_review once with your review, then end your turn.") return b.String(), nil } @@ -370,9 +515,9 @@ func findingsMessage(r api.Review) string { res = *r.Result } b := &strings.Builder{} - fmt.Fprintf(b, findingsPreamble+"%d of your work and raised the findings below. Reviewers can be wrong or miss context: check each one against the code and your brief. "+ - "Before you change anything, answer each finding with respond_to_finding (snapshot %d and the finding's number): fixing if it holds up and you'll act on it, ignoring with your reason if you're leaving it. "+ - "The human sees your answers on the findings and can overrule an ignore.", r.SnapshotN, r.SnapshotN) + fmt.Fprintf(b, findingsPreamble+"%d of your work as reviewer %q and raised the findings below. Reviewers can be wrong or miss context: check each one against the code and your brief. "+ + "Before you change anything, answer each finding with respond_to_finding (snapshot %d, reviewer %q and the finding's number): fixing if it holds up and you'll act on it, ignoring with your reason if you're leaving it. "+ + "The human sees your answers on the findings and can overrule an ignore.", r.SnapshotN, r.Stage, r.SnapshotN, r.Stage) findings, _ := res["findings"].([]any) for i, f := range findings { fm, _ := f.(map[string]any) @@ -391,6 +536,7 @@ func findingsMessage(r api.Review) string { type findingResponseArgs struct { Snapshot int `json:"snapshot"` + Reviewer string `json:"reviewer"` Finding int `json:"finding"` Response api.FindingResponseResponse `json:"response"` Reason string `json:"reason"` @@ -405,11 +551,12 @@ func (m *Manager) respondToFindingTool(l *live) mcp.Tool { "type": "object", "properties": { "snapshot": {"type": "integer", "description": "The reviewed snapshot's number."}, - "finding": {"type": "integer", "description": "The finding's number in the review, from 1."}, + "reviewer": {"type": "string", "description": "The reviewer's name, as the findings message gives it."}, + "finding": {"type": "integer", "description": "The finding's number in that reviewer's review, from 1."}, "response": {"type": "string", "enum": ["fixing", "ignoring"]}, "reason": {"type": "string", "description": "One line: what you'll do about it, or why you're leaving it."} }, - "required": ["snapshot", "finding", "response", "reason"] + "required": ["snapshot", "reviewer", "finding", "response", "reason"] }`), Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { args := findingResponseArgs{} @@ -433,21 +580,21 @@ func (m *Manager) respondToFindingTool(l *live) mcp.Tool { } reviewID := "" for _, r := range reviews { - if r.SnapshotN == args.Snapshot && r.State == api.ReviewStateDone { + if r.SnapshotN == args.Snapshot && r.Stage == args.Reviewer && r.State == api.ReviewStateDone { reviewID = r.Id } } if reviewID == "" { - return "", fmt.Errorf("%w: snapshot %d has no finished review", ErrInvalid, args.Snapshot) + return "", fmt.Errorf("%w: snapshot %d has no finished review by reviewer %q", ErrInvalid, args.Snapshot, args.Reviewer) } err = m.store.SetFindingResponse(ctx, reviewID, args.Finding-1, api.FindingResponse{Response: args.Response, Reason: reason}, agentActor) if errors.Is(err, store.ErrNotFound) { - return "", fmt.Errorf("%w: the review of snapshot %d has no finding %d", ErrInvalid, args.Snapshot, args.Finding) + return "", fmt.Errorf("%w: reviewer %q's review of snapshot %d has no finding %d", ErrInvalid, args.Reviewer, args.Snapshot, args.Finding) } if err != nil { return "", err } - return fmt.Sprintf("Recorded: %s finding %d of snapshot %d's review.", args.Response, args.Finding, args.Snapshot), nil + return fmt.Sprintf("Recorded: %s finding %d of reviewer %q's review of snapshot %d.", args.Response, args.Finding, args.Reviewer, args.Snapshot), nil }, } } @@ -672,25 +819,55 @@ func (m *Manager) recoverReviews(ctx context.Context) error { m.endReviewer(ctx, r.AgentSessionId, project, r.Id) m.failReview(ctx, r.Id, "byre restarted before the reviewer finished") } + done, err := m.store.LatestDoneStages(ctx) + if err != nil { + return err + } + for _, id := range done { + go m.reviewFinished(id) + } return nil } +func reviewName(r api.Review) string { + if r.Stage == "" { + return fmt.Sprintf("review of snapshot %d", r.SnapshotN) + } + return fmt.Sprintf("%q review of snapshot %d", r.Stage, r.SnapshotN) +} + func reviewLine(r api.Review) string { if r.State == api.ReviewStateFailed { - return fmt.Sprintf("review of snapshot %d failed: %s", r.SnapshotN, clip(r.Error, 300)) + return fmt.Sprintf("%s failed: %s", reviewName(r), clip(r.Error, 300)) } if r.State == api.ReviewStateSkipped { - return fmt.Sprintf("review of snapshot %d skipped: no changes", r.SnapshotN) + return fmt.Sprintf("%s skipped: no changes", reviewName(r)) } res := map[string]any{} if r.Result != nil { res = *r.Result } b := &strings.Builder{} - fmt.Fprintf(b, "review of snapshot %d: %s", r.SnapshotN, clip(strings.TrimSpace(fmt.Sprint(res["summary"])), 600)) + fmt.Fprintf(b, "%s: %s", reviewName(r), clip(strings.TrimSpace(fmt.Sprint(res["summary"])), 600)) if c, ok := res["change"].(string); ok && c != "" { fmt.Fprintf(b, "\n change: %s", clip(strings.TrimSpace(c), 600)) } + writeFindings(b, res, "\n ", 300) + return b.String() +} + +func reviewText(r api.Review) string { + res := map[string]any{} + if r.Result != nil { + res = *r.Result + } + b := &strings.Builder{} + b.WriteString(strings.TrimSpace(fmt.Sprint(res["summary"]))) + writeFindings(b, res, "\n", 4000) + return b.String() +} + +func writeFindings(b *strings.Builder, res map[string]any, lead string, limit int) { findings, _ := res["findings"].([]any) for _, f := range findings { fm, _ := f.(map[string]any) @@ -702,7 +879,6 @@ func reviewLine(r api.Review) string { } where += ")" } - fmt.Fprintf(b, "\n - [%v] %v%s: %s", fm["severity"], fm["title"], where, clip(strings.TrimSpace(fmt.Sprint(fm["detail"])), 300)) + fmt.Fprintf(b, "%s- [%v] %v%s: %s", lead, fm["severity"], fm["title"], where, clip(strings.TrimSpace(fmt.Sprint(fm["detail"])), limit)) } - return b.String() } diff --git a/internal/agents/workflows.go b/internal/agents/workflows.go index e1700ac..5943399 100644 --- a/internal/agents/workflows.go +++ b/internal/agents/workflows.go @@ -28,7 +28,25 @@ func (m *Manager) SaveWorkflow(ctx context.Context, name string, file []byte, ac if err != nil { return api.WorkflowSummary{}, err } - return api.WorkflowSummary{Name: name, Description: def.Description}, nil + kind := api.WorkflowSummaryKindTask + if def.Kind == api.WorkflowKindReview { + kind = api.WorkflowSummaryKindReview + } + return api.WorkflowSummary{Name: name, Description: def.Description, Kind: kind}, nil +} + +func (m *Manager) SetReviewWorkflow(ctx context.Context, projectID, name, actor string) (api.Project, error) { + name = strings.TrimSpace(name) + if name != "" { + def, err := m.store.GetWorkflow(ctx, name) + if err != nil { + return api.Project{}, err + } + if def.Kind != api.WorkflowKindReview { + return api.Project{}, fmt.Errorf("%w: workflow %q is a task workflow; a project reviews with a workflow of kind review", ErrInvalid, name) + } + } + return m.store.SetReviewWorkflow(ctx, projectID, name, actor) } func (m *Manager) WorkflowFile(ctx context.Context, name string) ([]byte, error) { @@ -150,13 +168,13 @@ func (m *Manager) WorkflowRun(ctx context.Context, taskID string) (api.WorkflowR attempts[*t.WorkflowStage] = append(attempts[*t.WorkflowStage], a) } - for _, s := range frozen.Definition.Stages { + for _, s := range workflow.Stages(frozen.Definition) { stage := api.WorkflowRunStage{ - Name: s.Name, - After: workflow.After(frozen.Definition, s.Name), + Name: s.Stage, + After: workflow.After(frozen.Definition, s.Stage), State: api.StageStateWaiting, WaitingOn: []string{}, - Attempts: attempts[s.Name], + Attempts: attempts[s.Stage], } if stage.Attempts == nil { stage.Attempts = []api.StageAttempt{} @@ -212,6 +230,9 @@ func (m *Manager) StartWorkflow(ctx context.Context, project api.Project, creato if err != nil { return nil, err } + if frozen.Definition.Kind == api.WorkflowKindReview { + return nil, fmt.Errorf("%w: workflow %q is a review workflow, which a project names to review its snapshots; it doesn't run on a brief", ErrInvalid, name) + } frozen.Brief = brief frozen.Title = title @@ -726,6 +747,31 @@ func (m *Manager) workflowTools(tool func(name, description, schema string, run return fmt.Sprintf("Saved workflow %q.", w.Name), nil }), + tool("set_review_workflow", + "Set the review workflow (a saved workflow of kind review) whose stages review every snapshot of a project's tasks, from the next snapshot on; an empty workflow turns reviews off for the project. Only when the human asks.", + `{"type": "object", "properties": { + "project_id": {"type": "string"}, + "workflow": {"type": "string"} + }, "required": ["project_id", "workflow"]}`, + func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { + args := struct { + ProjectID string `json:"project_id"` + Workflow string `json:"workflow"` + }{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", err + } + p, err := m.SetReviewWorkflow(ctx, args.ProjectID, args.Workflow, leadActor(user.Login)) + if err != nil { + return "", err + } + if p.ReviewWorkflow == "" { + return fmt.Sprintf("Project %s has no review workflow now: its snapshots aren't reviewed.", p.Name), nil + } + return fmt.Sprintf("Project %s reviews with workflow %q from its next snapshot on.", p.Name, p.ReviewWorkflow), nil + }), + tool("delete_workflow", "Delete a workflow from the server. Tasks that froze it keep their copy. Only when the human asks.", `{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}`, diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index c0ff331..b13e5d6 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -378,24 +378,6 @@ func (e SnapshotTrigger) Valid() bool { } } -// Defines values for StageBase. -const ( - StageBaseMain StageBase = "main" - StageBaseParent StageBase = "parent" -) - -// Valid indicates whether the value is a known member of the StageBase enum. -func (e StageBase) Valid() bool { - switch e { - case StageBaseMain: - return true - case StageBaseParent: - return true - default: - return false - } -} - // Defines values for StageAttemptState. const ( AttemptStateCleanedUp StageAttemptState = "cleaned_up" @@ -534,6 +516,24 @@ func (e TaskTab) Valid() bool { } } +// Defines values for WorkflowDefinitionKind. +const ( + WorkflowKindReview WorkflowDefinitionKind = "review" + WorkflowKindTask WorkflowDefinitionKind = "task" +) + +// Valid indicates whether the value is a known member of the WorkflowDefinitionKind enum. +func (e WorkflowDefinitionKind) Valid() bool { + switch e { + case WorkflowKindReview: + return true + case WorkflowKindTask: + return true + default: + return false + } +} + // Defines values for WorkflowRunStageState. const ( StageStateCleanedUp WorkflowRunStageState = "cleaned_up" @@ -567,6 +567,24 @@ func (e WorkflowRunStageState) Valid() bool { } } +// Defines values for WorkflowSummaryKind. +const ( + WorkflowSummaryKindReview WorkflowSummaryKind = "review" + WorkflowSummaryKindTask WorkflowSummaryKind = "task" +) + +// Valid indicates whether the value is a known member of the WorkflowSummaryKind enum. +func (e WorkflowSummaryKind) Valid() bool { + switch e { + case WorkflowSummaryKindReview: + return true + case WorkflowSummaryKindTask: + return true + default: + return false + } +} + // Defines values for ZombieKind. const ( ProcessWithoutSession ZombieKind = "process_without_session" @@ -1104,6 +1122,9 @@ type Project struct { Name string `json:"name"` Remote string `json:"remote"` RepoPath string `json:"repo_path"` + + // ReviewWorkflow The review workflow whose stages review every snapshot of the project's tasks; empty for no reviews. + ReviewWorkflow string `json:"review_workflow"` } // ProjectCreate defines model for ProjectCreate. @@ -1135,7 +1156,7 @@ type ProtocolErrors struct { Recent []ProtocolError `json:"recent"` } -// Review An agent session on the vendor the task's agent doesn't use, reviewing one snapshot's change. +// Review One stage of the project's review workflow, an agent session reviewing one snapshot's change. type Review struct { // AgentSessionId The reviewer's session; empty when skipped. AgentSessionId string `json:"agent_session_id"` @@ -1159,6 +1180,9 @@ type Review struct { Result *map[string]interface{} `json:"result,omitempty"` SnapshotN int `json:"snapshot_n"` + // Stage The review workflow's stage this reviewer ran; empty when skipped. + Stage string `json:"stage"` + // State skipped: the snapshot's step changed no files, so no reviewer ran. State ReviewState `json:"state"` TaskId string `json:"task_id"` @@ -1170,10 +1194,10 @@ type Review struct { // ReviewState skipped: the snapshot's step changed no files, so no reviewer ran. type ReviewState string -// ReviewModels defines model for ReviewModels. -type ReviewModels struct { - ReviewModel string `json:"review_model"` - SecondReviewModel string `json:"second_review_model"` +// ReviewWorkflow defines model for ReviewWorkflow. +type ReviewWorkflow struct { + // Workflow A saved workflow of kind review, or empty for no reviews. + Workflow string `json:"workflow"` } // SessionCreate defines model for SessionCreate. @@ -1211,30 +1235,6 @@ type Snapshot struct { // SnapshotTrigger turn_end: after byre's end-of-turn commit. requested: a task's agent or the lead asked for it with take_snapshot. answer: just before answers from the human were handed to the agent. fork: when the task's agent forked the task with its fork tool. ask_human: after byre committed the worktree inside an ask_human call whose task is behind the default branch, just before suggesting the agent merge it in. answer, fork and ask_human snapshots are never reviewed. message (the agent wrote text after one or more tool calls), tool_call and timer appear only on snapshots taken by earlier builds. type SnapshotTrigger string -// Stage defines model for Stage. -type Stage struct { - // After The stages whose outputs this stage gets, by name; it starts once all of them have finished. Absent means the previous stage in the list (for the first stage, none); empty means it starts from the brief. - After *[]string `json:"after,omitempty"` - - // Agents Runs the stage as a team: several agents in the stage's one task and worktree, each on its own model, sharing a board. The first agent finishes the stage. - Agents *[]TeamMember `json:"agents,omitempty"` - - // Base Where the stage's task branches from. parent (the default): the commit its one after stage finished at, or for a stage with none, where the workflow was started from. main: the project's default branch. Refused on a stage with more than one after stage, which always branches from the default branch. - Base *StageBase `json:"base,omitempty"` - - // Model The stage agent's model, which also picks its vendor: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code. Required without agents, refused with them. - Model string `json:"model,omitempty"` - - // Name One line of 1 to 100 characters, unique within the workflow. - Name string `json:"name"` - - // Prompt The stage agent's instructions; with agents, the instructions every agent of the team gets before its own. - Prompt string `json:"prompt"` -} - -// StageBase Where the stage's task branches from. parent (the default): the commit its one after stage finished at, or for a stage with none, where the workflow was started from. main: the project's default branch. Refused on a stage with more than one after stage, which always branches from the default branch. -type StageBase string - // StageAttempt defines model for StageAttempt. type StageAttempt struct { // Commit With output: the commit the stage finished at. @@ -1425,7 +1425,7 @@ type TaskCreate struct { // Title The task's title. Without it the task starts untitled, and its agent names it with set_task_title. Required with workflow. Title *string `json:"title,omitempty"` - // Workflow The name of a saved workflow to run instead of creating one task with no agent: each stage with no after stages becomes its own task, titled "<title>: <stage>", with the title as the run's brief. The response is the first of those tasks. + // Workflow The name of a saved workflow to run instead of creating one task with no agent: each stage that starts it becomes its own task, titled "<title>: <stage>", with the title as the run's brief. The response is the first of those tasks. Workflow *string `json:"workflow,omitempty"` } @@ -1561,12 +1561,6 @@ type User struct { // NotificationPrefs Which task events notify this user in the macOS app. NotificationPrefs NotificationPrefs `json:"notification_prefs"` - - // ReviewModel The model that reviews this user's tasks - ReviewModel string `json:"review_model"` - - // SecondReviewModel The model that reviews this user's tasks when review_model wrote the work under review. - SecondReviewModel string `json:"second_review_model"` } // UserUpdate defines model for UserUpdate. @@ -1575,17 +1569,23 @@ type UserUpdate struct { GitName string `json:"git_name"` } -// WorkflowDefinition A workflow's file, which holds everything but its name: its stages, each run as its own task once the stages it comes after have finished. Sent and returned as YAML. +// WorkflowDefinition A workflow's file, which holds everything but its name: its steps, run in order. Each stage runs as its own task once the steps before it have finished. Sent and returned as YAML. type WorkflowDefinition struct { - Description string `json:"description,omitempty"` - Stages []Stage `json:"stages"` + Description string `json:"description,omitempty"` + + // Kind task (the default): each stage runs as its own task. review: each stage is a reviewer of a snapshot, run when a project names the workflow as its review workflow; a stage gets the earlier stages' reviews. + Kind WorkflowDefinitionKind `json:"kind,omitempty"` + Steps []WorkflowStep `json:"steps"` } +// WorkflowDefinitionKind task (the default): each stage runs as its own task. review: each stage is a reviewer of a snapshot, run when a project names the workflow as its review workflow; a stage gets the earlier stages' reviews. +type WorkflowDefinitionKind string + // WorkflowRun defines model for WorkflowRun. type WorkflowRun struct { Brief string `json:"brief"` - // Definition A workflow's file, which holds everything but its name: its stages, each run as its own task once the stages it comes after have finished. Sent and returned as YAML. + // Definition A workflow's file, which holds everything but its name: its steps, run in order. Each stage runs as its own task once the steps before it have finished. Sent and returned as YAML. Definition WorkflowDefinition `json:"definition"` // Finished Whether the final stage finished, which ends the run. @@ -1600,7 +1600,7 @@ type WorkflowRun struct { // WorkflowRunStage defines model for WorkflowRunStage. type WorkflowRunStage struct { - // After The stages it comes after, with the definition's defaults applied. + // After The stages whose outputs it gets, from the steps' shape. After []string `json:"after"` // Attempts The tasks that ran the stage (the first, and forks of it), oldest first. @@ -1617,12 +1617,37 @@ type WorkflowRunStage struct { // WorkflowRunStageState waiting: no task yet. Otherwise the state of its latest finished attempt, or of its latest attempt when none has finished. type WorkflowRunStageState string +// WorkflowStep Exactly one of stage (with its model or agents, and prompt), order or parallel. A stage gets the outputs of the stages that end the step before it: the last stage of an order, every branch's last stage of a parallel. +type WorkflowStep struct { + // Agents Runs the stage as a team: several agents in the stage's one task and worktree, each on its own model, sharing a board. The first agent finishes the stage. + Agents *[]TeamMember `json:"agents,omitempty"` + + // Model The stage agent's model, which also picks its vendor: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code. Required without agents, refused with them. + Model string `json:"model,omitempty"` + + // Order Steps run one after another, each getting the outputs of the one before. + Order *[]WorkflowStep `json:"order,omitempty"` + + // Parallel Branches run at the same time, each getting the outputs of the step before the parallel. + Parallel *[]WorkflowStep `json:"parallel,omitempty"` + + // Prompt The stage agent's instructions; with agents, the instructions every agent of the team gets before its own. + Prompt string `json:"prompt,omitempty"` + + // Stage The stage's name, one line of 1 to 100 characters, unique within the workflow. + Stage string `json:"stage,omitempty"` +} + // WorkflowSummary defines model for WorkflowSummary. type WorkflowSummary struct { - Description string `json:"description"` - Name string `json:"name"` + Description string `json:"description"` + Kind WorkflowSummaryKind `json:"kind"` + Name string `json:"name"` } +// WorkflowSummaryKind defines model for WorkflowSummary.Kind. +type WorkflowSummaryKind string + // Zombie defines model for Zombie. type Zombie struct { Detail string `json:"detail"` @@ -1756,12 +1781,12 @@ type UpdateNotificationPrefsJSONRequestBody = NotificationPrefs // SetPaneFocusJSONRequestBody defines body for SetPaneFocus for application/json ContentType. type SetPaneFocusJSONRequestBody = PaneFocus -// UpdateReviewModelsJSONRequestBody defines body for UpdateReviewModels for application/json ContentType. -type UpdateReviewModelsJSONRequestBody = ReviewModels - // CreateProjectJSONRequestBody defines body for CreateProject for application/json ContentType. type CreateProjectJSONRequestBody = ProjectCreate +// SetReviewWorkflowJSONRequestBody defines body for SetReviewWorkflow for application/json ContentType. +type SetReviewWorkflowJSONRequestBody = ReviewWorkflow + // CreateTaskJSONRequestBody defines body for CreateTask for application/json ContentType. type CreateTaskJSONRequestBody = TaskCreate @@ -1789,6 +1814,9 @@ type CreateSessionJSONRequestBody = SessionCreate // MarkSummaryReadJSONRequestBody defines body for MarkSummaryRead for application/json ContentType. type MarkSummaryReadJSONRequestBody = SummaryRead +// PutWorkflowJSONRequestBody defines body for PutWorkflow for application/json ContentType. +type PutWorkflowJSONRequestBody = WorkflowDefinition + // ServerInterface represents all server handlers. type ServerInterface interface { @@ -1879,9 +1907,6 @@ type ServerInterface interface { // (PUT /api/me/pane-focus) SetPaneFocus(w http.ResponseWriter, r *http.Request) - // (PUT /api/me/review-models) - UpdateReviewModels(w http.ResponseWriter, r *http.Request) - // (GET /api/me/summary-reads) GetSummaryReads(w http.ResponseWriter, r *http.Request) @@ -1894,6 +1919,9 @@ type ServerInterface interface { // (GET /api/projects/{projectId}) GetProject(w http.ResponseWriter, r *http.Request, projectId string) + // (PUT /api/projects/{projectId}/review-workflow) + SetReviewWorkflow(w http.ResponseWriter, r *http.Request, projectId string) + // (GET /api/projects/{projectId}/tasks) ListTasks(w http.ResponseWriter, r *http.Request, projectId string) @@ -2645,20 +2673,6 @@ func (siw *ServerInterfaceWrapper) SetPaneFocus(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// UpdateReviewModels operation middleware -func (siw *ServerInterfaceWrapper) UpdateReviewModels(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateReviewModels(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - // GetSummaryReads operation middleware func (siw *ServerInterfaceWrapper) GetSummaryReads(w http.ResponseWriter, r *http.Request) { @@ -2727,6 +2741,32 @@ func (siw *ServerInterfaceWrapper) GetProject(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } +// SetReviewWorkflow operation middleware +func (siw *ServerInterfaceWrapper) SetReviewWorkflow(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "projectId" ------------- + var projectId string + + err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SetReviewWorkflow(w, r, projectId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListTasks operation middleware func (siw *ServerInterfaceWrapper) ListTasks(w http.ResponseWriter, r *http.Request) { @@ -4098,7 +4138,6 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/me/notifications", wrapper.UpdateNotificationPrefs) m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/me/default-model", wrapper.UpdateDefaultModel) m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/me/away", wrapper.UpdateAway) - m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/me/review-models", wrapper.UpdateReviewModels) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/me/lead/sessions", wrapper.ListLeadSessions) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/me/lead/events", wrapper.ListLeadEvents) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/me/lead/messages", wrapper.MessageLead) @@ -4111,6 +4150,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects", wrapper.ListProjects) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects", wrapper.CreateProject) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}", wrapper.GetProject) + m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/projects/{projectId}/review-workflow", wrapper.SetReviewWorkflow) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/tasks", wrapper.ListTasks) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects/{projectId}/tasks", wrapper.CreateTask) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/todo", wrapper.GetTodo) @@ -4176,303 +4216,306 @@ 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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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/notifications.go b/internal/server/notifications.go index 4e05cf1..bac01b6 100644 --- a/internal/server/notifications.go +++ b/internal/server/notifications.go @@ -54,22 +54,3 @@ func (s *Server) UpdateAway(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, u) } - -func (s *Server) UpdateReviewModels(w http.ResponseWriter, r *http.Request) { - req := api.ReviewModels{} - if !readJSON(w, r, &req) { - return - } - - if strings.TrimSpace(req.ReviewModel) == "" || strings.TrimSpace(req.SecondReviewModel) == "" { - writeError(w, http.StatusBadRequest, errors.New("review_model and second_review_model are required")) - return - } - - u, err := s.store.UpdateReviewModels(r.Context(), actor(r), req.ReviewModel, req.SecondReviewModel) - if err != nil { - writeStoreError(w, err) - return - } - writeJSON(w, http.StatusOK, u) -} diff --git a/internal/server/workflows.go b/internal/server/workflows.go index 7a46a5f..a589221 100644 --- a/internal/server/workflows.go +++ b/internal/server/workflows.go @@ -5,7 +5,9 @@ import ( "io" "net/http" "net/url" + "strings" + "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/workflow" ) @@ -21,6 +23,16 @@ func (s *Server) ListWorkflows(w http.ResponseWriter, r *http.Request) { } func (s *Server) GetWorkflow(w http.ResponseWriter, r *http.Request, name string) { + accept := r.Header.Get("Accept") + if strings.Contains(accept, "application/json") && !strings.Contains(accept, "yaml") { + def, err := s.store.GetWorkflow(r.Context(), name) + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, def) + return + } file, err := s.agents.WorkflowFile(r.Context(), name) if err != nil { writeAgentError(w, err) @@ -46,6 +58,19 @@ func (s *Server) PutWorkflow(w http.ResponseWriter, r *http.Request, name string writeJSON(w, http.StatusOK, summary) } +func (s *Server) SetReviewWorkflow(w http.ResponseWriter, r *http.Request, projectID string) { + req := api.ReviewWorkflow{} + if !readJSON(w, r, &req) { + return + } + p, err := s.agents.SetReviewWorkflow(r.Context(), projectID, req.Workflow, actor(r)) + if err != nil { + writeAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, p) +} + func (s *Server) DeleteWorkflow(w http.ResponseWriter, r *http.Request, name string) { err := s.store.DeleteWorkflow(r.Context(), name, actor(r)) if err != nil { diff --git a/internal/store/migrateworkflows.go b/internal/store/migrateworkflows.go new file mode 100644 index 0000000..0c35cd9 --- /dev/null +++ b/internal/store/migrateworkflows.go @@ -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 +} diff --git a/internal/store/migrations/072-add-review-workflows.sql b/internal/store/migrations/072-add-review-workflows.sql new file mode 100644 index 0000000..a6d3f42 --- /dev/null +++ b/internal/store/migrations/072-add-review-workflows.sql @@ -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; diff --git a/internal/store/notifications.go b/internal/store/notifications.go index 8ba553b..78fd69a 100644 --- a/internal/store/notifications.go +++ b/internal/store/notifications.go @@ -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) -} diff --git a/internal/store/review.go b/internal/store/review.go index 1ccdb76..5d6b686 100644 --- a/internal/store/review.go +++ b/internal/store/review.go @@ -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 } diff --git a/internal/store/store.go b/internal/store/store.go index e66518b..7787752 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 } diff --git a/internal/store/workflow.go b/internal/store/workflow.go index 0dbcf0d..6ea949d 100644 --- a/internal/store/workflow.go +++ b/internal/store/workflow.go @@ -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 } diff --git a/internal/workflow/file.go b/internal/workflow/file.go index 6bd2515..df813ea 100644 --- a/internal/workflow/file.go +++ b/internal/workflow/file.go @@ -12,7 +12,7 @@ import ( "g.fc.run/ai/byre/internal/api" ) -var keyOrder = []string{"description", "stages", "name", "after", "base", "model", "prompt"} +var keyOrder = []string{"description", "kind", "steps", "stage", "name", "model", "prompt", "agents", "order", "parallel"} func Parse(data []byte) (api.WorkflowDefinition, error) { raw := any(nil) diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index c86ce76..cd01f94 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -3,6 +3,7 @@ package workflow import ( "errors" "fmt" + "slices" "strings" "unicode" "unicode/utf8" @@ -49,70 +50,91 @@ func nameProblem(name string) string { func Validate(def api.WorkflowDefinition) error { problems := []string{} - if len(def.Stages) == 0 { - problems = append(problems, "it has no stages") + if len(def.Steps) == 0 { + problems = append(problems, "it has no steps") + } + if def.Kind != "" && !def.Kind.Valid() { + problems = append(problems, fmt.Sprintf("kind must be task or review, not %q", def.Kind)) } - seen := map[string]bool{} - for i, s := range def.Stages { - where := fmt.Sprintf("stage %d (%q)", i+1, s.Name) - p := nameProblem(s.Name) - if p != "" { - problems = append(problems, fmt.Sprintf("%s: name %s", where, p)) - } - if seen[s.Name] { - problems = append(problems, fmt.Sprintf("%s: another stage has the same name", where)) - } - seen[s.Name] = true - - switch { - case s.Agents != nil && strings.TrimSpace(s.Model) != "": - problems = append(problems, fmt.Sprintf("%s: model can't be given with agents, which name their own", where)) - case s.Agents != nil: - for _, p := range TeamProblems(*s.Agents) { - problems = append(problems, fmt.Sprintf("%s: %s", where, p)) + for i, s := range def.Steps { + problems = append(problems, stepProblems(s, fmt.Sprintf("steps[%d]", i), seen)...) + } + if def.Kind == api.WorkflowKindReview { + for _, s := range Stages(def) { + if s.Agents != nil { + problems = append(problems, fmt.Sprintf("stage %q: a review workflow's stages are single reviewers, so agents can't be given", s.Stage)) } - case strings.TrimSpace(s.Model) == "": - problems = append(problems, fmt.Sprintf("%s: model is required", where)) - } - - if strings.TrimSpace(s.Prompt) == "" { - problems = append(problems, fmt.Sprintf("%s: prompt is empty", where)) - } - - if s.Base != nil && !s.Base.Valid() { - problems = append(problems, fmt.Sprintf("%s: base must be parent or main, not %q", where, *s.Base)) - } - if s.Base != nil && len(After(def, s.Name)) > 1 { - problems = append(problems, fmt.Sprintf("%s: base can't be given on a stage with more than one after stage, which always branches from the default branch", where)) - } - if s.After == nil { - continue - } - listed := map[string]bool{} - for _, a := range *s.After { - switch { - case a == s.Name: - problems = append(problems, fmt.Sprintf("%s: after lists the stage itself", where)) - case Index(def, a) < 0: - problems = append(problems, fmt.Sprintf("%s: after lists %q, which isn't a stage", where, a)) - case listed[a]: - problems = append(problems, fmt.Sprintf("%s: after lists %q twice", where, a)) - } - listed[a] = true } } - - if len(problems) == 0 { - problems = append(problems, graphProblems(def)...) + if len(problems) == 0 && def.Kind != api.WorkflowKindReview { + finals := ends(api.WorkflowStep{Order: &def.Steps}) + if len(finals) != 1 { + problems = append(problems, fmt.Sprintf("it must end with one stage, which finishes the workflow, but ends with %d: %s", len(finals), quoteList(finals))) + } } - if len(problems) > 0 { return fmt.Errorf("%w: %s", ErrInvalid, strings.Join(problems, "; ")) } return nil } +func stepProblems(s api.WorkflowStep, where string, seen map[string]bool) []string { + kinds := 0 + for _, given := range []bool{s.Stage != "", s.Order != nil, s.Parallel != nil} { + if given { + kinds++ + } + } + if kinds != 1 { + return []string{fmt.Sprintf("%s: a step needs exactly one of stage, order and parallel", where)} + } + + if s.Stage == "" { + if s.Model != "" || s.Prompt != "" || s.Agents != nil { + return []string{fmt.Sprintf("%s: model, prompt and agents belong on a stage, not on an order or parallel", where)} + } + list, kind := s.Order, "order" + if s.Parallel != nil { + list, kind = s.Parallel, "parallel" + } + if len(*list) == 0 { + return []string{fmt.Sprintf("%s: %s is empty", where, kind)} + } + problems := []string{} + for i, c := range *list { + problems = append(problems, stepProblems(c, fmt.Sprintf("%s.%s[%d]", where, kind, i), seen)...) + } + return problems + } + + problems := []string{} + where = fmt.Sprintf("%s (stage %q)", where, s.Stage) + p := nameProblem(s.Stage) + if p != "" { + problems = append(problems, fmt.Sprintf("%s: name %s", where, p)) + } + if seen[s.Stage] { + problems = append(problems, fmt.Sprintf("%s: another stage has the same name", where)) + } + seen[s.Stage] = true + + switch { + case s.Agents != nil && strings.TrimSpace(s.Model) != "": + problems = append(problems, fmt.Sprintf("%s: model can't be given with agents, which name their own", where)) + case s.Agents != nil: + for _, p := range TeamProblems(*s.Agents) { + problems = append(problems, fmt.Sprintf("%s: %s", where, p)) + } + case strings.TrimSpace(s.Model) == "": + problems = append(problems, fmt.Sprintf("%s: model is required", where)) + } + if strings.TrimSpace(s.Prompt) == "" { + problems = append(problems, fmt.Sprintf("%s: prompt is empty", where)) + } + return problems +} + func TeamProblems(members []api.TeamMember) []string { problems := []string{} if len(members) < 2 { @@ -139,88 +161,96 @@ func TeamProblems(members []api.TeamMember) []string { return problems } -func graphProblems(def api.WorkflowDefinition) []string { - problems := []string{} - finals := []string{} - for _, s := range def.Stages { - if len(Next(def, s.Name)) == 0 { - finals = append(finals, fmt.Sprintf("%q", s.Name)) +func ends(s api.WorkflowStep) []string { + switch { + case s.Order != nil: + if len(*s.Order) == 0 { + return []string{} } - } - if len(finals) != 1 { - problems = append(problems, fmt.Sprintf("it must have exactly one final stage (one no other stage comes after), but has %d: %s", len(finals), strings.Join(finals, ", "))) - } - - state := map[string]int{} - var visit func(name string, path []string) - visit = func(name string, path []string) { - switch state[name] { - case 1: - problems = append(problems, fmt.Sprintf("its stages form a cycle: %s", strings.Join(append(path, fmt.Sprintf("%q", name)), " after "))) - return - case 2: - return + return ends((*s.Order)[len(*s.Order)-1]) + case s.Parallel != nil: + out := []string{} + for _, b := range *s.Parallel { + out = append(out, ends(b)...) } - state[name] = 1 - for _, a := range After(def, name) { - visit(a, append(path, fmt.Sprintf("%q", name))) - } - state[name] = 2 + return out } - for _, s := range def.Stages { - visit(s.Name, nil) - } - return problems + return []string{s.Stage} } -func Index(def api.WorkflowDefinition, name string) int { - for i, s := range def.Stages { - if s.Name == name { - return i - } - } - return -1 +type graph struct { + stages []api.WorkflowStep + after map[string][]string } -func Get(def api.WorkflowDefinition, name string) api.Stage { - return def.Stages[Index(def, name)] +func (g *graph) walk(s api.WorkflowStep, before []string) []string { + switch { + case s.Order != nil: + for _, c := range *s.Order { + before = g.walk(c, before) + } + return before + case s.Parallel != nil: + out := []string{} + for _, b := range *s.Parallel { + out = append(out, g.walk(b, before)...) + } + return out + } + g.stages = append(g.stages, s) + g.after[s.Stage] = before + return []string{s.Stage} +} + +func build(def api.WorkflowDefinition) graph { + g := graph{after: map[string][]string{}} + g.walk(api.WorkflowStep{Order: &def.Steps}, []string{}) + return g +} + +func Stages(def api.WorkflowDefinition) []api.WorkflowStep { + return build(def).stages +} + +func Get(def api.WorkflowDefinition, name string) api.WorkflowStep { + stages := Stages(def) + return stages[slices.IndexFunc(stages, func(s api.WorkflowStep) bool { return s.Stage == name })] } func After(def api.WorkflowDefinition, name string) []string { - i := Index(def, name) - s := def.Stages[i] - if s.After != nil { - return *s.After - } - if i == 0 { - return []string{} - } - return []string{def.Stages[i-1].Name} + return build(def).after[name] } func Next(def api.WorkflowDefinition, name string) []string { + g := build(def) next := []string{} - for _, s := range def.Stages { - for _, a := range After(def, s.Name) { - if a == name { - next = append(next, s.Name) - } + for _, s := range g.stages { + if slices.Contains(g.after[s.Stage], name) { + next = append(next, s.Stage) } } return next } func Starts(def api.WorkflowDefinition) []string { + g := build(def) starts := []string{} - for _, s := range def.Stages { - if len(After(def, s.Name)) == 0 { - starts = append(starts, s.Name) + for _, s := range g.stages { + if len(g.after[s.Stage]) == 0 { + starts = append(starts, s.Stage) } } return starts } func FromMain(def api.WorkflowDefinition, name string) bool { - s := Get(def, name) - return len(After(def, name)) > 1 || (s.Base != nil && *s.Base == api.StageBaseMain) + return len(After(def, name)) > 1 +} + +func quoteList(names []string) string { + quoted := []string{} + for _, n := range names { + quoted = append(quoted, fmt.Sprintf("%q", n)) + } + return strings.Join(quoted, ", ") } diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 78be3f0..e628804 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -146,7 +146,7 @@ final class ChatModel { } func working(_ row: Row) -> Bool { - row.pending && (running || subagents && row.inActive) || row.holdsActive && (running || subagents) + row.pending && (running || subagents && row.inActive || row.inReview) || row.holdsActive && (running || subagents) } var hasOlder: Bool { @@ -286,12 +286,6 @@ final class ChatModel { } } - func showReview(_ n: Int, _ task: String?) { - if let row = reviewRow(n, task) { - scrollTo(row.id) - } - } - func showFinding(_ reviewId: String, _ index: Int) { let row = builder.root.rows.first { r in if case .finding(let i) = r.kind { @@ -308,7 +302,7 @@ final class ChatModel { guard let taskId else { return [] } - let latest = latestReview(store.transcript) + let latest = latestReviewedSnapshot(store.transcript) let task = store.findTask(taskId) let active = task.map { $0.status == .active && !$0.merging && !$0.mergingIntoParent && $0.mergeQueuedAt == nil && !$0.mergeAfterReview } ?? false var questions: [Int] = [] @@ -320,7 +314,7 @@ final class ChatModel { case .question where row.question?.state == .open && row.taskId == taskId: questions.append(row.id) case .finding(let i): - if let r = row.review, r.id == latest?.id, findingOpen(r, i) { + if let r = row.review, r.snapshotN == latest, findingOpen(r, i) { open.append(row.id) } case .ready(_, _, let d, _) where row.id == builder.lastReady && active: @@ -1241,6 +1235,20 @@ struct RowView: View { } } + case .reviewer(let agentModel): + DetailsBox(nested: nested, working: row.active, status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) { + Text(styled([(row.review?.stage ?? "review", 600, nil), (agentModel.isEmpty ? "" : " · \(agentModel)", 400, Palette.muted), (row.text.isEmpty ? "" : " \(row.text)", 400, Palette.muted)])) + .lineLimit(1) + } content: { + VStack(alignment: .leading, spacing: 6) { + ForEach(row.body!.rows) { child in + RowView(row: child, model: model, nested: true) + } + } + .padding(.top, 6) + .padding(.bottom, 4) + } + case .group: DetailsBox(nested: nested, working: model.working(row)) { SubagentTag(show: row.tag) @@ -1318,9 +1326,7 @@ struct RowView: View { } case .snapshot(let snap): - SnapshotRow(snap: snap, review: row.review, taskId: row.taskId, inherited: row.taskId != model.taskId, forks: row.forks) { - model.showReview(snap.n, row.taskId) - } + SnapshotRow(snap: snap, taskId: row.taskId, inherited: row.taskId != model.taskId, forks: row.forks) case .question: if let item = row.question { @@ -1727,14 +1733,11 @@ func taskLink(_ store: Store, _ id: String) -> AttributedString { private struct SnapshotRow: View { let snap: Snapshot - let review: Review? let taskId: String? let inherited: Bool let forks: [String] - let showReview: () -> Void @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays - @Environment(\.paneAccent) private var accent @ViewState private var forking = false var body: some View { @@ -1768,9 +1771,6 @@ private struct SnapshotRow: View { .buttonStyle(SmallLinkButtonStyle()) .disabled(forking) .help("Start a new task from this snapshot, its agent continuing the conversation as it stood here") - if ![.answer, .fork, .askHuman].contains(snap.trigger) { - reviewButton - } ForEach(forks, id: \.self) { id in Text(AttributedString("forked as ") + taskLink(store, id)) .fixedSize() @@ -1782,44 +1782,4 @@ private struct SnapshotRow: View { .help("\(snap.trigger.rawValue.replacingOccurrences(of: "_", with: " ")) · \(time)") } - private var reviewButton: some View { - let n = review.map(findingCount) ?? 0 - return Button { - showReview() - } label: { - Text("review") - .font(Fonts.font(11, weight: n > 0 ? 700 : 400)) - .overlay(alignment: .topTrailing) { - if let review { - switch review.state { - case .running, .failed: - EmptyView() - case .skipped: - Text("●").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.muted).help("skipped: no changes") - .fixedSize() - .offset(x: 13, y: -7) - case .done: - if n == 0 { - Text("✓").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.ok).help(review.findingStates.additionalProperties.isEmpty ? "no findings" : "every finding addressed") - .fixedSize() - .offset(x: 13, y: -7) - } else { - Text("\(n)") - .font(Fonts.font(10, weight: 700)) - .foregroundStyle(Palette.danger) - .frame(height: 14) - .padding(.horizontal, 5) - .background(Palette.dangerTint, in: Capsule()) - .help("\(n) open findings") - .fixedSize() - .offset(x: 15, y: -9) - } - } - } - } - } - .buttonStyle(SmallLinkButtonStyle(color: n > 0 ? Palette.warn : Palette.accent, border: review?.state == .running ? accent : Palette.border)) - .disabled(review == nil || review?.state == .running) - .help(review?.state == .running ? "Review in progress" : review == nil ? "Not reviewed" : "Scroll to the review of snapshot \(snap.n)") - } } diff --git a/mac/Sources/Byre/MergeDialog.swift b/mac/Sources/Byre/MergeDialog.swift index abae805..b6fc37d 100644 --- a/mac/Sources/Byre/MergeDialog.swift +++ b/mac/Sources/Byre/MergeDialog.swift @@ -177,27 +177,28 @@ private func reviewWarnings(_ taskId: String, _ store: Store, _ leave: @escaping store.jumpTo(taskId, .review(latest.n)) } } - let r = reviews.first { $0.snapshotN == latest.n } - if let r { + let rs = reviews.filter { $0.snapshotN == latest.n } + for r in rs { let found = unignored(r) switch r.state { case .running: - items.append(MergeWarning(text: "Review of the latest snapshot is still running", open: open)) + items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot is still running", open: open)) case .failed: - items.append(MergeWarning(text: "Review of the latest snapshot failed", open: open)) + items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot failed", open: open)) default: if !found.isEmpty { - items.append(MergeWarning(text: "Review of the latest snapshot found \(severityCounts(found))", open: open)) + items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot found \(severityCounts(found))", open: open)) } } - } else { + } + if rs.isEmpty { items.append(MergeWarning(text: "The latest snapshot hasn't been reviewed", open: open)) } let changes = try await client.getTaskChanges(path: .init(taskId: taskId), query: .init(since: latest.commitSha)).ok.body.json if !changes.files.isEmpty { items.append(MergeWarning(text: "Changes since the last snapshot haven't been reviewed")) } - return (items, r?.state == .running) + return (items, rs.contains { $0.state == .running }) } private func unignored(_ r: Review) -> [JSONDict] { diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift index 986edb0..a49b239 100644 --- a/mac/Sources/Byre/Project.swift +++ b/mac/Sources/Byre/Project.swift @@ -110,6 +110,53 @@ struct ProjectView: View { } } +private struct ReviewWorkflowMenu: View { + let project: Project + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var reviewWorkflows: [String] = [] + + var body: some View { + Menu { + Button("No reviews") { set("") } + ForEach(reviewWorkflows, id: \.self) { name in + Button(name) { set(name) } + } + } label: { + HStack(spacing: 6) { + Text(project.reviewWorkflow.isEmpty ? "no reviews" : "reviews: \(project.reviewWorkflow)") + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .semibold)) + } + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + .contentShape(Rectangle()) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .help("The review workflow whose reviewers review every snapshot of this project's tasks") + .task { + do { + reviewWorkflows = try await store.api.client.listWorkflows().ok.body.json.filter { $0.kind == .review }.map(\.name) + } catch { + reviewWorkflows = [] + } + } + } + + private func set(_ name: String) { + Task { + do { + _ = try await store.api.client.setReviewWorkflow(path: .init(projectId: project.id), body: .json(.init(workflow: name))).ok.body.json + } catch { + overlays.showAlert(errorText(error)) + } + } + } +} + private struct ProjectBlock: View { let project: Project let todo: Result<Todo, ApiError>? @@ -121,6 +168,8 @@ private struct ProjectBlock: View { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(project.name).font(Fonts.font(13, weight: 700)) Text(project.defaultBranch).font(Fonts.font(11)).foregroundStyle(Palette.muted) + Spacer() + ReviewWorkflowMenu(project: project) } .padding(.top, 16) .padding(.bottom, 6) diff --git a/mac/Sources/Byre/Reviews.swift b/mac/Sources/Byre/Reviews.swift index 47701e6..5a10c7c 100644 --- a/mac/Sources/Byre/Reviews.swift +++ b/mac/Sources/Byre/Reviews.swift @@ -274,7 +274,7 @@ struct FindingActions: View { private func fix() { let f = findings(review)[index] let whereText = findingWhere(f) - let text = "Fix finding \(index + 1) from the review of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))" + let text = "Fix finding \(index + 1) from reviewer \"\(review.stage)\" of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))" busy = true Task { do { diff --git a/mac/Sources/Byre/Settings.swift b/mac/Sources/Byre/Settings.swift index 2916b23..c7c7afe 100644 --- a/mac/Sources/Byre/Settings.swift +++ b/mac/Sources/Byre/Settings.swift @@ -37,18 +37,6 @@ struct SettingsView: View { ModelMenu(current: store.defaultModel, help: "Model for new tasks") { m in try await store.api.client.updateDefaultModel(body: .json(.init(model: m))).ok.body.json } - if let me = store.me { - heading("Review models") - Text("The first model reviews your tasks' work. The second reviews work the first model wrote.") - .foregroundStyle(Palette.muted) - .fixedSize(horizontal: false, vertical: true) - ModelMenu(current: me.reviewModel, help: "Model that reviews your tasks") { m in - try await store.api.client.updateReviewModels(body: .json(.init(reviewModel: m, secondReviewModel: me.secondReviewModel))).ok.body.json - } - ModelMenu(current: me.secondReviewModel, help: "Model that reviews work the first review model wrote") { m in - try await store.api.client.updateReviewModels(body: .json(.init(reviewModel: me.reviewModel, secondReviewModel: m))).ok.body.json - } - } heading("iCloud sync") SyncState() heading("This Mac's permissions") diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 904e8ac..0e6312e 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -635,7 +635,7 @@ final class Store { try await self.loadMe() } } - case "project.created": + case "project.created", "project.updated": spawn("load projects") { try await self.loadProjects() } diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 6b8b7b8..d723cc4 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -159,7 +159,7 @@ final class SwitcherModel { self.anchor = anchor Task { do { - workflows = try await store.api.client.listWorkflows().ok.body.json + workflows = try await store.api.client.listWorkflows().ok.body.json.filter { $0.kind == .task } } catch { overlays.showAlert(errorText(error)) } diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index 9496bf0..ed362de 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -46,6 +46,7 @@ enum RowKind { case thinking(md: [MDBlock]) case tool(name: String, brief: String, input: String) case subagent(title: String, description: String, prompt: [MDBlock]) + case reviewer(model: String) case group case orphan(String) case turnEnd(error: String?) @@ -123,6 +124,17 @@ final class Row: Identifiable { return false } + var inReview: Bool { + var o = owner + while let r = o { + if case .reviewer = r.kind { + return r.active + } + o = r.owner + } + return false + } + func within(_ ancestor: Row) -> Bool { var o = owner while let r = o { @@ -193,7 +205,7 @@ private final class Subagent { private final class Ctx { var tools: [String: Row] = [:] var subagents: [String: Subagent] = [:] - var reviewers: Set<String> = [] + var reviewers: [String: Row] = [:] var snapshots: [String: Row] = [:] var reviews: [String: Review] = [:] var reviewRows: [String: [Row]] = [:] @@ -378,7 +390,7 @@ final class TranscriptBuilder { if ctx.handoff != nil { return } - if let sid = ev.agentSessionId, ctx.reviewers.contains(sid) { + if let sid = ev.agentSessionId, ctx.reviewers[sid] != nil { return } let row: Row @@ -437,10 +449,17 @@ final class TranscriptBuilder { rowTask = ev.taskId ?? taskId let own = rowTask == taskId if ev.kind == "session.created" && str(p, "role") == "reviewer", let sid = ev.agentSessionId, !sid.isEmpty { - ctx.reviewers.insert(sid) + let row = Row(.reviewer(model: str(p, "model") ?? ""), taskId: rowTask, body: true) + row.status = "running" + row.active = true + ctx.reviewers[sid] = row + ctx.subagents[sid] = Subagent(row: row) + ctx.run = nil + put(row) continue } - if let sid = ev.agentSessionId, ctx.reviewers.contains(sid) { + if let sid = ev.agentSessionId, let reviewer = ctx.reviewers[sid] { + reviewerEvent(ev, reviewer, ctx.subagents[parent] == nil ? sid : parent) continue } if ev.kind == "agent.vendor" { @@ -449,7 +468,7 @@ final class TranscriptBuilder { } if ev.kind == "session.subagents" { if int(p, "subagents") == 0 { - for sub in ctx.subagents.values where sub.row.active { + for sub in ctx.subagents.values where sub.row.active && !ctx.reviewers.values.contains(where: { $0 === sub.row }) { endSubagent(sub.row, .error, "ended with its process") } } @@ -517,11 +536,13 @@ final class TranscriptBuilder { } if ev.kind == "review.started" || ev.kind == "review.finished" || ev.kind == "review.finding" { let r = try! decodePayload(ev.payload, as: Review.self) - ctx.snapshots[snapKey(r.snapshotN)]?.review = r - ctx.reviews[snapKey(r.snapshotN)] = r + ctx.reviews[reviewKey(r.snapshotN, r.stage)] = r for row in ctx.reviewRows[r.id] ?? [] { row.review = r } + if let reviewer = ctx.reviewers[r.agentSessionId] { + reviewerState(reviewer, r) + } if ev.kind == "review.finished" { let rows = renderReview(r, ev.createdAt) if !rows.isEmpty { @@ -546,7 +567,7 @@ final class TranscriptBuilder { } let sub = ctx.subagents[parent] if ev.kind == "agent.tool_call" && isSubagentCall(p) { - let row = renderSubagent(p) + let row = renderSubagent(p, parent) row.started = ev.createdAt if let sub { sub.run = nil @@ -562,7 +583,7 @@ final class TranscriptBuilder { let row = Row(.findingResponse(snapshot: n, index: (int(input, "finding") ?? 0) - 1, response: str(input, "response") ?? "", reason: str(input, "reason") ?? ""), taskId: rowTask) row.started = ev.createdAt ctx.tools[jsString(p["id"])] = row - if let r = ctx.reviews[snapKey(n)], r.state == .done { + if let r = ctx.reviews[reviewKey(n, str(input, "reviewer") ?? "")], r.state == .done { row.review = r ctx.reviewRows[r.id, default: []].append(row) } @@ -572,7 +593,7 @@ final class TranscriptBuilder { } if ev.kind == "agent.tool_call" { let name = toolLabel(jsString(p["name"])) - let row = renderToolCall(p) + let row = renderToolCall(p, parent) row.started = ev.createdAt if name == "ToolSearch" { continue @@ -580,7 +601,7 @@ final class TranscriptBuilder { addToRun(row, Call(name: name, target: toolTarget(p["input"] ?? nil, worktree())), parent) continue } - guard let row = renderEvent(ev) else { + guard let row = renderEvent(ev, parent) else { continue } if let sub { @@ -653,6 +674,10 @@ final class TranscriptBuilder { "\(rowTask ?? "")/\(n)" } + private func reviewKey(_ n: Int, _ stage: String) -> String { + "\(snapKey(n))/\(stage)" + } + private func claudeTaskStatus(_ p: JSONDict) { guard str(p, "type") == "system", let sub = ctx.subagents[jsString(p["tool_use_id"])] else { return @@ -685,36 +710,97 @@ final class TranscriptBuilder { } } - private func renderToolCall(_ p: JSONDict) -> Row { + private func renderToolCall(_ p: JSONDict, _ parent: String) -> Row { let row = Row(.tool(name: toolLabel(jsString(p["name"])), brief: toolBrief(p["input"] ?? nil, worktree()), input: prettyJSON(p["input"] ?? nil)), taskId: rowTask) - row.tag = subagentTag(parentOf(p)) - row.owner = ctx.subagents[parentOf(p)]?.row + row.tag = subagentTag(parent) + row.owner = ctx.subagents[parent]?.row ctx.tools[jsString(p["id"])] = row return row } - private func renderSubagent(_ p: JSONDict) -> Row { + private func renderSubagent(_ p: JSONDict, _ parent: String) -> Row { let input = dict(p, "input") ?? [:] let id = jsString(p["id"]) let type = str(input, "subagent_type") ?? "subagent" let row = Row(.subagent(title: "\(jsString(p["name"])) \(type)", description: str(input, "description") ?? "", prompt: parseMarkdown(str(input, "prompt") ?? "")), taskId: rowTask, body: true) - row.tag = subagentTag(parentOf(p)) - row.owner = ctx.subagents[parentOf(p)]?.row + row.tag = subagentTag(parent) + row.owner = ctx.subagents[parent]?.row ctx.tools[id] = row ctx.subagents[id] = Subagent(row: row) return row } + private func reviewerEvent(_ ev: ByreEvent, _ reviewer: Row, _ parent: String) { + let p = ev.payload.value + let sub = ctx.subagents[parent]! + switch ev.kind { + case "agent.vendor": + claudeTaskStatus(p) + case "user.message": + addToRun(byrePrompt(str(p, "text") ?? "", ev.createdAt), Call(name: "byre", target: ""), parent) + case "agent.tool_call": + let name = toolLabel(jsString(p["name"])) + reviewer.text = "\(name) \(toolBrief(p["input"] ?? nil, worktree()))" + if isSubagentCall(p) { + let row = renderSubagent(p, parent) + row.started = ev.createdAt + if sub.row !== reviewer { + sub.run = nil + sub.row.body!.rows.append(row) + return + } + addToRun(row, Call(name: jsString(p["name"]), target: ""), parent) + return + } + let row = renderToolCall(p, parent) + row.started = ev.createdAt + if name != "ToolSearch" { + addToRun(row, Call(name: name, target: toolTarget(p["input"] ?? nil, worktree())), parent) + } + case "turn.ended": + for tool in ctx.tools.values where tool.result == nil && tool.within(reviewer) { + tool.unfinished = true + } + case "agent.message", "agent.thinking", "agent.tool_result", "session.state", "session.error", "session.rate_limited": + if ev.kind == "agent.message" && parent == ev.agentSessionId { + reviewer.text = trimmed(jsString(p["text"])).components(separatedBy: "\n").first ?? "" + } + guard let row = renderEvent(ev, parent) else { + return + } + sub.run = nil + sub.row.body!.rows.append(row) + default: + break + } + } + + private func reviewerState(_ row: Row, _ r: Review) { + row.review = r + row.reviewOf = r.snapshotN + switch r.state { + case .done: + endSubagent(row, .done, reviewCounts(r)) + row.text = "" + case .failed: + let superseded = r.error.hasPrefix("superseded") + endSubagent(row, superseded ? .done : .error, superseded ? "superseded" : "failed") + row.text = r.error + default: + break + } + } + private func renderReview(_ r: Review, _ when: Date) -> [Row] { + if r.state == .failed && r.agentSessionId.isEmpty { + return [Row(.note(Note(parts: [.text("\(r.stage) review of snapshot #\(r.snapshotN) failed: \(r.error)")], when: when, style: .error)), taskId: rowTask)] + } let fs = findings(r) - if fs.isEmpty && r.state != .failed { + if fs.isEmpty { return [] } - let outcome = r.state == .failed ? "failed: \(r.error)" : reviewCounts(r) - let parts: [NotePart] = [.text("review of snapshot #\(r.snapshotN): \(outcome)")] - let head = fs.isEmpty - ? Row(.note(Note(parts: parts, when: when, style: r.state == .failed ? .error : .plain)), taskId: rowTask) - : Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: rowTask) + let parts: [NotePart] = [.text("\(r.stage) review of snapshot #\(r.snapshotN): \(reviewCounts(r))")] + let head = Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: rowTask) let rows = [head] + fs.indices.map { Row(.finding(index: $0), taskId: rowTask) } for row in rows { row.review = r @@ -724,7 +810,13 @@ final class TranscriptBuilder { return rows } - private func renderEvent(_ ev: ByreEvent) -> Row? { + private func byrePrompt(_ text: String, _ when: Date) -> Row { + let first = text.components(separatedBy: "\n").first ?? "" + let brief = first.count > 120 ? "\(first.prefix(120))…" : first + return Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: rowTask) + } + + private func renderEvent(_ ev: ByreEvent, _ parent: String) -> Row? { let p = ev.payload.value let when = ev.createdAt func note(_ parts: [NotePart], _ style: NoteStyle = .plain, time: Bool = true) -> Row { @@ -739,9 +831,7 @@ final class TranscriptBuilder { } let text = str(p, "text") ?? "" if ev.actor == "byre" { - let first = text.components(separatedBy: "\n").first ?? "" - let brief = first.count > 120 ? "\(first.prefix(120))…" : first - let row = Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: rowTask) + let row = byrePrompt(text, when) row.hidden = bool(p, "continuation") || bool(p, "findings") return row } @@ -750,9 +840,9 @@ final class TranscriptBuilder { case "agent.message": let text = jsString(p["text"]) - if !parentOf(p).isEmpty { + if !parent.isEmpty { let row = Row(.subagentMessage(when: when, md: parseMarkdown(text)), taskId: rowTask) - row.tag = subagentTag(parentOf(p)) + row.tag = subagentTag(parent) return row } if trimmed(trimmed(text).components(separatedBy: "\n").last ?? "") == quietReply { @@ -764,7 +854,7 @@ final class TranscriptBuilder { case "agent.thinking": let row = Row(.thinking(md: parseMarkdown(jsString(p["text"]))), taskId: rowTask) - row.tag = subagentTag(parentOf(p)) + row.tag = subagentTag(parent) return row case "agent.tool_result": @@ -906,13 +996,11 @@ func findingCount(_ r: Review) -> Int { r.state == .done ? findings(r).indices.filter { findingOpen(r, $0) }.count : 0 } -func latestReview(_ events: ArraySlice<ByreEvent>) -> Review? { - var best: Review? +func latestReviewedSnapshot(_ events: ArraySlice<ByreEvent>) -> Int? { + var best: Int? for ev in events where isReviewEvent(ev) { let r = try! decodePayload(ev.payload, as: Review.self) - if best == nil || r.snapshotN >= best!.snapshotN { - best = r - } + best = max(best ?? r.snapshotN, r.snapshotN) } return best } diff --git a/mac/Sources/Byre/WorkflowEditor.swift b/mac/Sources/Byre/WorkflowEditor.swift new file mode 100644 index 0000000..3e9a4c0 --- /dev/null +++ b/mac/Sources/Byre/WorkflowEditor.swift @@ -0,0 +1,471 @@ +import ByreAPI +import SwiftUI + +typealias WorkflowDefinition = Components.Schemas.WorkflowDefinition +typealias WorkflowStep = Components.Schemas.WorkflowStep +typealias TeamMember = Components.Schemas.TeamMember + +@MainActor +@Observable +final class StepNode: Identifiable { + enum Kind { + case stage, order, parallel + } + + let id = UUID() + var kind: Kind + var name = "" + var model = "" + var prompt = "" + var agents: [TeamMember]? + var children: [StepNode] = [] + weak var parent: StepNode? + var open = false + + init(kind: Kind, children: [StepNode] = []) { + self.kind = kind + self.children = children + for c in children { + c.parent = self + } + } + + convenience init(_ step: WorkflowStep) { + if let order = step.order { + self.init(kind: .order, children: order.map { StepNode($0) }) + } else if let parallel = step.parallel { + self.init(kind: .parallel, children: parallel.map { StepNode($0) }) + } else { + self.init(kind: .stage) + name = step.stage ?? "" + model = step.model ?? "" + prompt = step.prompt ?? "" + agents = step.agents + } + } + + var step: WorkflowStep { + switch kind { + case .stage: + WorkflowStep(stage: name, model: agents == nil ? model : nil, prompt: prompt, agents: agents) + case .order: + WorkflowStep(order: children.map(\.step)) + case .parallel: + WorkflowStep(parallel: children.map(\.step)) + } + } + + var index: Int { + parent!.children.firstIndex { $0 === self }! + } + + func contains(_ other: StepNode) -> Bool { + other === self || children.contains { $0.contains(other) } + } + + func stageNames() -> [String] { + kind == .stage ? [name] : children.flatMap { $0.stageNames() } + } + + func insert(_ node: StepNode, at i: Int) { + node.parent = self + children.insert(node, at: i) + } + + func detach() { + parent?.children.remove(at: index) + parent = nil + } +} + +@MainActor +private func newStage(_ root: StepNode, _ model: String) -> StepNode { + let names = Set(root.stageNames()) + var n = names.count + 1 + while names.contains("Stage \(n)") { + n += 1 + } + let s = StepNode(kind: .stage) + s.name = "Stage \(n)" + s.model = model + s.open = true + return s +} + +private struct StepLine: Identifiable { + let node: StepNode + let guide: String + var id: UUID { node.id } +} + +@MainActor +private func stepLines(_ node: StepNode, _ lead: String) -> [StepLine] { + var out: [StepLine] = [] + for (i, c) in node.children.enumerated() { + let last = i == node.children.count - 1 + out.append(StepLine(node: c, guide: lead + (last ? "└─ " : "├─ "))) + out += stepLines(c, lead + (last ? " " : "│ ")) + } + return out +} + +struct WorkflowEditor: View { + let original: String? + let close: () -> Void + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var root: StepNode? + @ViewState private var loaded: WorkflowDefinition? + @ViewState private var name = "" + @ViewState private var description = "" + @ViewState private var review = false + @ViewState private var failure = "" + @ViewState private var saving = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + Button("‹ workflows") { + Task { + await leave() + } + } + .buttonStyle(SmallLinkButtonStyle()) + Spacer() + Button("save") { + Task { + await save() + } + } + .buttonStyle(WebButtonStyle(primary: true)) + .disabled(saving || !changed || name.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(.top, 16) + if let root { + if original == nil { + TextField("Name", text: $name) + .modifier(FieldStyle()) + .padding(.top, 12) + } else { + Text(name) + .font(Fonts.font(13, weight: 700)) + .textSelection(.enabled) + .padding(.top, 12) + } + TextField("Description", text: $description, axis: .vertical) + .modifier(FieldStyle()) + .padding(.top, 8) + Picker("", selection: $review) { + Text("Runs on a brief, each stage its own task").tag(false) + Text("Reviews snapshots, each stage a reviewer").tag(true) + } + .pickerStyle(.radioGroup) + .labelsHidden() + .padding(.top, 8) + if !failure.isEmpty { + Text(failure) + .foregroundStyle(Palette.danger) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) + } + VStack(alignment: .leading, spacing: 0) { + ForEach(stepLines(root, "")) { row in + StepRow(node: row.node, guide: row.guide, root: root) + } + } + .padding(.top, 12) + Button("add stage") { + root.insert(newStage(root, store.defaultModel), at: root.children.count) + } + .buttonStyle(SmallLinkButtonStyle()) + .padding(.top, 8) + } else if !failure.isEmpty { + Text(failure).foregroundStyle(Palette.muted).padding(.top, 12) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Palette.bg) + .task { + await load() + } + } + + private var current: WorkflowDefinition? { + root.map { WorkflowDefinition(description: description.isEmpty ? nil : description, kind: review ? .review : nil, steps: $0.children.map(\.step)) } + } + + private var changed: Bool { + original == nil || current != loaded + } + + private func load() async { + guard let original else { + let r = StepNode(kind: .order) + r.insert(newStage(r, store.defaultModel), at: 0) + root = r + return + } + do { + let def = try await store.api.client.getWorkflow(path: .init(name: original), headers: .init(accept: [.init(contentType: .json)])).ok.body.json + name = original + description = def.description ?? "" + review = def.kind == .review + root = StepNode(kind: .order, children: def.steps.map { StepNode($0) }) + loaded = current + } catch { + failure = errorText(error) + } + } + + private func save() async { + guard let def = current else { + return + } + saving = true + defer { saving = false } + let target = name.trimmingCharacters(in: .whitespaces) + do { + _ = try await store.api.client.putWorkflow(path: .init(name: target), body: .json(def)).ok.body.json + failure = "" + loaded = def + if original == nil { + close() + } + } catch { + failure = errorText(error) + } + } + + private func leave() async { + if changed, current != nil, !(await overlays.showConfirm(["This workflow has unsaved changes."], action: "Discard changes", danger: true)) { + return + } + close() + } +} + +private struct StepRow: View { + @Bindable var node: StepNode + let guide: String + let root: StepNode + @Environment(Store.self) private var store + @ViewState private var target = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(guide).foregroundStyle(Palette.muted) + switch node.kind { + case .stage: + Button { + node.open.toggle() + } label: { + Image(systemName: node.open ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Palette.muted) + .frame(width: 12) + } + .buttonStyle(.plain) + TextField("Stage name", text: $node.name) + .textFieldStyle(.plain) + .font(Fonts.font(13, weight: 700)) + if let agents = node.agents { + Text("team of \(agents.count)").foregroundStyle(Palette.muted).fixedSize() + } else { + StepModelMenu(model: $node.model) + } + case .order: + Text("in order").foregroundStyle(Palette.muted) + Spacer() + case .parallel: + Text("in parallel").foregroundStyle(Palette.muted) + Spacer() + } + actions + } + .padding(.vertical, 3) + .background(target ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 4)) + .draggable(node.id.uuidString) + .dropDestination(for: String.self) { items, _ in + drop(items) + } isTargeted: { + target = $0 + } + if node.kind == .stage && node.open { + VStack(alignment: .leading, spacing: 6) { + if node.agents != nil { + Text("Every agent's prompt").foregroundStyle(Palette.muted) + } + PromptEditor(text: $node.prompt) + if let agents = node.agents { + ForEach(agents.indices, id: \.self) { i in + HStack(spacing: 6) { + TextField("Agent name", text: Binding(get: { node.agents?[i].name ?? "" }, set: { node.agents?[i].name = $0 })) + .textFieldStyle(.plain) + .font(Fonts.font(13, weight: 700)) + StepModelMenu(model: Binding(get: { node.agents?[i].model ?? "" }, set: { node.agents?[i].model = $0 })) + } + .padding(.top, 4) + PromptEditor(text: Binding(get: { node.agents?[i].prompt ?? "" }, set: { node.agents?[i].prompt = $0 })) + } + } + } + .padding(.leading, CGFloat(guide.count) * 8 + 18) + .padding(.bottom, 8) + } + } + } + + private var actions: some View { + Menu { + switch node.kind { + case .stage: + Button("Add stage after") { addAfter() } + Button("Run in parallel with a new stage") { wrap(.parallel) } + Button("Group with a new stage in order") { wrap(.order) } + case .order, .parallel: + Button("Add stage inside") { node.insert(stage(), at: node.children.count) } + Button("Add stage after") { addAfter() } + Button(node.kind == .order ? "Change to in parallel" : "Change to in order") { + node.kind = node.kind == .order ? .parallel : .order + } + Button("Ungroup") { ungroup() } + } + Divider() + Button("Move up") { move(-1) } + .disabled(node.index == 0) + Button("Move down") { move(1) } + .disabled(node.index == node.parent!.children.count - 1) + Divider() + Button("Remove") { node.detach() } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Palette.muted) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + } + + private func stage() -> StepNode { + newStage(root, store.defaultModel) + } + + private func addAfter() { + node.parent!.insert(stage(), at: node.index + 1) + } + + private func wrap(_ kind: StepNode.Kind) { + let parent = node.parent! + let i = node.index + node.detach() + let group = StepNode(kind: kind) + group.insert(node, at: 0) + group.insert(stage(), at: 1) + parent.insert(group, at: i) + } + + private func ungroup() { + let parent = node.parent! + let i = node.index + let children = node.children + node.detach() + for (n, c) in children.enumerated() { + parent.insert(c, at: i + n) + } + } + + private func move(_ by: Int) { + let parent = node.parent! + let i = node.index + node.detach() + parent.insert(node, at: i + by) + } + + private func drop(_ items: [String]) -> Bool { + guard let id = items.first.flatMap(UUID.init(uuidString:)), let dragged = find(root, id), !dragged.contains(node) else { + return false + } + dragged.detach() + if node.kind == .stage { + node.parent!.insert(dragged, at: node.index) + } else { + node.insert(dragged, at: node.children.count) + } + return true + } + + private func find(_ n: StepNode, _ id: UUID) -> StepNode? { + if n.id == id { + return n + } + for c in n.children { + if let f = find(c, id) { + return f + } + } + return nil + } +} + +private struct StepModelMenu: View { + @Binding var model: String + + var body: some View { + Menu { + ForEach(models, id: \.self) { m in + Button(m) { model = m } + } + } label: { + HStack(spacing: 6) { + Text(model.isEmpty ? "model" : model) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .semibold)) + } + .font(Fonts.font(12)) + .foregroundStyle(model.isEmpty ? Palette.muted : Palette.text) + .contentShape(Rectangle()) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + } +} + +private struct PromptEditor: View { + @Binding var text: String + @FocusState private var focused: Bool + + var body: some View { + TextEditor(text: $text) + .font(Fonts.font(12)) + .scrollContentBackground(.hidden) + .writingToolsBehavior(.disabled) + .focused($focused) + .padding(.horizontal, 5) + .padding(.vertical, 4) + .frame(height: 160) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .topLeading) { + if text.isEmpty { + Text("Prompt…") + .foregroundStyle(Palette.muted) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .allowsHitTesting(false) + } + } + .overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border)) + } +} diff --git a/mac/Sources/Byre/Workflows.swift b/mac/Sources/Byre/Workflows.swift index 3671644..474b780 100644 --- a/mac/Sources/Byre/Workflows.swift +++ b/mac/Sources/Byre/Workflows.swift @@ -1,15 +1,41 @@ import ByreAPI import SwiftUI +private enum Editing: Equatable { + case none, new, existing(String) +} + struct WorkflowsView: View { + @ViewState private var editing = Editing.none + var body: some View { + switch editing { + case .none: + list + case .new: + WorkflowEditor(original: nil) { editing = .none } + case .existing(let name): + WorkflowEditor(original: name) { editing = .none } + .id(name) + } + } + + private var list: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - Text("Workflows run a brief through stages, each its own task. Run asks your lead to start one, so you can add the project and the brief first.") - .foregroundStyle(Palette.muted) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - WorkflowList() + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text("Workflows run a brief through stages, each its own task. Run asks your lead to start one, so you can add the project and the brief first.") + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + Button("new") { + editing = .new + } + .buttonStyle(SmallLinkButtonStyle()) + .help("Write a new workflow") + } + .padding(.top, 16) + WorkflowList { editing = .existing($0) } } .padding(.horizontal, 16) .padding(.top, 8) @@ -22,6 +48,7 @@ struct WorkflowsView: View { } private struct WorkflowList: View { + let edit: (String) -> Void @Environment(Store.self) private var store @ViewState private var list: [WorkflowSummary]? @ViewState private var failure: String? @@ -35,7 +62,7 @@ private struct WorkflowList: View { hint("No saved workflows. Ask your lead to write one.") } else { ForEach(list.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }, id: \.name) { w in - WorkflowRow(workflow: w) + WorkflowRow(workflow: w) { edit(w.name) } } } } else if let failure { @@ -81,14 +108,20 @@ private struct WorkflowList: View { private struct WorkflowRow: View { let workflow: WorkflowSummary + let edit: () -> Void @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays var body: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 2) { - Text(workflow.name) - .font(Fonts.font(13, weight: 700)) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(workflow.name) + .font(Fonts.font(13, weight: 700)) + if workflow.kind == .review { + Text("reviews snapshots").font(Fonts.font(11)).foregroundStyle(Palette.muted) + } + } if !workflow.description.isEmpty { Text(workflow.description) .foregroundStyle(Palette.muted) @@ -97,11 +130,18 @@ private struct WorkflowRow: View { .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) - Button("run") { - run() + Button("edit") { + edit() } .buttonStyle(SmallLinkButtonStyle()) - .help("Open the Lead tab with a request to run this workflow in its composer") + .help("See and change this workflow's steps and prompts") + if workflow.kind == .task { + Button("run") { + run() + } + .buttonStyle(SmallLinkButtonStyle()) + .help("Open the Lead tab with a request to run this workflow in its composer") + } } .padding(.vertical, 6) .overlay(alignment: .top) {