diff --git a/api/openapi.yaml b/api/openapi.yaml index afd78d6..fea0e5e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1233,28 +1233,21 @@ paths: operationId: getWorkflow responses: "200": - 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. + description: The workflow's definition. 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. With from, it renames workflow + Creates or replaces the workflow from its definition. The name is one line of 1 to 100 characters. Tasks that already froze the workflow keep their copy. With from, it renames workflow from to name, which must not be taken, and projects that review with from review with name instead. parameters: - { name: from, in: query, required: false, schema: { type: string } } requestBody: required: true content: - application/yaml: - schema: { type: string } application/json: schema: { $ref: "#/components/schemas/WorkflowDefinition" } responses: @@ -1479,9 +1472,9 @@ components: WorkflowDefinition: type: object description: > - A workflow's file, which holds everything but its name: its steps, run in order. Run on a brief, each stage - runs as its own task once the steps before it have finished; as a project's review workflow, each stage is a - reviewer of a snapshot and gets the earlier stages' reviews. Sent and returned as YAML. + A workflow's definition, which holds everything but its name: its steps, run in order. Run on a brief, each + stage runs as its own task once the steps before it have finished; as a project's review workflow, each stage + is a reviewer of a snapshot and gets the earlier stages' reviews. required: [steps] properties: description: { type: string, x-go-type-skip-optional-pointer: true } @@ -1528,7 +1521,7 @@ components: required: [name, definition] properties: name: { type: string } - definition: { type: string, description: The WorkflowDefinition as YAML. } + definition: { $ref: "#/components/schemas/WorkflowDefinition" } WorkflowRun: type: object diff --git a/docs/operations.md b/docs/operations.md index 8a1bc94..eac9840 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -12,7 +12,7 @@ - `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`). - `internal/codex`: the `codex app-server` JSON-RPC client, with types generated from the CLI's schema by `go generate ./internal/codex` (see `docs/codex-protocol.md`). One goroutine reads the CLI's stdout and blocks on the bounded `Events` channel when its consumer falls behind, so responses queued behind those notifications wait too: the goroutine draining `Events` (`codexProcess.drive`) therefore never waits on a reply itself. It writes `turn/steer` in order and waits for the reply on another goroutine, and starts the prose-question nudge and the turn-end `Deliver` on their own goroutines. Every request gives up after `rpcTimeout`, or the caller's earlier deadline, and that deadline covers waiting for the write lock, writing the request to stdin and waiting for the reply. Stdin is an `os.Pipe`, so writes take a deadline. A request whose deadline passes before any of it is written fails and leaves the CLI running. One that times out after part of its line is written has broken the framing, so byre kills the CLI, logging `codex stopped reading its stdin mid-line`, and the session ends as any exited process does. - `internal/antigravity`: the Antigravity CLI (`agy`) stream-json client (see `docs/antigravity-protocol.md`). Each session has its own plugin directory and a `127.0.0.1` listener that serves byre's MCP tools and hooks to the CLI, and restarts the CLI on the same conversation after an interrupt or a model switch. -- `internal/workflow`: validating workflow definitions and reading and writing their YAML files (see "Workflows"). +- `internal/workflow`: parsing and validating workflow definitions and walking their steps (see "Workflows"). - `hands/` is `byre-hands`, the Swift MCP server that lets task agents see and drive the Mac byre runs on (see "Screen and app control") and, bundled into the macOS app, the human's Macs (see "Client Macs"). - `internal/clients`: the WebSocket connections of the macOS apps that task agents use (see "Client Macs"). - `mac/` is the macOS app, byre's client; `docs/macos.md` describes how it is built, and "macOS app" below covers building, installing and its storage. It joins the tailnet as its own node and connects to byre's `https` origin on byre's own node (see "Tailnet"), typed into its connection screen. The server serves only the API; it has no web pages. @@ -44,7 +44,7 @@ An implementer that thinks its task shouldn't land at all (the approach failed, Agents find closed tasks with the `search_closed_tasks` MCP tool, which task agents get for their own project and the lead for one project (`project_id`) or all: a case-insensitive substring match of `query` on title, merge title, TODO item, summary and merge commit (`Store.SearchClosedTasks`), newest closed first, at most `limit` (default 20, at most 100), each with its id, outcome, merge commit, close time, creator, titles, TODO item, fork source and summary. The lead reads a closed task's transcript with `read_transcript`. There is no closed-task list in the UI; the human asks the lead. -**Knowledge base.** How byre features work that agents need only now and then lives in articles they fetch on demand instead of in their guidance: the Markdown files in `internal/agents/kb/`, embedded in the binary (`kb.go`). A file's name without `.md` is its topic, its first line a one-line summary and the rest the article. The `read_knowledge` MCP tool, which task agents and the lead get, lists every topic with its summary in its description and returns the article for `topic`. Adding an article is adding a file; the index follows. The articles are `signing` (turning on signed merge commits), `usage-commands` (the usage command's JSON format, see "Usage" under "Status"), `workflow-files` (a workflow's YAML, see "Workflows"; `read_workflow` and `save_workflow` point at it) and `todo-file` (the `TODO.md` format; the task guidance points at it). +**Knowledge base.** How byre features work that agents need only now and then lives in articles they fetch on demand instead of in their guidance: the Markdown files in `internal/agents/kb/`, embedded in the binary (`kb.go`). A file's name without `.md` is its topic, its first line a one-line summary and the rest the article. The `read_knowledge` MCP tool, which task agents and the lead get, lists every topic with its summary in its description and returns the article for `topic`. Adding an article is adding a file; the index follows. The articles are `signing` (turning on signed merge commits), `usage-commands` (the usage command's JSON format, see "Usage" under "Status"), `workflow-definitions` (a workflow definition's fields, see "Workflows"; `read_workflow` and `save_workflow` point at it) and `todo-file` (the `TODO.md` format; the task guidance points at it). `POST /api/tasks/{id}/discard` (the **Discard** button, the lead's `discard_task`, and byre itself for a finished workflow's other stages) doesn't remove anything at once: it gives the task's agent one dedicated turn to write its summary, and closes the task when that turn ends. The discard is a persisted state of the task, `tasks.discard_state`, and every step that changes it is one transaction in the store (`internal/store/discard.go`), which is what keeps it safe against turns starting, ending and byre restarting around it: @@ -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 "" 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}`, with `from` set to the name it was opened under for an existing workflow, and returns to the list; it shows the server's validation error under the description when it refuses, and leaving with unsaved changes asks first. The name is a field at the top of the editor; changing an existing workflow's name and saving renames it. `PUT` with `from` refuses with 404 when `from` doesn't exist, even when it equals the name, so an editor opened before another client renamed the workflow can't recreate the old name. When `from` differs from the name, it renames in the same transaction as the save (`Store.PutWorkflow`): it refuses with 409 when the new name is taken, deletes `from`, points every project whose `review_workflow` is `from` at the new name (a `project.updated` event each), and emits `workflow.deleted` for `from` before `workflow.updated` for the new name. Tasks that froze the workflow keep its old name in their copy. Under the description the editor draws the stages as a flowchart (`stepGraph`, laid out and drawn with the run view's `layoutFlowchart` and `FlowchartCanvas`), redrawn as the tree is edited. Each workflow in the list also has run (above) and delete, which asks first and calls `DELETE /api/workflows/{name}`. 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 is On when launchd has the service loaded (`launchctl print gui//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 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 "" 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}`, 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}`, with `from` set to the name it was opened under for an existing workflow, and returns to the list; it shows the server's validation error under the description when it refuses, and leaving with unsaved changes asks first. The name is a field at the top of the editor; changing an existing workflow's name and saving renames it. `PUT` with `from` refuses with 404 when `from` doesn't exist, even when it equals the name, so an editor opened before another client renamed the workflow can't recreate the old name. When `from` differs from the name, it renames in the same transaction as the save (`Store.PutWorkflow`): it refuses with 409 when the new name is taken, deletes `from`, points every project whose `review_workflow` is `from` at the new name (a `project.updated` event each), and emits `workflow.deleted` for `from` before `workflow.updated` for the new name. Tasks that froze the workflow keep its old name in their copy. Under the description the editor draws the stages as a flowchart (`stepGraph`, laid out and drawn with the run view's `layoutFlowchart` and `FlowchartCanvas`), redrawn as the tree is edited. Each workflow in the list also has run (above) and delete, which asks first and calls `DELETE /api/workflows/{name}`. 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 is On when launchd has the service loaded (`launchctl print gui//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 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 @@ -474,7 +474,7 @@ An implementer on Claude Code forks its own task with the `fork` MCP tool (`fork 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. -- **Review workflows.** A project's review workflow is any saved workflow (see "Workflows") whose stages are single agents, 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 missing workflow or one with a team stage is refused, `reviewProblem` in `internal/agents/workflows.go`), 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 ""`. 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. +- **Review workflows.** A project's review workflow is any saved workflow (see "Workflows") whose stages are single agents, 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 missing workflow or one with a team stage is refused, `reviewProblem` in `internal/agents/workflows.go`), 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 ""`. A project's snapshots are reviewed with the workflow as it is when each review stage starts: each `reviews` row names the definition it ran from (`reviews.definition_id`, see "Storage" under "Workflows"), 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 has a team stage 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//review-`, 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. - **Delivery.** A snapshot's review is delivered once its workflow is done: every stage has a finished row and no stage is left to start (`reviewsSettled`), so the agent never starts fixing while a reviewer is still at work, which would supersede it. `reviewFinished` then sends every stage's findings to the task's live agent in one `byre` message (`deliverFindings` and `findingsMessage` in `internal/agents/review.go`): under a `## Reviewer ""` heading per stage with findings, each finding's number, severity, title, location and detail, after 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"). Only the finish that settles the review sends it: of the snapshot's reviews, the one with the latest `finished_at` (then the highest id), so two stages finishing at once send one message; the startup passes that rerun `reviewFinished` send nothing. Nothing is sent when no stage found anything, or when a newer snapshot has been taken. 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 delivered`. Forwarding doesn't set any finding's state; **fix** and **ignore** stay available until the agent or the user answers. @@ -497,14 +497,15 @@ Reviews look at one task's change; an audit checks the whole codebase as it stan 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 `steps`, run in order. Any workflow can run on a brief, each stage its own task, and any whose stages are single agents can be a project's review workflow, each stage a reviewer of every snapshot (see "Reviews"). 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`). +- **Definition.** A workflow is a name and a `WorkflowDefinition` (`api/openapi.yaml`): an optional `description` and its `steps`, run in order. Any workflow can run on a brief, each stage its own task, and any whose stages are single agents can be a project's review workflow, each stage a reviewer of every snapshot (see "Reviews"). 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`. It travels as JSON everywhere; `workflow.Parse` reads it, refuses unknown fields and validates it. +- **Storage.** Definitions are rows, never text: `workflow_definitions` (`id`, `description`), `workflow_steps` (one per step: `definition_id`, `id` numbered depth-first from 0, `parent` (NULL at the top level), `position` among its siblings, `kind` `stage`, `order` or `parallel`, and a stage's `stage`, `model` and `prompt`) and `workflow_agents` (a team stage's agents: `definition_id`, `step_id`, `position`, `name`, `model`, `prompt`). A definition is never changed once written: a save writes a new one and points `workflows.definition_id` at it, so freezing is keeping the id, in `tasks.workflow_definition_id` and `reviews.definition_id`. `insertDefinition` and `loadDefinition` (`internal/store/definition.go`) write and rebuild the tree. - **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`, or an empty prompt. A workflow may end with several stages (a last `parallel`). 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 and turns reviews off (a `project.updated` event each) for the projects that reviewed with 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 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 `: <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. +- **API.** `GET /api/workflows` lists every workflow's name and description. `GET /api/workflows/{name}` returns its definition; `PUT` takes a definition as the body and creates or replaces the workflow; `DELETE` removes it and turns reviews off (a `project.updated` event each) for the projects that reviewed with 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; returns the definition as JSON), `save_workflow` (`name` and `definition`, the definition as an object whose input schema spells out every field, so nothing is quoted or escaped by hand) 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 nothing before it (`workflow.Starts`), branched from the lead's `from_task_id` HEAD when given, else from the default branch. Each stage task keeps the workflow's name and definition as they are when the run starts in `tasks.workflow_name` and `tasks.workflow_definition_id`, 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}` (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` 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. The workflow is finished once every stage has: when a last stage (one with nothing after it, `workflow.Ends`) finishes, byre checks that every last stage has a finished task in the run, and until they all do it only clears the flags. Since each stage records its result before taking `workflowMu`, two last stages finishing at once could both see the run complete, so under the lock a run that already has `task.workflow_finished` only clears the flags too. When the last of them finishes, byre starts discarding every other active task of the run, keeping each last stage's finished task (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`, and `kept`, the kept tasks' ids) on the task that finished last and clears the flags in the same transaction, leaving the kept tasks for the human to merge; each of them is then asked whether it is ready to merge. With one last stage `output` is its output; with several, each one's under a `## <stage>` heading. 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 run's cleanup when it finished), `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. +- **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, 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 run's cleanup when it finished), `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/go.mod b/go.mod index eea8ea3..c8a06f1 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require ( github.com/coder/websocket v1.8.15 github.com/creack/pty v1.1.24 github.com/getkin/kin-openapi v0.142.0 + github.com/go-git/go-git/v5 v5.19.3 github.com/oapi-codegen/runtime v1.7.0 - go.yaml.in/yaml/v3 v3.0.5 modernc.org/sqlite v1.60.1 tailscale.com v1.104.2 ) @@ -25,7 +25,6 @@ require ( github.com/gaissmai/bart v0.29.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.2 // indirect - github.com/go-git/go-git/v5 v5.19.3 // indirect github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect @@ -60,6 +59,7 @@ require ( github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect golang.org/x/crypto v0.57.0 // indirect diff --git a/go.sum b/go.sum index 7d60390..5b41bbf 100644 --- a/go.sum +++ b/go.sum @@ -227,12 +227,14 @@ github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= @@ -403,7 +405,6 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agents/kb/workflow-files.md b/internal/agents/kb/workflow-definitions.md similarity index 67% rename from internal/agents/kb/workflow-files.md rename to internal/agents/kb/workflow-definitions.md index 66eb538..10b601d 100644 --- a/internal/agents/kb/workflow-files.md +++ b/internal/agents/kb/workflow-definitions.md @@ -1,6 +1,6 @@ -# Workflow files +# Workflow definitions -A workflow's file is YAML: an optional `description`, then `steps`, which run in order. The workflow's name is not in the file. Unknown fields are refused. +A workflow's definition is an object: an optional `description`, then `steps`, which run in order. The workflow's name is not in it. `save_workflow` takes it as its `definition` argument and `read_workflow` returns it, both as JSON. Unknown fields are refused. Any workflow can run on a brief, each stage as its own task, and any workflow whose stages are single agents can be a project's review workflow (set with `set_review_workflow`), which reviews its tasks' snapshots: each stage is then one reviewer agent on its `model`, given the task's change and its stage's `prompt` (what to look for), and a stage after others also gets their reviews. A project can't review with a workflow that has a team stage (`agents`). @@ -22,26 +22,19 @@ A stage gets the outputs of the stages that end the step before it: the last sta 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. A workflow is finished once every stage has finished; when it ends with a `parallel`, each branch's last stage keeps its task to merge, and the run's other stage tasks are discarded. A review is delivered to the task's agent, every stage's findings in one message, once every stage has reviewed. An `order` or `parallel` can't be empty. -```yaml -description: Build a change, review and test it in parallel, then fix what they found. -steps: - - stage: Build - model: claude-opus-5-5 - prompt: Implement the brief. - - 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 - prompt: Fix every problem the reports list. - - name: checker - model: gpt-6.1-sol - prompt: Check each fix as the fixer lands it. - prompt: Merge the branches you are given and address both reports. +```json +{ + "description": "Build a change, review and test it in parallel, then fix what they found.", + "steps": [ + {"stage": "Build", "model": "claude-opus-5-5", "prompt": "Implement the brief."}, + {"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", "prompt": "Merge the branches you are given and address both reports.", "agents": [ + {"name": "fixer", "model": "claude-opus-5-5", "prompt": "Fix every problem the reports list."}, + {"name": "checker", "model": "gpt-6.1-sol", "prompt": "Check each fix as the fixer lands it."} + ]} + ] +} ``` diff --git a/internal/agents/review.go b/internal/agents/review.go index 70ab8d5..318d670 100644 --- a/internal/agents/review.go +++ b/internal/agents/review.go @@ -122,7 +122,7 @@ func (m *Manager) reviewsSettled(ctx context.Context, reviews []api.Review) (boo if r.State != api.ReviewStateDone { continue } - def, err := m.store.ReviewDefinition(ctx, r.Id) + _, def, err := m.store.ReviewDefinition(ctx, r.Id) if err != nil { return false, err } @@ -192,12 +192,12 @@ func (m *Manager) startReviews(ctx context.Context, task api.Task, snap api.Snap 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) + return m.store.CreateReview(ctx, api.Review{Id: store.NewID(), TaskId: task.Id, SnapshotN: snap.N, State: api.ReviewStateSkipped, CreatedAt: now, FinishedAt: &now}, "") } - def, err := m.store.GetWorkflow(ctx, project.ReviewWorkflow) + frozen, err := m.store.FreezeWorkflow(ctx, project.ReviewWorkflow) if err == nil { - err = reviewProblem(def) + err = reviewProblem(frozen.Definition) } if err != nil { return m.store.CreateReview(ctx, api.Review{ @@ -209,11 +209,11 @@ func (m *Manager) startReviews(ctx context.Context, task api.Task, snap api.Snap Error: fmt.Sprintf("project %s reviews with workflow %q, which can't run: %v", project.Name, project.ReviewWorkflow, err), CreatedAt: now, FinishedAt: &now, - }, nil) + }, "") } - for _, stage := range workflow.Starts(def) { - err = m.startReviewer(ctx, task, project, snap, def, stage, nil) + for _, stage := range workflow.Starts(frozen.Definition) { + err = m.startReviewer(ctx, task, project, snap, frozen.DefinitionID, frozen.Definition, stage, nil) if err != nil { slog.Error("start review stage", "task", task.Id, "snapshot", snap.N, "stage", stage, "err", err) } @@ -228,7 +228,7 @@ func (m *Manager) advanceReviews(ctx context.Context, r api.Review) bool { m.reviewMu.Lock() defer m.reviewMu.Unlock() - def, err := m.store.ReviewDefinition(ctx, r.Id) + defID, def, err := m.store.ReviewDefinition(ctx, r.Id) if err != nil { slog.Error("load review definition", "review", r.Id, "err", err) return false @@ -274,7 +274,7 @@ func (m *Manager) advanceReviews(ctx context.Context, r api.Review) bool { if len(inputs) != len(workflow.After(def, next)) { continue } - err = m.startReviewer(ctx, task, project, snap, def, next, inputs) + err = m.startReviewer(ctx, task, project, snap, defID, def, next, inputs) if err != nil { slog.Error("start review stage", "task", task.Id, "snapshot", r.SnapshotN, "stage", next, "err", err) continue @@ -284,7 +284,7 @@ func (m *Manager) advanceReviews(ctx context.Context, r api.Review) bool { 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) { +func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, defID string, def api.WorkflowDefinition, stage string, inputs []api.Review) (err error) { model := workflow.Get(def, stage).Model vendor := workflow.VendorOf(model) reviewID := store.NewID() @@ -305,7 +305,7 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api. Error: "the reviewer didn't start: " + err.Error(), CreatedAt: now, FinishedAt: &now, - }, &def) + }, defID) if rerr != nil { slog.Error("record review that didn't start", "task", task.Id, "stage", stage, "err", rerr) return @@ -356,7 +356,7 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api. Model: model, State: api.ReviewStateRunning, CreatedAt: time.Now().UTC(), - }, &def) + }, defID) if err != nil { m.endReviewer(ctx, sess.Id, project, reviewID) return err diff --git a/internal/agents/workflows.go b/internal/agents/workflows.go index cf8a437..863ab4f 100644 --- a/internal/agents/workflows.go +++ b/internal/agents/workflows.go @@ -15,12 +15,12 @@ import ( "g.fc.run/ai/byre/internal/workflow" ) -func (m *Manager) SaveWorkflow(ctx context.Context, name, from string, file []byte, actor string) (api.WorkflowSummary, error) { +func (m *Manager) SaveWorkflow(ctx context.Context, name, from string, definition []byte, actor string) (api.WorkflowSummary, error) { name, err := workflow.CleanName(name) if err != nil { return api.WorkflowSummary{}, err } - def, err := workflow.Parse(file) + def, err := workflow.Parse(definition) if err != nil { return api.WorkflowSummary{}, err } @@ -58,24 +58,12 @@ func reviewProblem(def api.WorkflowDefinition) error { return nil } -func (m *Manager) WorkflowFile(ctx context.Context, name string) ([]byte, error) { - def, err := m.store.GetWorkflow(ctx, name) - if err != nil { - return nil, err - } - return workflow.Format(def) -} - func (m *Manager) TaskWorkflow(ctx context.Context, taskID string) (api.TaskWorkflow, error) { frozen, err := m.store.TaskWorkflow(ctx, taskID) if err != nil { return api.TaskWorkflow{}, err } - file, err := workflow.Format(frozen.Definition) - if err != nil { - return api.TaskWorkflow{}, err - } - return api.TaskWorkflow{Name: frozen.Name, Definition: string(file)}, nil + return api.TaskWorkflow{Name: frozen.Name, Definition: frozen.Definition}, nil } func (m *Manager) WorkflowRun(ctx context.Context, taskID string) (api.WorkflowRun, error) { @@ -719,7 +707,48 @@ func stageGuidance(frozen store.FrozenWorkflow, stage string) string { "%s. Until you call it, the workflow waits on this stage.", stage, frozen.Name, then) } -const workflowFileHelp = "The file's YAML format is the read_knowledge article workflow-files." +const workflowHelp = "The definition's fields are the read_knowledge article workflow-definitions." + +const workflowDefinitionSchema = `{ + "type": "object", + "properties": { + "description": {"type": "string"}, + "steps": {"type": "array", "items": {"$ref": "#/$defs/step"}} + }, + "required": ["steps"], + "additionalProperties": false +}` + +const workflowSchemaDefs = `"$defs": { + "step": { + "type": "object", + "description": "Exactly one of stage (with model or agents, and prompt), order or parallel.", + "properties": { + "stage": {"type": "string"}, + "model": {"type": "string"}, + "prompt": {"type": "string"}, + "agents": {"type": "array", "items": {"$ref": "#/$defs/agent"}}, + "order": {"type": "array", "items": {"$ref": "#/$defs/step"}}, + "parallel": {"type": "array", "items": {"$ref": "#/$defs/step"}} + }, + "additionalProperties": false + }, + "agent": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "model": {"type": "string"}, + "prompt": {"type": "string"} + }, + "required": ["name", "model", "prompt"], + "additionalProperties": false + } +}` + +func formatDefinition(def api.WorkflowDefinition) (string, error) { + b, err := json.MarshalIndent(def, "", " ") + return string(b), err +} func (m *Manager) workflowTools(tool func(name, description, schema string, run func(ctx context.Context, user api.User, raw json.RawMessage) (string, error)) mcp.Tool) []mcp.Tool { return []mcp.Tool{ @@ -746,7 +775,7 @@ func (m *Manager) workflowTools(tool func(name, description, schema string, run }), tool("read_workflow", - "Read a workflow's file: with name, the workflow on the server; with task_id, the copy that task froze when it was created. "+workflowFileHelp, + "Read a workflow's definition: with name, the workflow on the server; with task_id, the copy that task froze when it was created. "+workflowHelp, `{"type": "object", "properties": { "name": {"type": "string"}, "task_id": {"type": "string"} @@ -764,33 +793,40 @@ func (m *Manager) workflowTools(tool func(name, description, schema string, run return "", fmt.Errorf("%w: give exactly one of name and task_id", ErrInvalid) } if args.TaskID != "" { - tw, err := m.TaskWorkflow(ctx, args.TaskID) + frozen, err := m.store.TaskWorkflow(ctx, args.TaskID) if err != nil { return "", err } - return fmt.Sprintf("Task %s froze workflow %q:\n\n%s", args.TaskID, tw.Name, tw.Definition), nil + text, err := formatDefinition(frozen.Definition) + if err != nil { + return "", err + } + return fmt.Sprintf("Task %s froze workflow %q:\n\n%s", args.TaskID, frozen.Name, text), nil } - file, err := m.WorkflowFile(ctx, args.Name) - return string(file), err + def, err := m.store.GetWorkflow(ctx, args.Name) + if err != nil { + return "", err + } + return formatDefinition(def) }), tool("save_workflow", - "Create or replace a workflow on the server from its file. Tasks that already froze it keep their copy. "+ - "The name is one line of 1 to 100 characters, shown to the human on buttons and pickers. "+workflowFileHelp, + "Create or replace a workflow on the server from its definition. Tasks that already froze it keep their copy. "+ + "The name is one line of 1 to 100 characters, shown to the human on buttons and pickers. "+workflowHelp, `{"type": "object", "properties": { "name": {"type": "string"}, - "definition": {"type": "string", "description": "The workflow's file, as YAML."} - }, "required": ["name", "definition"]}`, + "definition": `+workflowDefinitionSchema+` + }, "required": ["name", "definition"], `+workflowSchemaDefs+`}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { args := struct { - Name string `json:"name"` - Definition string `json:"definition"` + Name string `json:"name"` + Definition json.RawMessage `json:"definition"` }{} err := json.Unmarshal(raw, &args) if err != nil { return "", err } - w, err := m.SaveWorkflow(ctx, args.Name, "", []byte(args.Definition), leadActor(user.Login)) + w, err := m.SaveWorkflow(ctx, args.Name, "", args.Definition, leadActor(user.Login)) if err != nil { return "", err } diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 809e0f1..89f29f8 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -1404,9 +1404,9 @@ type TaskTab string // TaskWorkflow The workflow a task froze when it was created, as it was then. type TaskWorkflow struct { - // Definition The WorkflowDefinition as YAML. - Definition string `json:"definition"` - Name string `json:"name"` + // Definition A workflow's definition, which holds everything but its name: its steps, run in order. Run on a brief, each stage runs as its own task once the steps before it have finished; as a project's review workflow, each stage is a reviewer of a snapshot and gets the earlier stages' reviews. + Definition WorkflowDefinition `json:"definition"` + Name string `json:"name"` } // TeamMember defines model for TeamMember. @@ -1539,7 +1539,7 @@ type UserUpdate struct { GitName string `json:"git_name"` } -// WorkflowDefinition A workflow's file, which holds everything but its name: its steps, run in order. Run on a brief, each stage runs as its own task once the steps before it have finished; as a project's review workflow, each stage is a reviewer of a snapshot and gets the earlier stages' reviews. Sent and returned as YAML. +// WorkflowDefinition A workflow's definition, which holds everything but its name: its steps, run in order. Run on a brief, each stage runs as its own task once the steps before it have finished; as a project's review workflow, each stage is a reviewer of a snapshot and gets the earlier stages' reviews. type WorkflowDefinition struct { Description string `json:"description,omitempty"` Steps []WorkflowStep `json:"steps"` @@ -1549,7 +1549,7 @@ type WorkflowDefinition struct { type WorkflowRun struct { Brief string `json:"brief"` - // Definition A workflow's file, which holds everything but its name: its steps, run in order. Run on a brief, each stage runs as its own task once the steps before it have finished; as a project's review workflow, each stage is a reviewer of a snapshot and gets the earlier stages' reviews. Sent and returned as YAML. + // Definition A workflow's definition, which holds everything but its name: its steps, run in order. Run on a brief, each stage runs as its own task once the steps before it have finished; as a project's review workflow, each stage is a reviewer of a snapshot and gets the earlier stages' reviews. Definition WorkflowDefinition `json:"definition"` // Finished Whether the final stage finished, which ends the run. @@ -4230,8 +4230,8 @@ 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{ - "7L1rcxs5lij4VxDcjbA9k6Jc09V3d6TYD267Hp4tV3kt11bc7a7gQEyQRCsJZAFIySqH97ffOA8gkUkk", - "ScmWyz09X2wqE4nHwcHBeZ/3s6XdttYoE/zs7P3MKd9a4xX+8Y1z1sGPpTVBmQA/Zds2eimDtub0794a", + "7L1rcxs5lij4VxDcjbA9k6Jc09V3d6TYD267Hr5brvJarq2I7a7gQEyQRCsJZAFIySqH97ffOA8gkUkk", + "ScmWy909X2wqE4nHwcHBeZ/3s6XdttYoE/zs7P3MKd9a4xX+8Y1z1sGPpTVBmQA/Zds2eimDtub0794a", "eOaXG7WV8Ot/d2o1O5v9b6d9r6f01p9Sbx8+fKhmtfJLp1voZHZGw8xn8ILbQlfP1sqE184ulce/W2db", "5YKmqW2UbMIGfg378qFbXomtksYLKULnjNBeuM4YbdZCmloYK3xwSm6FulYmiI30Qjqnr1UtVtYJ7GEh", "V0G5hVdLa2p/LtQ7HVTN/YaNEi1NDDpfW6Pms2qmTLednf11Zq9mFc0DHuKHs1+rWbhtFU7QabOeVbN3", @@ -4246,7 +4246,7 @@ var swaggerSpec = []string{ "cbOxXuEiaeG0v9rHiQIIZIScj3SiMJKzjdodKMLsTEiGX9+NeOyVEm/w6ZPznVG8aLQPMLVrRDvllbBd", "yEm/3raN2ioTlANyoGQ9q9KQu5dATv1SF3Wj4CO6sWYV3s+NOv4SySnLBfT+knrcef4mDbHz6nk/Jsyx", "u8St8Lvg/N7eiK00t5FCRCxPnwjpVLx+K3Epl1drZztTA2Z7oc2y6WpVC7xusQekFWEj040DT7d4SytT", - "wwVUFe6YjNaPeIsMaRCp9qFMfysMO/nWNo298QKoE04SERx/RdS5kV4wBcaTeybWbTj5F2qJDAgcbaR/", + "wwVUFe6YjNaPeIsMaRCp9qFMfysMO/nWNo298QKoE04SERx/RdS5kV4wBcaTeybWbTj5N2qJDAgcbaR/", "lVirrTa6fwvXngl67eS1DrcVMCbulukQDWVNTlNzlFviY8STWr2bVTPZ9zT7dXKRi+GtPFxvNlR2OnRd", "iSWcTiMub8XlrVPnwtKm4bpE2DiAMNwXLl+QWFpzrZyXIXZDR7kzQQ+hmNH3EvkYXc6jS5lIS3Y5EwUs", "rbi/spFKJMo9uEWr/BrOT0HxPg9BLjdbZpJHt7lkwBauDGXqBfBjZdZJb+W6QMTw8ZmoZZDi5zc/FHH5", @@ -4261,7 +4261,7 @@ var swaggerSpec = []string{ "89ba5g32OJ/tYPXuEdyH+bgTBcEUBEwWh1gKQkZuq9wayLy/8kIC1hu1DKoWr+QSyHxtK4HSnL1WTpzK", "Vp8ucSB/ym1hwiOK6NbdNvK4HwOd1FEBKMjMb6Wpp5AK2ESU+373GyFhcdnexutuY7dK1NqpZbDuthK6", "l8Ciugd47LaIAmVygoi2S09AVo405Vy00gFQ48haEXPPxDNH7c7Ab1ULWK4OgtYPiK2XG2wBOwVtPC2X", - "VVVwDnAtcO0urVnpNcrLHhl4fNGu50S7xGPbKtOuW0CJd39++u9PKrGUSBUkdyCbxvOdf5De1UUE7NrG", + "VVVwDnAtcO0urVnpNcrLHhl4fNGu50S7xGPbKtOuW0CJd39++p9PKrGUSBUkdyCbxvOdf5De1UUE7NrG", "yvqMTyQKOG650dcAHGuUUCa4W6FM7UXXDvkOvPLhfOMC///Tc3GzkQE4e6FRenQKfjjVNnKp6ruoGXSt", "TGGyPfwJ7AFJkRd/m/0ot0r8rXv69E9LtZW6wZ/qb7MKTgt8Bs0SWX8ll48GWwCyl7Gi88rNmWpdqdsy", "zWxznhOQelYxFGfVrLY3hn+mC2xWZdg3q+hEzSpcTZH9LHOOsetKpJ4rUUJr8di6CvGp/wSfW5dQ+/YJ", @@ -4289,7 +4289,7 @@ var swaggerSpec = []string{ "9lvtvarJ7NKoVQBLS8S4V8qtlbiRDpSjOYOKAxUZweNggh/nIGGfnHx5P7c1L3KIB8m3aN8tM4DTzlWI", "T4vgBV6ggPyD2zpzqhsoI3aJEH9HdFqvN0G00ijhN/bGn+fbExuSxoOudGIwQtkH9PIQAN5Kf/VWXu71", "InrLE5iY3846D9F7mFYJqi/NpX33MqiiDyTqEDSwaoaxVPorVWdaBVRIwxrwz4Iu8qF4zomuMr5uuBg0", - "Kr0L4vEr6a5ALdybgZYbq5foGoZuytKL/zwRj8UTvJD+U5zin/8S/2z18urEGhQh//NE/FX8mrf767tf", + "Kr0L4vEr6a5ALdybgZYbq5foGoZuytKL/zoRj8UTvJD+S5zin/8W/2z18urEGhQh/+tE/FX8mrf767tf", "83bgp4YufMh+PakSRCthcWZePFZyueE5oGfmu4AGNutq5Z5UYts1QbdgRQiuI1/X2PWTSjgFahB0UxOP", "YTFb6WCHeE2PsL8nlZDJWcjP/2ZKdsIDXK9T3jbXd9yk9NGEK8XA9f8oDhXRNd1DBU71KAqEvTD9uQOb", "jlqoDE79pwXhKmfgo+vXwLurX/zkuaSF1qX79qMAxwpxYOy1eUmffzWG5cQdeHC6Jf4gQ7+jZ5z5txX2", @@ -4358,7 +4358,7 @@ var swaggerSpec = []string{ "BFBjuRqcpXNfhB5NhKLOpokIgwdd+2KFMo4VMf3+RGtYSgPDFEebddpRa5YxsA66xNPiRSxlaAAZtBcb", "1dyBMNEakhKmgGzwKmHyAKC0sq28UrFSACImftCzG9HlK/i0YHYRCYts9EkIH6AfPXhz6sF3Xa8v1MGP", "i2ZW0QyHz4lvhW+j2oQWiw3bDn+wFt36aIvC2KZJJAUGHmj7gszG+1bBl+yKberdJeMEu5BSByMkijSM", - "X/KS+ZrSYVqnTh8sDicFIv6dNpCJfU93ou6a5nonsgPC+AIu7SkdtfMDXiIGFMCzFjOSP05dwBFNf/wr", + "X/KS+ZrSYVqnTh8sDicFIv6dNpCJfU93ou6a5nonsgPC+AIu7SkdtfMDXiIGFMCzFjOSP05dwBFNf/w7", "FEtoJfp4Meiit1stgnJb1MlKL17/9ObtXn3KwaKeu9O25AoZ1OBmAc73SrUBtBkBFe0oMrNtXm45Xzyl", "X4apkTs6uQ7RIsRjYirn1DvJVuRx4QXn9YHWT+aiB1+vYTsrsS5ETOhWPRf9VX0O/CHK3lEMQknBoJdb", "4g/PI21KUW+vIpcYcXmMu9pzpcobeUufqwWQqjSQT0eZTc+JXMQOcwIZuxjcOMPJ7N5IfdfwPz0877UN", @@ -4377,164 +4377,162 @@ var swaggerSpec = []string{ "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", - "8KkO4Oerw7FD3CcU5AEDq0aM1xejovvw4X8NAA==", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/workflows.go b/internal/server/workflows.go index c418185..dc1d45d 100644 --- a/internal/server/workflows.go +++ b/internal/server/workflows.go @@ -4,14 +4,11 @@ import ( "fmt" "io" "net/http" - "net/url" - "strings" "g.fc.run/ai/byre/internal/api" - "g.fc.run/ai/byre/internal/workflow" ) -const maxWorkflowFileBytes = 1 << 20 +const maxWorkflowBytes = 1 << 20 func (s *Server) ListWorkflows(w http.ResponseWriter, r *http.Request) { workflows, err := s.store.ListWorkflows(r.Context()) @@ -23,38 +20,25 @@ 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) + def, err := s.store.GetWorkflow(r.Context(), name) if err != nil { - writeAgentError(w, err) + writeStoreError(w, err) return } - w.Header().Set("Content-Type", "application/yaml") - w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(workflow.Filename(name))) - w.WriteHeader(http.StatusOK) - w.Write(file) + writeJSON(w, http.StatusOK, def) } func (s *Server) PutWorkflow(w http.ResponseWriter, r *http.Request, name string, params api.PutWorkflowParams) { - file, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWorkflowFileBytes)) + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWorkflowBytes)) if err != nil { - writeError(w, http.StatusBadRequest, fmt.Errorf("read workflow file: %w", err)) + writeError(w, http.StatusBadRequest, fmt.Errorf("read workflow: %w", err)) return } from := "" if params.From != nil { from = *params.From } - summary, err := s.agents.SaveWorkflow(r.Context(), name, from, file, actor(r)) + summary, err := s.agents.SaveWorkflow(r.Context(), name, from, body, actor(r)) if err != nil { writeAgentError(w, err) return diff --git a/internal/store/definition.go b/internal/store/definition.go new file mode 100644 index 0000000..93e7a3f --- /dev/null +++ b/internal/store/definition.go @@ -0,0 +1,134 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "g.fc.run/ai/byre/internal/api" +) + +func insertDefinition(ctx context.Context, tx *sql.Tx, def api.WorkflowDefinition) (string, error) { + id := NewID() + _, err := tx.ExecContext(ctx, `INSERT INTO workflow_definitions (id, description) VALUES (?, ?)`, id, def.Description) + if err != nil { + return "", err + } + next := 0 + var insert func(steps []api.WorkflowStep, parent *int) error + insert = func(steps []api.WorkflowStep, parent *int) error { + for pos, s := range steps { + stepID := next + next++ + kind, children := "stage", (*[]api.WorkflowStep)(nil) + switch { + case s.Order != nil: + kind, children = "order", s.Order + case s.Parallel != nil: + kind, children = "parallel", s.Parallel + } + _, err := tx.ExecContext(ctx, `INSERT INTO workflow_steps (definition_id, id, parent, position, kind, stage, model, prompt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, stepID, parent, pos, kind, s.Stage, s.Model, s.Prompt) + if err != nil { + return err + } + if s.Agents != nil { + for i, a := range *s.Agents { + _, err = tx.ExecContext(ctx, `INSERT INTO workflow_agents (definition_id, step_id, position, name, model, prompt) VALUES (?, ?, ?, ?, ?, ?)`, + id, stepID, i, a.Name, a.Model, a.Prompt) + if err != nil { + return err + } + } + } + if children != nil { + err = insert(*children, &stepID) + if err != nil { + return err + } + } + } + return nil + } + return id, insert(def.Steps, nil) +} + +type stepRow struct { + id int + kind string + step api.WorkflowStep +} + +func loadDefinition(ctx context.Context, q querier, id string) (api.WorkflowDefinition, error) { + def := api.WorkflowDefinition{} + err := q.QueryRowContext(ctx, `SELECT description FROM workflow_definitions WHERE id = ?`, id).Scan(&def.Description) + if errors.Is(err, sql.ErrNoRows) { + return def, fmt.Errorf("%w: no workflow definition %s", ErrNotFound, id) + } + if err != nil { + return def, err + } + + agents := map[int][]api.TeamMember{} + rows, err := q.QueryContext(ctx, `SELECT step_id, name, model, prompt FROM workflow_agents WHERE definition_id = ? ORDER BY step_id, position`, id) + if err != nil { + return def, err + } + for rows.Next() { + step := 0 + a := api.TeamMember{} + err = rows.Scan(&step, &a.Name, &a.Model, &a.Prompt) + if err != nil { + rows.Close() + return def, err + } + agents[step] = append(agents[step], a) + } + err = errors.Join(rows.Err(), rows.Close()) + if err != nil { + return def, err + } + + children := map[int][]stepRow{} + rows, err = q.QueryContext(ctx, `SELECT id, coalesce(parent, -1), kind, stage, model, prompt FROM workflow_steps WHERE definition_id = ? ORDER BY coalesce(parent, -1), position`, id) + if err != nil { + return def, err + } + for rows.Next() { + r := stepRow{} + parent := 0 + err = rows.Scan(&r.id, &parent, &r.kind, &r.step.Stage, &r.step.Model, &r.step.Prompt) + if err != nil { + rows.Close() + return def, err + } + if a, found := agents[r.id]; found { + r.step.Agents = &a + } + children[parent] = append(children[parent], r) + } + err = errors.Join(rows.Err(), rows.Close()) + if err != nil { + return def, err + } + + var build func(parent int) []api.WorkflowStep + build = func(parent int) []api.WorkflowStep { + steps := []api.WorkflowStep{} + for _, r := range children[parent] { + switch r.kind { + case "order": + sub := build(r.id) + r.step.Order = &sub + case "parallel": + sub := build(r.id) + r.step.Parallel = &sub + } + steps = append(steps, r.step) + } + return steps + } + def.Steps = build(-1) + return def, nil +} diff --git a/internal/store/migratedefinitions.go b/internal/store/migratedefinitions.go new file mode 100644 index 0000000..0293249 --- /dev/null +++ b/internal/store/migratedefinitions.go @@ -0,0 +1,135 @@ +package store + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + + "g.fc.run/ai/byre/internal/api" +) + +func migrateWorkflowDefinitions(tx *sql.Tx) error { + _, err := tx.Exec(` + CREATE TABLE workflow_definitions ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL + ); + CREATE TABLE workflow_steps ( + definition_id TEXT NOT NULL REFERENCES workflow_definitions(id), + id INTEGER NOT NULL, + parent INTEGER, + position INTEGER NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('stage', 'order', 'parallel')), + stage TEXT NOT NULL, + model TEXT NOT NULL, + prompt TEXT NOT NULL, + PRIMARY KEY (definition_id, id), + UNIQUE (definition_id, parent, position) + ); + CREATE TABLE workflow_agents ( + definition_id TEXT NOT NULL REFERENCES workflow_definitions(id), + step_id INTEGER NOT NULL, + position INTEGER NOT NULL, + name TEXT NOT NULL, + model TEXT NOT NULL, + prompt TEXT NOT NULL, + PRIMARY KEY (definition_id, step_id, position), + FOREIGN KEY (definition_id, step_id) REFERENCES workflow_steps(definition_id, id) + ); + ALTER TABLE tasks ADD COLUMN workflow_definition_id TEXT REFERENCES workflow_definitions(id); + ALTER TABLE reviews ADD COLUMN definition_id TEXT REFERENCES workflow_definitions(id); + ALTER TABLE workflows ADD COLUMN definition_id TEXT REFERENCES workflow_definitions(id); + `) + if err != nil { + return err + } + + ids := map[string]string{} + for _, c := range []struct{ table, key, from, to string }{ + {"workflows", "name", "definition", "definition_id"}, + {"tasks", "id", "workflow_definition", "workflow_definition_id"}, + {"reviews", "id", "definition", "definition_id"}, + } { + err = moveDefinitions(tx, ids, c.table, c.key, c.from, c.to) + if err != nil { + return err + } + } + + _, err = tx.Exec(` + CREATE TABLE workflows_next ( + name TEXT PRIMARY KEY, + definition_id TEXT NOT NULL REFERENCES workflow_definitions(id) + ); + INSERT INTO workflows_next (name, definition_id) SELECT name, definition_id FROM workflows; + DROP TABLE workflows; + ALTER TABLE workflows_next RENAME TO workflows; + ALTER TABLE tasks DROP COLUMN workflow_definition; + ALTER TABLE reviews DROP COLUMN definition; + `) + return err +} + +func moveDefinitions(tx *sql.Tx, ids map[string]string, table, key, from, to string) error { + rows, err := tx.Query(fmt.Sprintf(`SELECT %s, %s FROM %s WHERE %s != ''`, key, from, table, from)) + 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 + } + + ctx := context.Background() + for _, r := range all { + id, found := ids[r.def] + if !found { + def := api.WorkflowDefinition{} + dec := json.NewDecoder(bytes.NewReader([]byte(r.def))) + dec.DisallowUnknownFields() + err = dec.Decode(&def) + if err != nil { + return fmt.Errorf("%s %s: %w", table, r.key, err) + } + id, err = insertDefinition(ctx, tx, def) + if err != nil { + return fmt.Errorf("%s %s: %w", table, r.key, err) + } + back, err := loadDefinition(ctx, tx, id) + if err != nil { + return fmt.Errorf("%s %s: %w", table, r.key, err) + } + want, err := json.Marshal(def) + if err != nil { + return err + } + got, err := json.Marshal(back) + if err != nil { + return err + } + if !bytes.Equal(want, got) { + return fmt.Errorf("%s %s: definition reads back as %s, not %s", table, r.key, got, want) + } + ids[r.def] = id + } + _, err = tx.Exec(fmt.Sprintf(`UPDATE %s SET %s = ? WHERE %s = ?`, table, to, key), id, r.key) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/store/review.go b/internal/store/review.go index 5d6b686..b4a868c 100644 --- a/internal/store/review.go +++ b/internal/store/review.go @@ -12,24 +12,16 @@ import ( 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, def *api.WorkflowDefinition) error { +func (s *Store) CreateReview(ctx context.Context, r api.Review, definitionID string) 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, 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) + INSERT INTO reviews (id, task_id, snapshot_n, stage, definition_id, agent_session_id, vendor, model, state, error, created_at, finished_at) + VALUES (?, ?, ?, ?, nullif(?, ''), nullif(?, ''), ?, ?, ?, ?, ?, ?) + `, r.Id, r.TaskId, r.SnapshotN, r.Stage, definitionID, r.AgentSessionId, r.Vendor, r.Model, string(r.State), r.Error, formatTime(r.CreatedAt), finishedAt) if err != nil { return err } @@ -40,21 +32,17 @@ func (s *Store) CreateReview(ctx context.Context, r api.Review, def *api.Workflo }) } -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) +func (s *Store) ReviewDefinition(ctx context.Context, id string) (string, api.WorkflowDefinition, error) { + defID := sql.NullString{} + err := s.db.QueryRowContext(ctx, `SELECT definition_id FROM reviews WHERE id = ?`, id).Scan(&defID) if errors.Is(err, sql.ErrNoRows) { - return api.WorkflowDefinition{}, ErrNotFound + return "", api.WorkflowDefinition{}, ErrNotFound } - if err != nil { - return api.WorkflowDefinition{}, err + if err != nil || !defID.Valid { + return "", api.WorkflowDefinition{}, err } - def := api.WorkflowDefinition{} - if text == "" { - return def, nil - } - err = json.Unmarshal([]byte(text), &def) - return def, err + def, err := loadDefinition(ctx, s.db, defID.String) + return defID.String, def, err } func (s *Store) GetReview(ctx context.Context, id string) (api.Review, error) { @@ -92,7 +80,7 @@ func (s *Store) RunningReviews(ctx context.Context) ([]api.Review, error) { 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 != '' + WHERE t.status = ? AND r.state = ? AND r.definition_id IS NOT NULL 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 { diff --git a/internal/store/store.go b/internal/store/store.go index cd9e6bc..61b7a18 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -33,7 +33,8 @@ type migration struct { } var goMigrations = map[string]func(*sql.Tx) error{ - "071-workflow-steps": migrateWorkflowSteps, + "071-workflow-steps": migrateWorkflowSteps, + "078-normalize-workflow-definitions": migrateWorkflowDefinitions, } func loadMigrations() ([]migration, error) { @@ -337,18 +338,14 @@ func (s *Store) GetProject(ctx context.Context, id string) (api.Project, error) func (s *Store) CreateTask(ctx context.Context, t api.Task, frozen *FrozenWorkflow, actor string) error { workflowName := "" - workflowDefinition := "" + var workflowDefinitionID *string workflowBrief := "" workflowTitle := "" workflowRun := "" workflowStage := "" if frozen != nil { - b, err := json.Marshal(frozen.Definition) - if err != nil { - return err - } workflowName = frozen.Name - workflowDefinition = string(b) + workflowDefinitionID = &frozen.DefinitionID workflowBrief = frozen.Brief workflowTitle = frozen.Title workflowRun = *t.WorkflowRun @@ -411,9 +408,9 @@ func (s *Store) CreateTask(ctx context.Context, t api.Task, frozen *FrozenWorkfl return err } _, err = tx.ExecContext(ctx, ` - INSERT INTO tasks (id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, todo_item, forked_from, forked_at, workflow_name, workflow_definition, workflow_brief, workflow_title, workflow_run, workflow_stage, parent_task_id, audit) + INSERT INTO tasks (id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, todo_item, forked_from, forked_at, workflow_name, workflow_definition_id, workflow_brief, workflow_title, workflow_run, workflow_stage, parent_task_id, audit) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, t.Id, t.ProjectId, t.Title, mergeTitle, t.Branch, t.WorktreePath, t.BaseRef, t.PortBase, t.Color, string(t.Status), t.CreatedBy, formatTime(t.CreatedAt), todoItem, forkedFrom, forkedAt, workflowName, workflowDefinition, workflowBrief, workflowTitle, workflowRun, workflowStage, parentTaskID, audit) + `, t.Id, t.ProjectId, t.Title, mergeTitle, t.Branch, t.WorktreePath, t.BaseRef, t.PortBase, t.Color, string(t.Status), t.CreatedBy, formatTime(t.CreatedAt), todoItem, forkedFrom, forkedAt, workflowName, workflowDefinitionID, workflowBrief, workflowTitle, workflowRun, workflowStage, parentTaskID, audit) if err != nil { return err } diff --git a/internal/store/workflow.go b/internal/store/workflow.go index 723a2b2..012eb37 100644 --- a/internal/store/workflow.go +++ b/internal/store/workflow.go @@ -3,7 +3,6 @@ package store import ( "context" "database/sql" - "encoding/json" "errors" "fmt" @@ -11,15 +10,16 @@ import ( ) type FrozenWorkflow struct { - Name string - Definition api.WorkflowDefinition - Brief string - Title string - Audit string + Name string + DefinitionID string + Definition api.WorkflowDefinition + Brief string + Title string + Audit string } 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 w.name, d.description FROM workflows w JOIN workflow_definitions d ON d.id = w.definition_id ORDER BY w.name`) if err != nil { return nil, err } @@ -38,26 +38,13 @@ func (s *Store) ListWorkflows(ctx context.Context) ([]api.WorkflowSummary, error } func (s *Store) GetWorkflow(ctx context.Context, name string) (api.WorkflowDefinition, error) { - def := "" - err := s.db.QueryRowContext(ctx, `SELECT definition FROM workflows WHERE name = ?`, name).Scan(&def) - if errors.Is(err, sql.ErrNoRows) { - return api.WorkflowDefinition{}, fmt.Errorf("%w: no workflow %q", ErrNotFound, name) - } - if err != nil { - return api.WorkflowDefinition{}, err - } - w := api.WorkflowDefinition{} - err = json.Unmarshal([]byte(def), &w) - return w, err + f, err := s.FreezeWorkflow(ctx, name) + return f.Definition, err } var ErrWorkflowExists = errors.New("workflow exists") func (s *Store) PutWorkflow(ctx context.Context, name, from string, def api.WorkflowDefinition, actor string) error { - b, err := json.Marshal(def) - if err != nil { - return err - } return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { if from != "" { found := false @@ -91,8 +78,12 @@ func (s *Store) PutWorkflow(ctx context.Context, name, from string, def api.Work return err } } - _, err := tx.ExecContext(ctx, `INSERT INTO workflows (name, definition) VALUES (?, ?) - ON CONFLICT (name) DO UPDATE SET definition = excluded.definition`, name, string(b)) + defID, err := insertDefinition(ctx, tx, def) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO workflows (name, definition_id) VALUES (?, ?) + ON CONFLICT (name) DO UPDATE SET definition_id = excluded.definition_id`, name, defID) if err != nil { return err } @@ -154,27 +145,33 @@ func (s *Store) DeleteWorkflow(ctx context.Context, name, actor string) error { } func (s *Store) FreezeWorkflow(ctx context.Context, name string) (FrozenWorkflow, error) { - def, err := s.GetWorkflow(ctx, name) + f := FrozenWorkflow{Name: name} + err := s.db.QueryRowContext(ctx, `SELECT definition_id FROM workflows WHERE name = ?`, name).Scan(&f.DefinitionID) + if errors.Is(err, sql.ErrNoRows) { + return FrozenWorkflow{}, fmt.Errorf("%w: no workflow %q", ErrNotFound, name) + } if err != nil { return FrozenWorkflow{}, err } - return FrozenWorkflow{Name: name, Definition: def}, nil + f.Definition, err = loadDefinition(ctx, s.db, f.DefinitionID) + return f, err } func (s *Store) TaskWorkflow(ctx context.Context, taskID string) (FrozenWorkflow, error) { f := FrozenWorkflow{} - def := "" - err := s.db.QueryRowContext(ctx, `SELECT workflow_name, workflow_definition, workflow_brief, workflow_title, audit FROM tasks WHERE id = ?`, taskID).Scan(&f.Name, &def, &f.Brief, &f.Title, &f.Audit) + defID := sql.NullString{} + err := s.db.QueryRowContext(ctx, `SELECT workflow_name, workflow_definition_id, workflow_brief, workflow_title, audit FROM tasks WHERE id = ?`, taskID).Scan(&f.Name, &defID, &f.Brief, &f.Title, &f.Audit) if errors.Is(err, sql.ErrNoRows) { return FrozenWorkflow{}, fmt.Errorf("%w: no task %s", ErrNotFound, taskID) } if err != nil { return FrozenWorkflow{}, err } - if f.Name == "" { + if !defID.Valid { return FrozenWorkflow{}, fmt.Errorf("%w: task %s has no workflow", ErrNotFound, taskID) } - err = json.Unmarshal([]byte(def), &f.Definition) + f.DefinitionID = defID.String + f.Definition, err = loadDefinition(ctx, s.db, f.DefinitionID) return f, err } diff --git a/internal/workflow/file.go b/internal/workflow/file.go deleted file mode 100644 index df813ea..0000000 --- a/internal/workflow/file.go +++ /dev/null @@ -1,107 +0,0 @@ -package workflow - -import ( - "bytes" - "encoding/json" - "fmt" - "slices" - "strings" - - "go.yaml.in/yaml/v3" - - "g.fc.run/ai/byre/internal/api" -) - -var keyOrder = []string{"description", "kind", "steps", "stage", "name", "model", "prompt", "agents", "order", "parallel"} - -func Parse(data []byte) (api.WorkflowDefinition, error) { - raw := any(nil) - err := yaml.Unmarshal(data, &raw) - if err != nil { - return api.WorkflowDefinition{}, fmt.Errorf("%w: %v", ErrInvalid, err) - } - j, err := json.Marshal(raw) - if err != nil { - return api.WorkflowDefinition{}, fmt.Errorf("%w: %v", ErrInvalid, err) - } - - def := api.WorkflowDefinition{} - dec := json.NewDecoder(bytes.NewReader(j)) - dec.DisallowUnknownFields() - err = dec.Decode(&def) - if err != nil { - return api.WorkflowDefinition{}, fmt.Errorf("%w: %v", ErrInvalid, err) - } - return def, Validate(def) -} - -func Format(def api.WorkflowDefinition) ([]byte, error) { - j, err := json.Marshal(def) - if err != nil { - return nil, err - } - doc := yaml.Node{} - err = yaml.Unmarshal(j, &doc) - if err != nil { - return nil, err - } - restyle(&doc) - - out := &bytes.Buffer{} - enc := yaml.NewEncoder(out) - enc.SetIndent(2) - err = enc.Encode(&doc) - if err != nil { - return nil, err - } - err = enc.Close() - if err != nil { - return nil, err - } - return out.Bytes(), nil -} - -func Filename(name string) string { - return strings.Map(func(r rune) rune { - if strings.ContainsRune(`/\:*?"<>|`, r) { - return '_' - } - return r - }, name) + ".yaml" -} - -func restyle(n *yaml.Node) { - switch n.Kind { - case yaml.ScalarNode: - n.Style = 0 - if n.Tag == "!!str" && strings.Contains(n.Value, "\n") { - n.Style = yaml.LiteralStyle - } - case yaml.MappingNode: - n.Style = 0 - pairs := [][2]*yaml.Node{} - for i := 0; i+1 < len(n.Content); i += 2 { - pairs = append(pairs, [2]*yaml.Node{n.Content[i], n.Content[i+1]}) - } - slices.SortStableFunc(pairs, func(a, b [2]*yaml.Node) int { - return keyRank(a[0].Value) - keyRank(b[0].Value) - }) - n.Content = n.Content[:0] - for _, p := range pairs { - n.Content = append(n.Content, p[0], p[1]) - } - case yaml.SequenceNode: - n.Style = 0 - } - for _, c := range n.Content { - restyle(c) - } -} - -func keyRank(key string) int { - i := slices.Index(keyOrder, key) - if i < 0 { - return len(keyOrder) - } - return i -} diff --git a/internal/workflow/parse.go b/internal/workflow/parse.go new file mode 100644 index 0000000..fd203c2 --- /dev/null +++ b/internal/workflow/parse.go @@ -0,0 +1,20 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "fmt" + + "g.fc.run/ai/byre/internal/api" +) + +func Parse(data []byte) (api.WorkflowDefinition, error) { + def := api.WorkflowDefinition{} + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + err := dec.Decode(&def) + if err != nil { + return api.WorkflowDefinition{}, fmt.Errorf("%w: %v", ErrInvalid, err) + } + return def, Validate(def) +} diff --git a/mac/Sources/Byre/WorkflowEditor.swift b/mac/Sources/Byre/WorkflowEditor.swift index 5ec85fb..7e4c10f 100644 --- a/mac/Sources/Byre/WorkflowEditor.swift +++ b/mac/Sources/Byre/WorkflowEditor.swift @@ -228,7 +228,7 @@ struct WorkflowEditor: View { return } do { - let def = try await store.api.client.getWorkflow(path: .init(name: original), headers: .init(accept: [.init(contentType: .json)])).ok.body.json + let def = try await store.api.client.getWorkflow(path: .init(name: original)).ok.body.json name = original description = def.description ?? "" root = StepNode(kind: .order, children: def.steps.map { StepNode($0) })