From 2eccd23a9804ebed6e40b33d89a6efb154f2a994 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Thu, 15 Oct 2026 22:11:25 -0700 Subject: [PATCH] Run workflow stages with finish_stage handoffs and lead signals --- api/openapi.yaml | 10 +- docs/operations.md | 17 +- internal/agents/fork.go | 1 + internal/agents/lead.go | 9 +- internal/agents/leadtools.go | 45 ++++- internal/agents/manager.go | 69 ++++++- internal/agents/tasks.go | 27 ++- internal/agents/workflows.go | 228 ++++++++++++++++++++- internal/api/api.gen.go | 366 ++++++++++++++++------------------ internal/server/server.go | 2 +- internal/store/store.go | 36 +++- internal/store/workflow.go | 94 ++++++++- internal/workflow/file.go | 2 +- internal/workflow/workflow.go | 7 +- web/src/api.gen.ts | 8 +- 15 files changed, 665 insertions(+), 256 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index a8d9536..8eca0bd 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1006,15 +1006,10 @@ components: Stage: type: object - required: [name, vendor, model, prompt] + required: [name, model, prompt] properties: name: { type: string, description: "One line of 1 to 100 characters, unique within the workflow." } - vendor: - type: string - enum: [claude, codex] - x-enum-varnames: [StageVendorClaude, StageVendorCodex] - description: Must be the vendor that runs model (gpt-* models run on Codex, every other model on Claude Code). - model: { type: string } + model: { type: string, description: "The stage agent's model, which also picks its vendor: gpt-* models run on Codex, every other model on Claude Code." } prompt: { type: string, description: "The stage agent's instructions." } TaskWorkflow: @@ -1154,6 +1149,7 @@ components: created_at: { type: string, format: date-time } forked_from: { type: string, description: "A fork's first start resumes this vendor session, cut at forked_at, under a new vendor_session_id; absent for other sessions." } forked_at: { type: string, description: "With forked_from: the vendor's id of the last thing the fork keeps, a message for Claude Code, a turn for Codex." } + workflow_stage: { type: integer, description: "The stage (from 1) of the task's workflow this session runs; absent for a session outside a workflow." } SessionCreate: type: object diff --git a/docs/operations.md b/docs/operations.md index 28cdb1b..63c5fbb 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -100,7 +100,7 @@ Agents spawn parallel work with the `create_task` MCP tool (`internal/agents/tas Agents don't commit; byre does. Each `user.message` event that starts a turn records `head`, the worktree's `HEAD` when the turn started. When a turn ends, byre runs `git add --all` and commits if anything changed (`git.CommitAll`), as the session's creator, with the first sentence of the turn's final message as the message (`commitMessage` in `internal/agents/manager.go`); `turn.ended` then carries the new sha as `commit`. A failed commit is logged and the turn still ends. -A session runs on one of two vendors, picked by the model it is created with (`vendorOf` in `internal/agents/process.go`): `gpt-*` models run on Codex (`codex app-server`), every other model on Claude Code. The UI's model list is `models` in `web/src/task.ts`; a live session's picker offers only its own vendor's models, and the API refuses (400) a switch to the other vendor's model. Both vendors sit behind `agentProcess` (`internal/agents/process.go`), and both end a turn through the same `turnEnded`, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor. +A session runs on one of two vendors, picked by the model it is created with (`workflow.VendorOf` in `internal/workflow/workflow.go`): `gpt-*` models run on Codex (`codex app-server`), every other model on Claude Code. The UI's model list is `models` in `web/src/task.ts`; a live session's picker offers only its own vendor's models, and the API refuses (400) a switch to the other vendor's model. Both vendors sit behind `agentProcess` (`internal/agents/process.go`), and both end a turn through the same `turnEnded`, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor. For Claude, byre chooses the session ID (`vendor_session_id`) up front. Codex picks its own thread id: a Codex session is created with an empty `vendor_session_id`, and byre stores the thread id when its first process starts (`startCodex`); a session with an id resumes it with `thread/resume`. Processes start on demand: the first message after a restart resumes the session (`--resume` for Claude). Sessions are `running` from the moment a turn is sent until it ends (Claude's `result`, Codex's `turn/completed`); on startup, every session still marked `running` was cut off mid-turn, and byre resumes it and sends a message telling the agent to re-check the working tree. Stopping the server kills all agent processes. Merging or discarding a task kills its sessions and marks them `completed`. @@ -145,8 +145,8 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie - **Sessions.** A lead session is an `agent_sessions` row with `owner` set to the user's login, `role` `lead` and no `task_id`. Its events carry `owner` and no project or task. It runs in `leads/` with `leadGuidance` (passed and re-attached the same way as a task agent's guidance) and the lead tools instead of the task tools. It has no Stop-hook bounce, no snapshots, no end-of-turn commit and no turn summary; a turn that ends in error records the error on its `session.state` event, which the pane shows as a note. - **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs`; the lead's PostToolUse hook (Codex: `turn/steer` after each completed tool item) hands held messages over after its next tool call, like a task's queued messages (see "Agent sessions"), and any still held become its next turn. Signals are never handed over mid-turn. The composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints. -- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.created` (naming the TODO item it was started from, if any), `task.merged`, `task.discarded`, and `turn.ended` with `summary: true` (" finished a turn:" with its `result` cut to 1500 characters). The human switching the right pane is not a signal. A signal caused by the lead itself (actor ` (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor ` (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `web/src/task.ts`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it; the partial message starts hidden and is first shown by its 100ms render, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message isn't drawn and doesn't open the turn, so the spinner stays off, and its partial message stays hidden, until the lead draws something else, such as a tool call, thinking or a real message, which draws the `byre` message ahead of it. When such a turn ends in `NO_REPLY`, everything it drew is removed with it (its `byre` message, tool calls, thinking), so the transcript keeps nothing of it; a turn that ends in a real message keeps all of it. -- **Tools.** `list_tasks` (each project's active tasks, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`), `read_inbox`, `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; refused while it is mid-turn), `create_task` and `start_todo` (each with an optional `workflow` to freeze), `interrupt`, `compact_task`, `merge_task`, `discard_task`, `show`, `compact_self`, and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor ` (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks. +- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.created` (naming the TODO item it was started from, if any), `task.merged`, `task.discarded`, `turn.ended` with `summary: true` (" finished a turn:" with its `result` cut to 1500 characters), `task.workflow_stage` (" started stage n/N of "": .") and `task.workflow_finished` (" finished workflow "":" with its output cut to 1500 characters). The human switching the right pane is not a signal. A signal caused by the lead itself (actor ` (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor ` (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `web/src/task.ts`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it; the partial message starts hidden and is first shown by its 100ms render, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message isn't drawn and doesn't open the turn, so the spinner stays off, and its partial message stays hidden, until the lead draws something else, such as a tool call, thinking or a real message, which draws the `byre` message ahead of it. When such a turn ends in `NO_REPLY`, everything it drew is removed with it (its `byre` message, tool calls, thinking), so the transcript keeps nothing of it; a turn that ends in a real message keeps all of it. +- **Tools.** `list_tasks` (each project's active tasks, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`), `read_inbox`, `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; refused while it is mid-turn), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run), `interrupt`, `compact_task`, `merge_task`, `discard_task`, `show`, `compact_self`, and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor ` (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks. - **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The page applies the focus on load and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. The URL follows the focus: `#/task/`, `#/project/`, or `#/` for neither. - **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane, the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged or discarded, or its selected tab is `inbox` and the task has no open items. When an `inbox.updated`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: has nothing left in its inbox." (or "is merged."/"is discarded."), even when the event itself makes no signal, such as a dismissed error; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo//`; Markdown views draw that (`web/src/todolink.ts`) as the title with a **Start** button that calls `POST /api/projects/{id}/todo/tasks` with the first model, as the Project tab's does, or, once an active task was started from that item, the task's colour, dot and state, linking to it. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The page sends `PUT /api/me/pane-focus` with `focused` whenever that changes: true while the browser window has focus and the focused element is inside the right pane. The pane itself is focusable (`tabIndex` -1), so a click anywhere in it counts, not only on inputs and buttons; a re-render that removes the focused element hands focus back to the pane rather than counting as leaving. The page sends false on `pagehide` (with `keepalive`) and resends true when its event stream reconnects. byre keeps the flag in memory per user (`Manager.paneFocused`), so it starts false after a restart until the page resends it. - **Start hold.** Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (`StartTodo` with the user as actor, not the lead's `start_todo`), byre sets `Manager.startedTodo` for them, and while it is set and the pane's selected tab is `project`, every `show` that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. The user's own `PUT /api/me/focus` (switching task or tab) or a message to the lead clears it. It is in memory, so a restart clears it. @@ -237,7 +237,7 @@ The transcript is the timeline. The UI learns a task's snapshots from the `snaps The cut point comes from `vendor_uuid`: every top-level `agent.message`, `agent.thinking`, `agent.tool_call` and `agent.tool_result` event (not a subagent's) carries the `uuid` of the Claude Code stream-json line it came from, which is the id of that entry in the CLI's own session transcript. `forkPoint` takes the last such event of the snapshot's session at or before the snapshot's `event_seq`, then extends the cut through the following tool calls and results until every tool call it has kept has its result, so the fork never resumes on an unanswered tool call. For a `turn_end` snapshot the cut is the turn's last message. A Codex session's events carry no `vendor_uuid` except its `turn.ended`, which carries the turn id, so for Codex `forkPoint` picks the last turn that ended at or before the snapshot. Snapshots whose session recorded no `vendor_uuid` by then, such as those from before byre recorded it, can't be forked. -A fork of a task that froze a workflow gets a copy of the source's frozen workflow, not the library's current one (see "Workflows"). +A fork of a task that froze a workflow gets a copy of the source's frozen workflow and brief, not the library's current one, and its session carries the snapshot session's stage, so the fork goes on through the workflow from there in its own worktree (see "Workflows"). The fork's session guidance gets one more line (`forkGuidance` in `internal/agents/guidance.go`) naming the source task, the snapshot, the fork's branch and worktree and the source's worktree, telling the agent that everything before the fork happened in the other worktree and to work only in its own. Claude Code can reuse the system prompt recorded for the source conversation on a fork, the source's cwd included, so the `UserPromptSubmit` hook's copy is what reliably tells it. The fork's transcript starts with a note from its `task.created` event, "forked this task from at snapshot #n", linking the source while it is active; the lead's signal for it reads " was forked by from at snapshot n", and `list_tasks` notes the source. The fork's transcript shows only its own events, not the source's history. @@ -254,14 +254,17 @@ Every new snapshot of a task gets a review: a separate agent session on the vend ## Workflows -A workflow is a named sequence of stages, each a stage name, a vendor, a model and the stage agent's prompt written out in full. 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. Nothing runs a workflow yet: byre stores, validates, exports, imports and freezes them. +A workflow is a named sequence of stages, each a stage name, a model and the stage agent's prompt written out in full. 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 a task's frozen copy stage by stage (`internal/agents/workflows.go`). There is no UI for starting or managing them: the lead does both with its tools, and draws a workflow for the human as a Mermaid flowchart of its stage names, which its chat renders (see "Inbox"). - **Names.** The name is the workflow's key and is shown to the human on buttons and pickers, so it is free text: one line of 1 to 100 characters, trimmed (`workflow.CleanName`). It isn't part of the definition, so the file doesn't carry it. In URLs it is a path-escaped segment. - **File.** One workflow per file: the `WorkflowDefinition` (`api/openapi.yaml`) as YAML, an optional `description` and its `stages`. `workflow.Format` writes it with multi-line prompts as literal blocks; `workflow.Parse` reads YAML or JSON and refuses unknown fields. The download is named after the workflow with `/\:*?"<>|` replaced by `_` (`workflow.Filename`). -- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed) with no stages, a stage name that isn't a valid one-line name or repeats another stage's, an empty prompt, or a `vendor` that isn't the one that runs its `model` (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, everything else Claude Code). +- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed) with no stages, a stage name that isn't a valid one-line name or repeats another stage's, an empty model or an empty prompt. A stage's vendor follows from its model (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, everything else Claude Code); a file with a `vendor` key is refused as an unknown field. - **API.** `GET /api/workflows` lists every workflow's name and description. `GET /api/workflows/{name}` downloads its file; `PUT` takes a file as the body and creates or replaces the workflow; `DELETE` removes it. Each save or delete records a `workflow.updated` or `workflow.deleted` event carrying the name. `sqlite3 byre.db 'SELECT name FROM workflows'` lists them. Saves and reads go through the Manager (`internal/agents/workflows.go`) so the HTTP API and the lead's tools share them. - **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. -- **Freezing.** `POST /api/projects/{id}/tasks`, and the lead's `create_task` and `start_todo`, take an optional `workflow` (404 when there is none of that name). The task copies the workflow's name and definition as they are at that moment into `tasks.workflow_name` and `tasks.workflow_definition`, so later saves or deletes don't change it. The Task's `workflow` is the name; `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_task` and the Project tab's Start create tasks without one; a fork copies its source's. +- **Freezing.** `POST /api/projects/{id}/tasks`, and the lead's `create_task` and `start_todo`, take an optional `workflow` (404 when there is none of that name). The task copies the workflow's name and definition as they are at that moment into `tasks.workflow_name` and `tasks.workflow_definition`, so later saves or deletes don't change it, and 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 Task's `workflow` is the name; `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_task` and the Project tab's Start create tasks without one; a fork copies its source's, brief included. `POST /api/projects/{id}/tasks` only freezes: nothing starts its stages. +- **Starting.** The lead's `create_task` and `start_todo` with `workflow` start stage 1 instead of an agent on the prompt. `model` together with `workflow` is refused, since each stage names its own. +- **Stages.** Each stage runs in a new implementer session on the stage's model, created by the user who started the workflow; `agent_sessions.workflow_stage` (`workflow_stage` on the AgentSession, from 1) records which. Any other session the task gets later (a Compact handoff's, one started by a message after the last completed, a Sync or Merge with no session) inherits the latest session's stage. Starting stage n records a `task.workflow_stage` event (`workflow`, `stage`, `stages`, `name`; actor the lead for stage 1, `byre` after) and sends the session its first message, actor `byre`, so the transcript shows it collapsed under its first line: `Workflow "", stage of : `, then the stage's prompt, a `## Brief` section when the brief isn't empty, and from stage 2 a `## From the previous stage ()` section holding the previous stage's output. Nothing else of earlier stages is passed on; the worktree and branch carry their work. +- **Advancing.** A stage session gets the `finish_stage` tool (`output`, required) and a guidance line naming the workflow and its stage. The call stores the output in `agent_sessions.stage_output`, replacing any earlier one. 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. If it isn't the last stage, it completes the session, starts the next stage's session with that output and, only once that session's first message is recorded, clears `stage_output` and `stage_finished`. After the last stage it records `task.workflow_finished` (`workflow`, `output`) and clears them in the same transaction, and the session stays idle for more messages. 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. Queued messages and held answers of a finished stage aren't delivered to it: queued ones go to the next stage's session after its turn ends. If the next stage can't start, byre logs `advance workflow stage` and files the error in the task's inbox, and the flag stays set. On startup, before resuming running sessions, `recoverStages` advances every session with `stage_finished` set, skipping the steps already done: it completes the session if it isn't yet; it starts the next stage when there is no later session of that stage; and when there is one that never got a message (`SessionStarted`), it sends it the stage's first message, and the restart notice isn't sent to it. `sqlite3 byre.db 'SELECT id, task_id, workflow_stage FROM agent_sessions WHERE stage_finished = 1'` lists stages waiting to advance. `list_tasks` shows each task's workflow and `stage n/N `, or `finished` once the task has a `task.workflow_finished` event. ## Events diff --git a/internal/agents/fork.go b/internal/agents/fork.go index 1832ea1..d663a23 100644 --- a/internal/agents/fork.go +++ b/internal/agents/fork.go @@ -68,6 +68,7 @@ func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n CreatedAt: time.Now().UTC(), ForkedFrom: &srcSess.VendorSessionId, ForkedAt: &at, + WorkflowStage: srcSess.WorkflowStage, }, t.ProjectId) if err != nil { return api.Task{}, fmt.Errorf("task %s created, but its agent session was not: %w", t.Id, err) diff --git a/internal/agents/lead.go b/internal/agents/lead.go index 16801fe..4e4368a 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -30,6 +30,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left - Reviews: after each new snapshot of a task, a reviewer agent from the other vendor reviews the change. The results are on the task's reviews tab, read_transcript shows each review's findings, and a question shows the review of the turn that asked it. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding. - Dig into context with list_tasks, read_transcript and read_inbox, and with your own Read, Grep and Bash on a task's worktree path (from list_tasks). Don't edit files in task worktrees; ask the task's agent with send_message. - You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it. +- Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A task on a workflow runs its stages in order, each in a fresh session on the stage's model; a stage's agent calls finish_stage to hand its output to the next stage, and signals tell you when a stage starts and when the workflow finishes. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no numbers or models, for example a fenced mermaid block holding "flowchart LR" and "Build --> Check". - Whenever you name a task in a message, link it as a Markdown link to #/task/, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task//. When you point the human at a question to answer, link to #/task//inbox; at a finished turn, to #/task//transcript. - Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line
Title, the Markdown content, then a line
. - You are long-running. Your context window is large. Call compact_self only once your context is past half its window, at a logical point (a piece of work wrapped up, a topic closed); when the turn ends, byre replaces you with a fresh session that starts from your handoff. @@ -261,7 +262,7 @@ func (m *Manager) leadTurnEnded(ctx context.Context, l *live, res endResult) err func (m *Manager) Observe(ev api.Event) { switch ev.Kind { - case "inbox.created", "inbox.updated", "task.created", "task.merged", "task.discarded", "turn.ended": + case "inbox.created", "inbox.updated", "task.created", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished": default: return } @@ -390,6 +391,12 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) case "task.discarded": return fmt.Sprintf("%s was discarded by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil + case "task.workflow_stage": + return fmt.Sprintf("%s started stage %v/%v of %q: %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["stage"], ev.Payload["stages"], ev.Payload["workflow"], ev.Payload["name"]), nil + + case "task.workflow_finished": + return fmt.Sprintf("%s finished workflow %q:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["workflow"], clip(fmt.Sprint(ev.Payload["output"]), 1500)), nil + case "turn.ended": if ev.Payload["summary"] != true { return "", nil diff --git a/internal/agents/leadtools.go b/internal/agents/leadtools.go index 9cb743e..df0bce3 100644 --- a/internal/agents/leadtools.go +++ b/internal/agents/leadtools.go @@ -156,15 +156,16 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { tool("create_task", "Create a task with its own branch and worktree, and start an agent on it with prompt as its first message. "+ "The new agent sees nothing else, so the prompt must be a complete brief. It starts from the project's default branch, "+ - "or from from_task_id's committed HEAD.", + "or from from_task_id's committed HEAD. With workflow, the task runs that workflow's stages in order instead, each in a fresh session on the stage's model; "+ + "prompt is then optional and is added to every stage's first message as its brief, and model can't be given.", `{"type": "object", "properties": { "project_id": {"type": "string"}, "title": {"type": "string"}, "prompt": {"type": "string"}, "from_task_id": {"type": "string"}, "model": {"type": "string"}, - "workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of."} - }, "required": ["project_id", "title", "prompt"]}`, + "workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."} + }, "required": ["project_id", "title"]}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { args := struct { ProjectID string `json:"project_id"` @@ -178,8 +179,14 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { if err != nil { return "", err } - if strings.TrimSpace(args.Title) == "" || strings.TrimSpace(args.Prompt) == "" { - return "", errors.New("title and prompt are required") + if strings.TrimSpace(args.Title) == "" { + return "", errors.New("title is required") + } + if args.Workflow == "" && strings.TrimSpace(args.Prompt) == "" { + return "", errors.New("prompt is required without a workflow") + } + if args.Workflow != "" && args.Model != "" { + return "", errModelWithWorkflow } if user.GitName == "" || user.GitEmail == "" { return "", ErrNoGitIdentity @@ -199,10 +206,17 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { return "", err } } - task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login), args.Workflow) + task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login), args.Workflow, args.Prompt) if err != nil { return "", err } + if args.Workflow != "" { + err = m.startWorkflow(ctx, task, user, leadActor(user.Login)) + if err != nil { + return "", err + } + return fmt.Sprintf("Task %s %q created from %s and its workflow %q started.", task.Id, task.Title, task.BaseRef, args.Workflow), nil + } _, err = m.create(ctx, task, user, args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), nil) if err != nil { return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) @@ -211,12 +225,13 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { }), tool("start_todo", - "Start a task on an item of a project's TODO.md (its title, exactly as list_tasks' project or the file on the default branch has it).", + "Start a task on an item of a project's TODO.md (its title, exactly as list_tasks' project or the file on the default branch has it). "+ + "With workflow, the task runs that workflow's stages with the item as their brief, and model can't be given.", `{"type": "object", "properties": { "project_id": {"type": "string"}, "title": {"type": "string"}, "model": {"type": "string"}, - "workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of."} + "workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."} }, "required": ["project_id", "title"]}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { args := struct { @@ -229,6 +244,9 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { if err != nil { return "", err } + if args.Workflow != "" && args.Model != "" { + return "", errModelWithWorkflow + } project, err := m.store.GetProject(ctx, args.ProjectID) if err != nil { return "", err @@ -562,6 +580,13 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) { if t.ForkedFrom != nil { fmt.Fprintf(b, "; forked from %s at snapshot %d", *t.ForkedFrom, *t.ForkedAt) } + if t.Workflow != nil { + stage, err := m.workflowStatus(ctx, t.Id) + if err != nil { + return "", err + } + fmt.Fprintf(b, "; workflow %q, %s", *t.Workflow, stage) + } b.WriteString("\n") } if !listed { @@ -674,6 +699,10 @@ func transcriptLine(ev api.Event, root string) string { } case "task.merged", "task.discarded": return fmt.Sprintf("%s by %s", ev.Kind, ev.Actor) + case "task.workflow_stage": + return fmt.Sprintf("started stage %v/%v of workflow %q: %s", p["stage"], p["stages"], text("workflow"), text("name")) + case "task.workflow_finished": + return fmt.Sprintf("finished workflow %q: %s", text("workflow"), clip(text("output"), 300)) case "review.finished": r := api.Review{} err := remarshal(p, &r) diff --git a/internal/agents/manager.go b/internal/agents/manager.go index 988b688..facfe53 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -74,6 +74,8 @@ type live struct { projectID string owner string reviewID string + stage int + stages int stopping bool interrupted bool asked bool @@ -126,6 +128,18 @@ func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, p } func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string, images []string) (api.AgentSession, error) { + existing, err := m.store.ListSessions(ctx, task.Id) + if err != nil { + return api.AgentSession{}, err + } + stage := 0 + if len(existing) > 0 && existing[len(existing)-1].WorkflowStage != nil { + stage = *existing[len(existing)-1].WorkflowStage + } + return m.createAt(ctx, task, creator, prompt, model, actor, images, stage) +} + +func (m *Manager) createAt(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string, images []string, stage int) (api.AgentSession, error) { if creator.GitName == "" || creator.GitEmail == "" { return api.AgentSession{}, ErrNoGitIdentity } @@ -157,6 +171,9 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, p CreatedBy: creator.Login, CreatedAt: time.Now().UTC(), } + if stage > 0 { + sess.WorkflowStage = &stage + } err = m.store.CreateSession(ctx, sess, task.ProjectId) if err != nil { @@ -265,12 +282,20 @@ func (m *Manager) Recover(ctx context.Context) error { return err } + staged, err := m.recoverStages(ctx) + if err != nil { + return err + } + sessions, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning) if err != nil { return err } for _, sess := range sessions { + if staged[sess.Id] { + continue + } task, err := m.taskFor(ctx, sess) if err != nil { return err @@ -280,9 +305,13 @@ func (m *Manager) Recover(ctx context.Context) error { if err != nil { slog.Error("resume session", "session", sess.Id, "err", err) m.logUndelivered(ctx, sess.Id, "resume failed") - err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": err.Error()}) - if err != nil { - return err + derr := m.store.DropStageOutput(ctx, sess.Id) + if derr != nil { + return derr + } + serr := m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": err.Error()}) + if serr != nil { + return serr } if sess.Role == api.Implementer { m.abandonMerge(task.Id, task.ProjectId) @@ -518,6 +547,16 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap return nil, err } tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.exposePortTool(l)} + if sess.Role == api.Implementer && sess.WorkflowStage != nil { + frozen, err := m.store.TaskWorkflow(ctx, task.Id) + if err != nil { + return nil, err + } + l.stage = *sess.WorkflowStage + l.stages = len(frozen.Definition.Stages) + g += stageGuidance(frozen, l.stage) + tools = append(tools, m.finishStageTool(l)) + } env := portEnv(task) stdio := []mcp.Stdio{} if sess.Role == api.Reviewer { @@ -588,6 +627,12 @@ func (m *Manager) pump(l *live) { } m.logUndelivered(ctx, l.id, "agent process exited mid-turn") + if l.stage > 0 { + err = m.store.DropStageOutput(ctx, l.id) + if err != nil { + slog.Error("drop stage output", "session", l.id, "err", err) + } + } err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, map[string]any{"error": "agent process exited mid-turn"}) if err != nil { slog.Error("record session exit", "session", l.id, "err", err) @@ -788,6 +833,18 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { m.fileError(l, failure) } + stageFinished := false + switch { + case l.stage == 0: + case finished && !handoff && mergingBy == "": + stageFinished, err = m.store.FinishStage(ctx, l.id) + default: + err = m.store.DropStageOutput(ctx, l.id) + } + if err != nil { + return err + } + err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, nil) if err != nil { return err @@ -809,6 +866,12 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { default: go m.nextMerge(l.projectID) } + if stageFinished { + go m.advanceStage(l) + if l.stage < l.stages { + return nil + } + } err = m.Deliver(ctx, l.id) if err != nil { slog.Error("deliver inbox responses", "session", l.id, "err", err) diff --git a/internal/agents/tasks.go b/internal/agents/tasks.go index 6f7d30d..a47bad4 100644 --- a/internal/agents/tasks.go +++ b/internal/agents/tasks.go @@ -16,8 +16,8 @@ import ( "g.fc.run/misc/byre/internal/todo" ) -func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base, actor, workflowName string) (api.Task, error) { - frozen, err := m.freeze(ctx, workflowName) +func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base, actor, workflowName, brief string) (api.Task, error) { + frozen, err := m.freeze(ctx, workflowName, brief) if err != nil { return api.Task{}, err } @@ -28,11 +28,6 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap if creator.GitName == "" || creator.GitEmail == "" { return api.Task{}, ErrNoGitIdentity } - frozen, err := m.freeze(ctx, workflowName) - if err != nil { - return api.Task{}, err - } - src, found, err := git.ReadFile(project.RepoPath, project.DefaultBranch, todo.File) if err != nil { return api.Task{}, err @@ -55,14 +50,26 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap } } + frozen, err := m.freeze(ctx, workflowName, todoPrompt(item)) + if err != nil { + return api.Task{}, err + } + task, err := m.createTask(ctx, project, api.Task{Title: item.Title, CreatedBy: creator.Login, TodoItem: &item.Title}, frozen, project.DefaultBranch, actor) if err != nil { return api.Task{}, err } - _, err = m.create(ctx, task, creator, todoPrompt(item), model, actor, nil) + if frozen != nil { + err = m.startWorkflow(ctx, task, creator, actor) + } else { + _, err = m.create(ctx, task, creator, todoPrompt(item), model, actor, nil) + if err != nil { + err = fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) + } + } if err != nil { - return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) + return api.Task{}, err } if actor == creator.Login { m.setStartedTodo(creator.Login, true) @@ -172,7 +179,7 @@ func (m *Manager) createTaskFromAgent(ctx context.Context, l *live, raw json.Raw model = sess.Model } - task, err := m.CreateTask(ctx, project, args.Title, creator.Login, base, creator.Login, "") + task, err := m.CreateTask(ctx, project, args.Title, creator.Login, base, creator.Login, "", "") if err != nil { return "", err } diff --git a/internal/agents/workflows.go b/internal/agents/workflows.go index 9acf086..ef6510a 100644 --- a/internal/agents/workflows.go +++ b/internal/agents/workflows.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "strings" "g.fc.run/misc/byre/internal/api" @@ -48,7 +49,9 @@ func (m *Manager) TaskWorkflow(ctx context.Context, taskID string) (api.TaskWork return api.TaskWorkflow{Name: frozen.Name, Definition: string(file)}, nil } -func (m *Manager) freeze(ctx context.Context, name string) (*store.FrozenWorkflow, error) { +var errModelWithWorkflow = fmt.Errorf("%w: model can't be combined with workflow: each stage names its own model", ErrInvalid) + +func (m *Manager) freeze(ctx context.Context, name, brief string) (*store.FrozenWorkflow, error) { if name == "" { return nil, nil } @@ -56,11 +59,232 @@ func (m *Manager) freeze(ctx context.Context, name string) (*store.FrozenWorkflo if err != nil { return nil, err } + frozen.Brief = brief return &frozen, nil } +func stageMessage(frozen store.FrozenWorkflow, n int, previous string) string { + stages := frozen.Definition.Stages + stage := stages[n-1] + + b := &strings.Builder{} + fmt.Fprintf(b, "Workflow %q, stage %d of %d: %s\n\n%s", frozen.Name, n, len(stages), stage.Name, strings.TrimSpace(stage.Prompt)) + if strings.TrimSpace(frozen.Brief) != "" { + fmt.Fprintf(b, "\n\n## Brief\n\n%s", strings.TrimSpace(frozen.Brief)) + } + if n > 1 { + fmt.Fprintf(b, "\n\n## From the previous stage (%s)\n\n%s", stages[n-2].Name, strings.TrimSpace(previous)) + } + return b.String() +} + +func (m *Manager) startStage(ctx context.Context, task api.Task, creator api.User, frozen store.FrozenWorkflow, n int, previous, actor string) (api.AgentSession, error) { + stage := frozen.Definition.Stages[n-1] + err := m.store.AppendEvent(ctx, api.Event{ + Kind: "task.workflow_stage", + ProjectId: &task.ProjectId, + TaskId: &task.Id, + Actor: actor, + Payload: map[string]any{"id": task.Id, "workflow": frozen.Name, "stage": n, "stages": len(frozen.Definition.Stages), "name": stage.Name}, + }) + if err != nil { + return api.AgentSession{}, err + } + return m.createAt(ctx, task, creator, stageMessage(frozen, n, previous), stage.Model, "byre", nil, n) +} + +func (m *Manager) startWorkflow(ctx context.Context, task api.Task, creator api.User, actor string) error { + frozen, err := m.store.TaskWorkflow(ctx, task.Id) + if err == nil { + _, err = m.startStage(ctx, task, creator, frozen, 1, "", actor) + } + if err != nil { + return fmt.Errorf("task %s created, but its workflow did not start: %w", task.Id, err) + } + return nil +} + +func (m *Manager) advanceStage(l *live) { + _, err := m.nextStage(context.Background(), l.id) + if err != nil { + slog.Error("advance workflow stage", "session", l.id, "err", err) + m.fileError(l, fmt.Sprintf("The workflow didn't move on from this stage: %v. byre tries again when it next starts.", err)) + } +} + +func (m *Manager) recoverStages(ctx context.Context) (map[string]bool, error) { + ids, err := m.store.FinishedStages(ctx) + if err != nil { + return nil, err + } + started := map[string]bool{} + for _, id := range ids { + next, err := m.nextStage(ctx, id) + if err != nil { + slog.Error("advance workflow stage", "session", id, "err", err) + continue + } + if next != "" { + started[next] = true + } + slog.Info("advanced workflow stage", "session", id, "next", next) + } + return started, nil +} + +func (m *Manager) nextStage(ctx context.Context, sessionID string) (string, error) { + sess, err := m.store.GetSession(ctx, sessionID) + if err != nil { + return "", err + } + task, err := m.store.GetTask(ctx, sess.TaskId) + if err != nil { + return "", err + } + if task.Status != api.TaskStatusActive { + return "", m.store.ClearStage(ctx, sess.Id) + } + frozen, err := m.store.TaskWorkflow(ctx, task.Id) + if err != nil { + return "", err + } + output, err := m.store.StageOutput(ctx, sess.Id) + if err != nil { + return "", err + } + + next := *sess.WorkflowStage + 1 + if next > len(frozen.Definition.Stages) { + return "", m.store.FinishWorkflow(ctx, task, sess.Id, frozen.Name, output) + } + + creator, err := m.store.GetUser(ctx, sess.CreatedBy) + if err != nil { + return "", err + } + if sess.State != api.AgentSessionStateCompleted { + m.logUndelivered(ctx, sess.Id, "stage finished") + err = m.retire(ctx, sess, "byre") + if err != nil { + return "", err + } + } + + successor, found, err := m.successor(ctx, sess, next) + if err != nil { + return "", err + } + sent := true + switch { + case !found: + successor, err = m.startStage(ctx, task, creator, frozen, next, output, "byre") + default: + sent, err = m.resendStage(ctx, successor, task, frozen, next, output) + } + if err != nil { + return "", err + } + err = m.store.ClearStage(ctx, sess.Id) + if err != nil || !sent { + return "", err + } + return successor.Id, nil +} + +func (m *Manager) successor(ctx context.Context, sess api.AgentSession, n int) (api.AgentSession, bool, error) { + sessions, err := m.store.ListSessions(ctx, sess.TaskId) + if err != nil { + return api.AgentSession{}, false, err + } + for _, s := range sessions { + if s.CreatedAt.Before(sess.CreatedAt) || s.Id == sess.Id || s.WorkflowStage == nil || *s.WorkflowStage != n { + continue + } + return s, true, nil + } + return api.AgentSession{}, false, nil +} + +func (m *Manager) resendStage(ctx context.Context, successor api.AgentSession, task api.Task, frozen store.FrozenWorkflow, n int, previous string) (bool, error) { + started, err := m.store.SessionStarted(ctx, successor.Id) + if err != nil || started || successor.State == api.AgentSessionStateCompleted { + return false, err + } + if successor.State == api.AgentSessionStateRunning { + err = m.store.SetSessionState(ctx, successor.Id, api.AgentSessionStateIdle, "byre", nil) + if err != nil { + return false, err + } + } + _, err = m.turn(ctx, successor, task, "byre", stageMessage(frozen, n, previous), nil) + return err == nil, err +} + +func (m *Manager) workflowStatus(ctx context.Context, taskID string) (string, error) { + finished, err := m.store.WorkflowFinished(ctx, taskID) + if err != nil || finished { + return "finished", err + } + frozen, err := m.store.TaskWorkflow(ctx, taskID) + if err != nil { + return "", err + } + sessions, err := m.store.ListSessions(ctx, taskID) + if err != nil { + return "", err + } + if len(sessions) == 0 || sessions[len(sessions)-1].WorkflowStage == nil { + return "not started", nil + } + n := *sessions[len(sessions)-1].WorkflowStage + stages := frozen.Definition.Stages + return fmt.Sprintf("stage %d/%d %s", n, len(stages), stages[n-1].Name), nil +} + +func (m *Manager) finishStageTool(l *live) mcp.Tool { + return mcp.Tool{ + Name: "finish_stage", + Description: "Finish your stage of this task's workflow. Call it once the stage's work is done; byre starts the next stage, in a fresh session, when this turn ends " + + "(after the last stage, the workflow is finished). Calling it again in the same turn replaces the output. " + + "The call counts only for the turn that makes it: if that turn ends in an error or is interrupted, it is discarded, and you call it again once the stage's work is done.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "output": {"type": "string", "description": "What the next stage starts from: everything it needs from this stage, since it sees nothing else of your context. For the last stage, what the workflow produced."} + }, + "required": ["output"] + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + args := struct { + Output string `json:"output"` + }{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed finish_stage call: %w", err) + } + if strings.TrimSpace(args.Output) == "" { + return "", fmt.Errorf("%w: output is required", ErrInvalid) + } + err = m.store.SetStageOutput(ctx, l.id, args.Output) + if err != nil { + return "", err + } + if l.stage == l.stages { + return "This is the workflow's last stage: it finishes when this turn ends. End your turn now with your summary.", nil + } + return fmt.Sprintf("byre starts stage %d when this turn ends. End your turn now with your summary.", l.stage+1), nil + }, + } +} + +func stageGuidance(frozen store.FrozenWorkflow, n int) string { + return fmt.Sprintf("\n- This task runs byre workflow %q, and you are its stage %d of %d, %q. When this stage's work is done, call byre's finish_stage tool with its output, then end your turn: "+ + "byre then starts the next stage in a fresh session, which gets the output and nothing else of your context. Until you call it, the task stays in your stage.", + frozen.Name, n, len(frozen.Definition.Stages), frozen.Definition.Stages[n-1].Name) +} + const workflowFileHelp = "A workflow's file is YAML: an optional description, then stages, each with name (one line, unique in the workflow), " + - "vendor (claude or codex: gpt-* models run on codex, every other model on claude), model and prompt (the stage agent's instructions). " + + "model (which also picks the vendor: gpt-* models run on Codex, every other model on Claude Code) and prompt (the stage agent's instructions). " + "The workflow's name is not in the file." 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 { diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index bde98a0..422902e 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -324,24 +324,6 @@ func (e SnapshotTrigger) Valid() bool { } } -// Defines values for StageVendor. -const ( - StageVendorClaude StageVendor = "claude" - StageVendorCodex StageVendor = "codex" -) - -// Valid indicates whether the value is a known member of the StageVendor enum. -func (e StageVendor) Valid() bool { - switch e { - case StageVendorClaude: - return true - case StageVendorCodex: - return true - default: - return false - } -} - // Defines values for TaskStatus. const ( TaskStatusActive TaskStatus = "active" @@ -466,6 +448,9 @@ type AgentSession struct { // VendorSessionId Claude Code's session id, chosen by byre; or the Codex thread id, empty until the session's first turn starts its thread. VendorSessionId string `json:"vendor_session_id"` + + // WorkflowStage The stage (from 1) of the task's workflow this session runs; absent for a session outside a workflow. + WorkflowStage *int `json:"workflow_stage,omitempty"` } // AgentSessionRole reviewer: a review's session (see Review); a task's sessions list leaves these out. @@ -830,6 +815,7 @@ type SnapshotTrigger string // Stage defines model for Stage. type Stage struct { + // Model The stage agent's model, which also picks its vendor: gpt-* models run on Codex, every other model on Claude Code. Model string `json:"model"` // Name One line of 1 to 100 characters, unique within the workflow. @@ -837,14 +823,8 @@ type Stage struct { // Prompt The stage agent's instructions. Prompt string `json:"prompt"` - - // Vendor Must be the vendor that runs model (gpt-* models run on Codex, every other model on Claude Code). - Vendor StageVendor `json:"vendor"` } -// StageVendor Must be the vendor that runs model (gpt-* models run on Codex, every other model on Claude Code). -type StageVendor string - // Status defines model for Status. type Status struct { ActiveTasks int `json:"active_tasks"` @@ -2922,175 +2902,175 @@ 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{ - "7H1/cxu3kuBXQfGuStbuiJL3Ze9u5bo/HNvJ87s49lnKpu7evtIDZ0AST0OABjCiGJe/+1V3AxjMEEOO", - "ZEt2NvdPInNm8KPR3ejf/XFS6tVaK6GcnZx/nBhh11pZgf94ZYw28EeplRPKwZ98va5lyZ3U6vQfViv4", - "zZZLseLw1381Yj45n/yX03bUU3pqT2m0T58+FZNK2NLINQwyOadpphN44N+FoZ4vhHLvjC6FxX+vjV4L", - "4yQtbSl47ZbwV3cs65rymq0EV5Zx5hqjmLTMNEpJtWBcVUxpZp0RfMXEjVCOLbll3Bh5Iyo214bhCFd8", - "7oS5sqLUqrLPmLiVTlR+XLcUbE0Lg8EXWonppJgI1awm53+d6OtJQeuAH/HDyd+KiduuBS7QSLWYFJPb", - "E/jg5IYbxVewqb9O/oybegvf0Z8XfhT61ys/1qdiUnPrrnD9V9ztQuHXpVCdZcL7bGO0E4yzWirBnGbS", - "WQ+KSTGZa7OCoSYVd+LEyZWYFBNt2AaGko5Zx40T1XRyrpq67m/nUzFZywoW4n+XyomFMPDACmulVled", - "5+131nEnELMs04rhsV/QJ3Gy7u4ul4L5QY9gB/B9ZkGO2+uhSW+Eqgi1e48+FRMjPjTSiApOJFl7O2D8", - "nDYdttA/lSIgaXv4evYPUTpYQLrNXewujeBOVP5ss0ezs6XwzWyb3fFcm+s4Yg9bpFsy/3xu9OocUYf2", - "eGSZrJie40+IRW4JlAT/hE/YtRBrWzDOVsJavsBf2YuaN5VgL3QlikCG+LuuxO00t/pk+t0FPsepjiyb", - "S2M9KjIjbLMSQI3S+sUGrChY2TjGHYubLlijKmEYZ0ps/NtX7eE+Y3xmgRnAIrVbijiUza52AKtWuhL1", - "7vLfiw+NsMg/4IVnTKzWbpswkwjqSsx5U7spex7mZ1rVW2Y30pVLYdlMuI1AgrRMb1T7JY6cX6zeKGF2", - "VwVUVOuFVGyz1FawWvCKcUBLAqm0YaEAFM4A+4HcAmlmZjK6FrsTGXEjxUaYc8YZ/d0Ow55YIdh7/PX4", - "2c4sltXSOljaDZ60sILpxqXcVq7WtVgJ5QTQI2xiUsQpd/luynDiEFUNFOUviUmBV2ItxvPtlJgvYPTX", - "NOLO7+/jFDuPXrRzdnlX765MTgRPbN95tFyuO8gPuq71xjKgNkRAxB78K5zLhlvmOQrbSLc8Z4u1O/kn", - "j2dwoQKzRnou4CI1W083NBQ8a3lAel4l/oxArsRt9oB2yHN3B8ngCTLJqmAlILNisy2bbY14xrTBfeFK", - "mVsagBm8R6jdKCc7G49MBnkWchqL1EafZsDcuzF6NwURX3JjEI/IbbK9R5COOhy9SK+E7H3iHC+XKy+k", - "9W4T7sGWYVlCVVcgD+SvbrniiwxF48/nrOKOs1/e/5TFvWupqpTI5hL3VMn5fBIGhn/yhUH5A0AGIFB8", - "bZcabs/G1Fn0WAmz4jmk8IOdszf0BrO6MaXILm/Nc9IjLPKcGbHWJ0bU3MkbweDNKfNb5or+YvAmkyRk", - "eZ610ebaGQE33szqunECkC+OgwJXnm2CzLy7mfn8nC2kQ56J6G24WuR3E6C2R8Yybs857xOV4BgOykl4", - "2jm8/L7WswxGkj5xRa9/HBZmvqAAlN1ejng7i7sbFb6U5pVyZru7Y7lQGifpnzOcMD0kWUDA9+dsupCO", - "fkalBZ4QfxW3ZQ2sDxDQshV35RLEc7wQKmlE6bTZMj7TN6KLbzOta8EVLBMurjyeyN/EEFWgtiB/Q6yf", - "bZ1AYSOejFTuv33XTpaK/9tVLdV1Ml+ykoAAffWFuxYYTNqCzfHKQh2O+RGZg9es41sbKDGQ4JSAdd5/", - "uRUk4qsAubWWylmm9GYpjCg8MMWNLEXBrC6vBQJ4Ldedq6ySwNA9Y8MJR8oLAU0ut2vxEgdJf/mBBkx/", - "ekuD99EVz9HP2ALaH2MRcW4AU3+S1sESd3AVoB7Q1omVPaTYR6yPBzrhxvDtMJ8F2TMia9HhkZ2zMVq7", - "VAqFh/Bblgk606gSqPPAfKjxr4Cy/EYBNxTbCCOYEXDviypHNz3g486KCKx0/hzAoy2lC2uvNR1mTeHF", - "7Ng32Xufw3azdI5i/tUBxfw+PDjc+mOVkAugLBVkWVzWkSXDjEXGx1ljBSg4c102dtqsYfqKNFTSXtxS", - "bNlM1Bq0Uj1lb0FhQmqHL5kE8RB1GrEakAS2tebpqlvAro2GPwevVfGhA5phJjh8we4YHD5MPBgLf4Dt", - "Eg/eP8A6XixBTMhgQ1VJgLrNiwAzqbjZ5rl0JWqx51NdV1d5OjcCWJQ/LniFzcQcKA8JGR/ulc6yultj", - "U7mSV5WoSK6Wc4l/4nIF6YE4v5/iqkTQZLS6Adr2sxUJ7FJgRKANHkVrNu2eRQvsXT5FkqVVR479cvnD", - "yf9gTty6Z/hfQGZkhvlrfRhq/lofg6ji1mXH2MNdcWlLXVeWrBWoTqGNxtuNYEvjOaq/vTyM0pn98obA", - "/RJ0ix1Yr0FEym5pAF75VdEw2amBM2Xm7fCOxL6k0F48aNn03xGJyMXSsTVXgtml3thnqb4RXkQLrWcw", - "xO5c3hw6O3SRX3J7fcln/m2bx094QuwXWPa6caQwFyCDaVMJM2WXUVqFZdEXJMBFNjxKsEjX05MrBg0k", - "lx5AA/DbOYdDrAABQdDLHf5rNdO3r51YZRjumEt2Vuvy2kthuxR9nyt4YKK+Po4mSQBZa6AXKKIUE8Od", - "uKrlKu+56F6ZXeCHQc8ZKlG3jj15w811pTcKrUh4KOVSy1KAzI6OCG7Z30/YE3aMLO7v7BT/+U/hn2tZ", - "Xp9ohXL630/YX9nf0vf+evu39D2utmQxRPQ6LlhYUME2y+0VAltUBdO4YMueCF4u/YKOLI7U4vFxwVZN", - "7eS6FuyJMw2ZtsM8xyC2lnq1EqoSFXuCZjRurkUVNniE4x0XjEfbjJ2yDrDPGf5vylKQn3urbpH8SuNH", - "ay88OKEHdEcdF36k/1CTIifHSG2k26YI4IExKSYLb3VCoyloNFs5UpH5Po7xI41Bltx3Ybpi8gOMdViS", - "MsLq+uaOmB4/GtD4O77MUQwHqfm9/yzHdghGdh/mk2gDr7XXH3otvI0msnDmtL5m6GjE38IQaHvl9jpg", - "VfwwKsXRIYNGSpR16X0J6KZVKbxuHL4Ut9K6jj2f2DMCZTQ7psPNgSWa1A8CF23dh0xOZGu9Gm2xSZAr", - "tb3uMOB05Chkr1tkjcw4FbmDUTbhxSlqtTgxeD0QQlW7N8RnIugLXBGaRKV6TZ8/7R9OD17tlAeWa3Mq", - "RMvIRq84MUxnEOc+F1wlankjzJe5FmEPQ2job4nOVndxtS+YDArQ3tQ1+ngv4YNPxQTU2JGEEHbjP4qm", - "ofTgDiqRORx7BFQYeRJdtksPzoNcYYUKd/szJm556eotA+GBHGZRlmjFAi9ZoDkoeQkEifBShz+OPv/e", - "OskciTwbxCIySZC1C9nU1K+brCE7azj3j3mdfM/R5pHIVQIu4wCAvI3sfkjYx7WIZjjeQSS69LPGCJm1", - "lzzJaFpMKmlX0tqssJlcG50hBAzAld0AK2jHwL9tsxbGimpAfn1DtrQh3EYPTwbZvq/1jMnK+02bNVwQ", - "8Fuxe5wg1dIwdEpWViKe/t0QaiCo4A36WckoKi1JAlxVrOYgT2gl7JT96mN3KjmfC5P4e1tnJ45edFy/", - "SbBBtNWE4A5vTcvjVhbz3/AtmwkveVAwkYeLEWwhb4QaoYMNaf0/ayfnPhrtnRFzm/MlyHKJUR8SEIlE", - "HoTUXCppl6JC2FmmYKwtQZMsh2hblIYBrqE3ma1h5VOSOFJzjpcbrgK5Ztbxv8MjL6oh2YYPzxnoF1P2", - "Fh3XIQoka+RBMT8zfkevaAW73QH2rPFnrU7CkiLrGRim1U0yA6XqzL61GLHWJjfAJcrHeC4oyYLMixDj", - "zDarFTdb9gQeT0n7wkf+AcHyeIS9KXNsKXja5UWod7edw8h3XIkBgxBasLPeCdAtN0tZE63NjN4A/m2k", - "qvQGvRX4aXQE+oGYoMAXIEqpIodpbR4jQBDWlN+KcbKUa56zYFbSrmu+vRp0JS6kuxIrLuvBp+HTXIDL", - "JgQwIiUuOZpDrXCMo0tcVkI56bbPyPrv4O2FcExp5gyXtTBZDoUOg8OiFL1WdPeYrDndXBZwXpP+ImF9", - "gwAG1MyFysHvvcgERHS4pcyNqECNXDq3tuenp//RnJ39qQTWdmTZUluHP4hz+h1Goh9Op5/n1Wh1Mg9A", - "XPxBSfQd6XWPEh/pme7VzHA1YC8eEE4HT8iIlXZDj9b6apz9OQVb+9nOguN0d4tY8CAeEoX27G30BnbW", - "PrAOp0tdD7hJ72QW4kh6ZeNCkDMZYL5j/0t+v8fwHqyRRpRC3kSvUW/b3A5EUh0Kc86GVoR5jyybN3XN", - "aqEWbtkJsdglu7HByzHmrGMC8fYL3AcByy/u4LHYXCBP4zXA4CLj9bvOG7vL73mAKRYfgEBuBV7xNYiw", - "pW7qCtj+mhuLSlol8d9GlHqhpBXMSlUKksx8eHrBrsVWVCBF0e6nOXMonLBy+dNYaYtTwMUKUnQBsqOw", - "3ssx2lbWxedPB6wyHoxxZbmjeB8vlR7HV17laCOGk6Di9Bqg1yot8DZtrCi8oRLEPVCEg73wyDJyle6K", - "ujmnxi4QQ/xtG5v5LNUA7LVcr0U13cem70LxIjCNXtwSSALVOdsst/mYc68AfAk70oCW9mrcpo2wTZ1B", - "yEorkVqUCaLNbCXdlbcxc7No0Axz3krGwedyHPScSqqFLRh6O0hWFjfCSLctWMmdWGAUjpOuFgWrhOOy", - "Lshdjxql7IiSLUbGqEM1kPgRlPaepkKAoH0lKGedWHu8wywdjHArmNXwj7B/ZrhKY7DaoG2A1aTwhw48", - "jaYZ6ckg6uoFaSc/vqTRk19+CBMlv12EOUenn9wDXfYHHCeHkrWA7wQiB/t28P4dEBh8zPrXsJ2sjV6t", - "72s92ZWfcbQR/n96LwuLEKx8P99vqVdAyHbJc3I8PUVPjxEC3vKJGmtuvNaXxsodWfbnV89fxnytDbfM", - "8WuhvhyXvaE9fRh2uh9ZtD9Z53ProhoXPVGwLH9Xe3OoEcIHj1CApnVxX6xZt2HTI4JXhgT03QW/0xal", - "lZ6iFJZpC8LTp888851jFqGY21OQNk7hdXv60VPdp1P47vSj+nRXNamYhLMdeCgXi1zYnGuMuhKqOvfu", - "Q6/BCVWd6PkJ2gIJfabRcvekpS/KA0Q7Mn2eGsGd1jUreV3b4wL/cQX/KBjsYdmsOFkZAUcM4+u14IaC", - "frRqoUeIB0KY4KaWsMBG1pVNGXeIZyziZuDPMB/wrjAh/A7TjeTkgSgvCXhv4jy9B5eNUa9w1v4DresX", - "tIbek+f2+s9+Rf1vaIEHeHOGHatJhw8kCJESXIsKh/mz47nov2E+mDfDvFVeIdJz9hSI8OnZGVzOhpdO", - "GFuwRskPjUBpIgm+ntd6k4/oi9w2k8sJS47Bn1JZZ5py2IcxdHm+aaxjM9EVfrljplHezs2efEYS0/Fd", - "spjymAn7/Hdc2YvwffobDTWgPu/c2/vupRgluRMQLG/EFXKvvMhGGOqzhnP3+CuEEvGRkFyMChhcR4aj", - "PbVgsqpFm8MnFaYsVKOVp07qd+ZSr2ZXqB6PjGlM0nLHR+zWACncZwYKz9P9C8vKxsCdXG9Dpnn+IvBx", - "CgOwX3uV8ao18Y/WMG3GJH9AI2/RJWOSoEt8J8yINAhPXGQdH61zE2Cu0Kif4QNo6w95+kpvCqbrChYR", - "1Nq76d8w3nsxzwarWHFV1jKcbFZvMXcVjjJVA3Y3+We9Yeiz5XGneFev+JYtNPJS3TjGuwUKvBtOOkZm", - "AoYiVFNe55HsN72ayRzpXgR69GFqYQmoECrNasx2I5wuKPi+h+ZI6EtusYZCOBY671GH8n9xaQetIQn8", - "E1rfoeOEnooua+uecZeW+5jYJZtdDtgCNH/Iu3Sb48iX3F5nwsC5FVc++283MnTYEF3W2g7l8geJGyPM", - "QNpeCQMKNdrPbMmNZ8Qjjea6zl21r1UlbplUXm375fWRpQnXvBbOCRAXrVwo8jagxIJBDJzBgI0BBKIT", - "o89Q/icvNMii6BZmQApALcAE+ELnsf1bqJSgQjhe1HK8/kVvMu7ya99b8yCJY5YWoZKMCJ9kahYg9t+p", - "YAHgxhXJn7tLIMzxdpoPDbfLqJQSjnkXBCNU7Zj4YD20ZKV9xQhNqJhdHy3kQyOafXj9Bt7CQU0sqUB+", - "U66SeHMZa59QfLwnAqkWEWY+RlLQkkKedyh3ogDc0rKlqO9AK7QHtKINHCc8irjSASjtbMWvhT1PAkTx", - "gzZHPdh5MTeKNuytee4qmT0vgGvjroDhDGWdGBvzNVK3IfyGXnD2JA4BZxn/8c//dlywNbdWVMHf4s04", - "oCcKs5KK13BnvXv7/nKvXLSvQkwv6Qd5x8TD3Ef/EG8bKYsDQyYx+XkYq/3pTRi1/ellOz5cX+GQd1V2", - "Xekr6fMBDqDA5duXb6crCpDo8mx/A96dzoMOlp8ddh8mD2+2E8+N/k2k/Jo9WQgHIPg1vAt3Pxxwqdfb", - "487CuF+8F2HyqRW0PtRw7+D77Eb3IuS7xBbvyv74RXvDpgQQrrUkwesuLlOACCXZ2fyVngd+YJ2GTF4g", - "HnETq2tEXnSCBJYQoueuMfqjy3QLcoIVoc7F2ogbqRubGtYxIzrvAKnvEHucJBfm4h6NyG0cM6zn3axe", - "QKI56dt0NWDqLVoBqriRw+vvR/PQ0eI6wtYGj2/Aej1M1+MIi7dkNU98b8AX50aI3zCNnkomoSy03vbJ", - "kYoASUXWSSQ3uIlU1qzSj7LA1Q/t+ZLyvwILdYYr2gZgvEfnALdiEhg30l9IDMEIuiTiPdLmaKY7u0yn", - "veSzF3HmSz77wU8O77XzX/LZu7iESz577VdxyWfv40LwJb8Wv+Ff9x5ZPCiecr/Ueu7ZQOFPAQ3qS7Kn", - "92Kh4oHlpwoLedk52P/z/E2+cspAzEXeLJTMnT14D8YLH33Q997XZByUSq4ALZ7mbmejNwff2vFn16hZ", - "6YG0iEtd6UzdAcxSycp+dO3tcD8q3hcvUu7wdupVCEjiDSOfG2dD0JXG1L5DCqtfeBh/aMf5NMGZrra+", - "/N1urhwMiDbRSihHl7IRK32TFOLbxTf/0bwxCDYMrcjaLEQ5jLToHvDMSQluQAtbCl5hCQ6sLhLWl5H7", - "kWMNiAB7BOSwcJRFYdlFFCjgXDGzD40XPpQ+QMdHA47lkAXBvAXA0IFdgBh2F1v6WOGfJD5Nct5nMXZv", - "5tpZ4wHX48PXRsyt9hfl5fUMJ1rlI4lf0IOgcaYCEeW9GaGOBhTSvLJxoJhBlIl6JdOw3A5OSdH0SfE0", - "1N2C+NEdsJg0ivbmMDEUL7vEII40DIxJWQDG4KqSUTLOTO6WlnhGfrZubZzJCPa98tawIA4k6+tWQEgX", - "lj1yn7z0YNHDo4N8i4lKsgWu1iFdYN8NsJtf8DmxwtkVDAHtF6x0sgu6e0Omt/DR0cy70ksuWiGRXwFn", - "CrbBvAty7Cei/szzcpgayzOJD41QJXJG1ADILdfWErgIpoRQmifKTpjq3JfEklV9zIikC30CP57Ya7k+", - "CSlVJ1j/CfDUmUaQwWFxB8WIXK53SsP0M+yD9wVFc2WIZ98m7y4/toPlVuNN9plFuCEc7FcaCPZ2f5UH", - "2zodHvwVn/gbJV9z4J7lhUfHqPt0YL+xXVh8QjY914meOJltDflmDdXxnZxNz6ZnlLEoFF/LyfnkT9Oz", - "6Z+ogMgSQXfK1/KUVyupTlvL1kKgoAEgRu7wupqcT34U7iJYKDq5wv9ydvbFynL7GTJ1uS+EuRGGURVj", - "pMHG8oWYwrufCtrHrNZUKGStbcZse+GwsF1bNlEb1lisIRcrBlO9EBDJQkyT4Rt6/xmV3tyuUaSshBNl", - "MItRqszWCcqLWXnn/7uffyzYX969+rFgP77+gWnDfhWzd1P23FFk77+yN/J7Yh1dWP8SQ9ImhBfCuu+9", - "aJ7AGZd1upDzLoCjhbitWrODbPTpP9Zicd9v1+ren27EbH3XbzsE4lljDw2ffjE0RMhnkJACRLQRFQNc", - "mwJxfUfonxsuru80Rl5/9/RPo9/uIfbpR/jf6+rTPhKNOLOXQP9giLN7iADJI0skS3qdNyUjiJDI/eF+", - "d4fjAr5q+Eo4Yezk/K8fJyD7hZpNdBNO6AwnfWwukj31d/C3gAZUii45/hyLPEEzOL06Za94uaR/UPF0", - "STZzKz48o5K5nsth1T6QZv5y8fbnKf3T+oi8n7h1J/jLyeuX7Im3JdKzD40wWxa3fYyZtEaA+AlEQtEC", - "6MwPK0K3pb8Ii1Cji7vg5wnl9ozAYNxndDi14NX/RDi1Vb38m16LLXldY0R6W7wvboOSNRXDun9sof0Y", - "uq3Ml2PClAxCQ0zyR7sUvELFyh9uB1KTIoenQ0E+nwo/JkK0HRLB/GWGSuKyB3Ft4FNfynznuzaV828H", - "mY4Tt45Q+MQ3euhQ8kG6JRSlT+9BnIGIyG48REOv27zsgi3lYimsY6EqizcGPYmlmxbcxbyVgs238rig", - "/E8fLkRhQuwHWQPeMMWN8U4uX/jBIBry4B4+siwtbw/D5jDzJ2ldMDzn0LJ3eB2f1Z2P/jOwJkTzj7t0", - "05I8I9Bp/+0+voDNgFF1B/3ecFei1khJ+8EqKg2LC52yl2TzsXCqmKnv8837+Hfa2VxeXH2O5SRsJyCa", - "5gaFVAnKvaGC+yy8bDAorpO+AK+GwGsMNSAvDoZFUg8aiqyFe2E34R45caNCbYscOvqSQq+jQ2ZIXr2/", - "QNYpXTRKGPza6AJShi8QG5HgzsLiHRgcvP1v92WHpx9hiQdEy3b7D6gAJjDOwxSt1VFga0nvYaQ1Asv9", - "pDUyfAxB8414SDCipXMAgiApAYmj3OMB0WQWSUY/v84vT9KJXfEBCPq+wAkk64FzN4ptqWolTueh8MYQ", - "BlBljgfcJ02Q2WgsaZ+iwpHdqVDaxY5+H6FSm4qUB0rFoVI9oR9TO1ZbHbYglwkoEHw23RW4U5h8eYRL", - "wPF4uDZ4BokpgaqJP+z10EFNkOZPvUiwz14mVGWTZlo+wLaHNK2+VZALkfoipP2Act5Y9musMYOvSstW", - "sjrxRSQ7RZ6Woq5QUrEhIdNZpsQt9cXJSSQ+z+qn0IDpyyNTt2rXKKT6ly82eadf2wBudeCfAnTjU1y1", - "ibDFsPnP4XWIUCHFZlC3utyHPDFDp+gpT88pOg2d4yw0wyKpFnGComUpMoDKme0qS4AIIeNg8hiiYveE", - "xkmLbZO3LnBTPx1Ra5MlVme9n6tfXywWE+uCf29NsZwosOuAfBjayjk6/xMKCHA3nkQpYe8lu0mCjuiy", - "ldTiCfVR1S+11fLWLahvRctlidRKro4cW7VdeXbu4bZy2MMccTv+qKP9bgg0ovqMM0gz4LJCGrCOd21a", - "z8OzjRA1OIJjPK/rIFZFOc3f4t1N0BUVRn6g4+wUbnpkZ00EWp5ggRt6QH0BXDn96P86oCyn8H4gLnVg", - "3909f2nFOELhfrpxDpynMRV4kBgvfUbdw1Mi5siNvLhb4y3uoF+k6VuCfzEg6hPl2k7CTRvpH4Plo6+7", - "3fJulFuO/VxSQ8WH4D1JCP0jMx7CkWGuA4B8NLUuT1E+tHlQHCcBUc/TqOXQ1yx41obOGptTYIMgio7a", - "OfofhcPY6gfkgTj+HkshdUu3Dnujz3ltRVsGJhO9rXQAxO+JbepKt7zzq7GOkLqADqw0m4x6k5gqWIbQ", - "guu1+JihVqTKXJu214k9Z5T04L1wMWDnvZhjBdpu8mUno9d/aPmKRsq7e7lxMTL+gXhVjOP+47Gq+zoo", - "glp8+tH/dUDyClr3A3KdMfaX2Ar7QfhIBMX9+EgOpqfezDKOidxzAUNM5Lm9JhJNi7XEKpHEU5ZcVXo+", - "Z08WmtcFq0QpvcEI/czwC6+YUJUtyKIRfZgFle879kXoKdykMcq/nNablzbamCrf8xHoo1fOEhkJshYM", - "98MCPZ53RY4Vlus5XZdpZfjPCwL/IPo+rvkwrB7BlGQBT78m2Z9iZLRp1l8HSV8p7+/wFXbI/LxzkK/D", - "Ir+Ro4xAw3OMDWu+7kl2vA+PfZAXRKpE2oThRLXStmbyWC2w5QzAnIJxzYs7LavCcWQsG1T0vRhUT8Jn", - "D3DsaZGEaWAwXevXCHXvqP1za94eQLkLoaq2ttwf0t+B4F/uMKpvTZpJijUekGOiyvxQqtMekTARB7+0", - "5EI7v5/Y0gXeadnWPcgquK/aTJ9uZc1oz3gS88WKmPLfKPqLcgwU1pGD32G/okTvZtsP/5hhzi8MVnSr", - "KNyhiMKwgq1DTRWsLY4sibrUsMtutVW9UZiXe0v+VeAgTbkUlQ+3DfULirbKqbTdtny+2KHg1eGNUKg0", - "VpAYKgQRVr6vDISvLezEmgr2NVm5yJNCm5vfw7GBUrVOh234aCHaXDiHdkMFsw0chvXtvaeBZ4eo6aXg", - "1ZT9rB3jda03scOL3wwWHMsFP8KZHYqa7C8+gZA6xyCMFmvazH+pei0VDffuIU54jgHXaY5ldoGhYHBm", - "jZ1Y4kzZqfDteRfI2dPuNY6k8zmyoXRwqE3hi/4oqm2ZDycV688NQv5MO6NHwgzbfCfMCRrDQBn51oL9", - "Hpsx6xPeuKWvGzlofERPpg1lhALuUiB/aLAUBagOA2/rhbAnGBSSFHoKnyAPWvIK873QG04xrK0EzBpV", - "kVNVGswBPW777Xo+IU3f2vQ8pFhv09UCV/YFKbE6Gi0+7Q7EOHsRwCKqE/jcNwfK87wX+rkH4aM4HpP2", - "SiO9Hu0hf/uSQuWbsQ+iYqOwXz+m08PRY5tGoOaZcBshFOOYU0/XXfSELDpX03To6sJW8KMi9X3e/vgN", - "D8XsU92hzx7HH8hnj6PrKhTB2quuPVwYXujIP+TC465c/rGZdiXNIIm8VYJV0ojSabPtFeYLYkYR35DC", - "hjb8mAwz2yJ3nbJXyuFD+FeFUjRmF9Ri7phu3DMW5eqKCf8uvBBKVJC1DQvGAiPGpiAoCNWC34huCwKY", - "2+hmsWTTKSXV2O2qluq68NXzG5vPZohEaw7Jmhc1t8sTK+AlRypCzcnroLtFxozWLlSmgbW0peqCs21I", - "5vmqVPNSmp+kdXvyKCNSYMF/PLE/OhVhacZxZq37zDtk03qPlZhsVs0FsvE6Zyhh5GuQkiLp3yc3Hb7t", - "cyuNWHGZsTf5ApRf00QRa2zez1jxZew3hzJyE+CKNKMWS430A13fcRIr19za4CutORYd/8BmvEQNEL/P", - "B7kCtPZmqz5cZikWrO4MteK3vj7b2dlZsb9c26Pk+iFkxsi2v2CbFyo07w/tSbBu/OvZ2fG3L+2C4DqI", - "ks+ZEYum5obk27xhbMouxa2ji9IXuqHuN5xZ+Zsg6DxjlHNPzjX/JlM6ZNE/+BUNQt2XvaMP3MJ3YtMP", - "Ksu+8AMNMEc4kT/2PYz1IoaJIJaAARqIUW07Ei1IaLEOYmgDpir/MXeOl8sVlTVATJcWZDtdNw5Tdodw", - "7Zm/Boywur4BXu8RHy18oXpM0ka5pctuSZkCC8rgki7+/ceClUttfa+jWycUBiAM08/rFfmLxqSQf3kC", - "+L3WILE3i3++XdVfvYQJoG4oYXIvWv/TH5MzoPnu8eVzzM9JmExSMzwm1mk1lwZUY9/90pF/JCY4YdgJ", - "mTnxrg0NNU6k6hYnlepG+gqtjtpVWmIkVJI/FwbDFKBhXW8L38MWWwIMKBTYbEOrWEcch03cRZk9anXA", - "1xVMoz3Lqjel5m2nyKDJ6tqxSbS2yYKtG9yG9ElvcgHAmnPrTubabHjIYW0XVi5Fea1Dw2Z/CiZRr3J6", - "VbBMtPDyvU5ivMCephAy9oQokiKeITsveK6e9PpSwAtWuOM0ctJ3jGhHkApmWRhhbX/W4LoL7Qa7cQ59", - "7aQtGystm8vbUPkMkzrbrhC4PWwJY3ciM3dDJLwBJK6W10bwaku9LrJ5nWbxubHk+U5UGBvcr+B4sIlG", - "p2/GkSUAnSNlUMVg7iu9/fd/SfrVHa6xm06cL/73eMEX+5TwVeyBEppEaOMblfwOoi9OA/N8/Nsgxjt2", - "umGHrjR7OWXb6SiJJSgSWZKrLV4ltSyd9fFGC6GQjtSC+T+BfkFv80VNkyGPbCcKE6nUhoipTka3dD5q", - "XomDxD5Izq+/eoCch3k+0vEbR+F1aH+wzwRFLXN20E3crrUN1zb94wpeLRKLPdyteM+YG381c1nbkiN+", - "mBvRiYiFj5lqVjPyn/pGsEttXcH0jTDsz5eX7y6K/nUh833BBgpfvWsbPjxGQib1ehidCUZr+/ZtRGPQ", - "JpqF8dUCr7W1MEk0UUdEyJ7W+0c8rPd3OqvfzVFl6irswvmbr27Q4TwDtR6+naM4FDmsuhsZii/0RUsq", - "Uj9i1/huTkA+pJcibdMQ8i8f1Nttn//IKUljbuYk9eL3cR3HruNjGGvS4L2ra4Vbl6d998c3rboWgGIu", - "3yL+ddIhftxF7NvO603wB3rrpVtyN3RJX0Q4PAY/CrONrrQSVvc7YP5hqXBep3Ntrh9MWSmyA6kxY3R8", - "eYfSREO+IdsstRXZPHMUwVu7fBIVSwRRxJK+VMWyw4vRrgUIJlXjLTbJAL4CilY3wlieNDazTuuY/9yN", - "XaUuMj7MOuk1fH1MdpGETR2lXD0UV+6EEFIVK7Qt9ZWmlBCprS6lxVL+W1xT6JFsfAkUv6cwPse+frFR", - "c45Ef9Dm+qIN//3/aacHGXtsQTfsV/1VzC50eS2cdwxRC9I0atWPUTDOsFULs0tR1yA8gDTQup2CMQXx", - "uLagdiH7lfPYDTZo1YxyTbE3AqEZyRubUOIvNj1dYqfakjrDrw2QbOWNm9jTqHHrxksmQGXCFozX2CvV", - "u3lDyhbNSQ2tg3Sj/Le9V731wcrfBHYKxz5e8eFS19TBi6Wd4aJ1WnjwiFsZ7N+0TzIvEhmUuhLsu7Oz", - "s2jOhNdDh3ifOWUEt3s9YUmDvxHOsNBQbpAr7m9Olx8U29Pde9C+u+0pEXFPeMUCjS1atggbuob/sX3G", - "TdIRLEvjsW5mX86nzkJ48QCuDpruKi2sOnLZSOHYj+whC5uFOYaKm/nnoV3X9D/ZCaeNUwel86FmxGPa", - "cbKfOIjGopLe9IrtyYTdaXRcaXXkPJgHLukfu/2OHzrQLs4z5G8GIKi4g29QdA5L228p+TW+9RiqSb+B", - "1ggNBdMW0x5m2NCXkCm+19poU6ElQuD0Izz7RAgOGLgLi5f4+zB2ZWru0SfVlGEdLuJ3RBu+kbBPmSn1", - "ensv/BjKhb0fDWx5P0xjVGBFr3vcObL04ba5yAO4SqKBfHx7yOl+IErxPdPuZtRq9mhmGLC0rnmZuLmp", - "ezToYWSvqgUIr8PAwO4wf7l4+3PQjLDijo0eUeCDT0H+eHp2lnpEU4wKTmDCrM5K+jiW4Zrvmi62jLGa", - "jUKUxys/usMz9laP7jHkOxU3/PTp/wUAAP//", + "7L1tcxs5kiD8VxB8nghZuyVKnum9u5XjPrhtd4/n2m2fpb6Ou9kJDViVJDEqAjSAEsV2+L9fIBNAoYoo", + "siRbsnv7vnTLrCq8JDIT+Z4fJ6VarZUEac3k/ONEg1kraQD/8Uprpd0fpZIWpHV/8vW6FiW3QsnTfxol", + "3W+mXMKKu7/+fw3zyfnk/zttRz2lp+aURvv06VMxqcCUWqzdIJNzmmY6cQ/8u26o5wuQ9p1WJRj891qr", + "NWgraGlL4LVdur+6YxnblNdsBVwaxplttGTCMN1IKeSCcVkxqZixGviKwQ1Iy5bcMK61uIGKzZVmOMIV", + "n1vQVwZKJSvzjMGtsFD5ce0S2JoW5gZfKAnTSTEB2awm53+bqOtJQetwP+KHk78XE7tdAy5QC7mYFJPb", + "E/fByQ3Xkq/cpv42+Qtu6q37jv688KPQv175sT4Vk5obe4Xrv+J2Fwq/LkF2luneZxutLDDOaiGBWcWE", + "NR4Uk2IyV3rlhppU3MKJFSuYFBOl2cYNJSwzlmsL1XRyLpu67m/nUzFZi8otxP8upIUFaPfAgDFCyavO", + "8/Y7Y7kFxCzDlGR47Bf0SZysu7vLJTA/6JHbgfs+syDLzfXQpDcgK0Lt3qNPxUTDh0ZoqNyJJGtvB4yf", + "06bDFvqnUgQkbQ9fzf4JpXULSLe5i92lBm6h8mebPZqdLYVvZtvsjudKX8cRe9gi7JL553OtVueIOrTH", + "I8NExdQcf0IssktHSe6f7hN2DbA2BeNsBcbwBf7KXtS8qYC9UBUUgQzxd1XB7TS3+mT63QU+x6mODJsL", + "bTwqMg2mWYGjRmH8YgNWFKxsLOOWxU0XrJEVaMaZhI1/+6o93GeMz4xjBm6Ryi4hDmWyqx3AqpWqoN5d", + "/nv40IBB/uFeeMZgtbbbhJlEUFcw501tp+x5mJ8pWW+Z2QhbLsGwGdgNIEEapjay/RJHzi9WbSTo3VU5", + "KqrVQki2WSoDrAZeMe7QkkAqTFioAwpnDvsduQXSzMykVQ27E2m4EbABfc44o7/bYdgTA8De46/Hz3Zm", + "MawWxrql3eBJgwGmGptyW7Fa17ACacHRo9vEpIhT7vLdlOHEIaraUZS/JCYFXok1jOfbKTFfuNFf04g7", + "v7+PU+w8etHO2eVdvbsyORE8sX3n0XK57iA/qLpWG8MctSECIvbgX+FcNtwwz1HYRtjlOVus7cm/eDxz", + "F6pj1kjPhbtI9dbTDQ3lnrU8ID2vEn9GIFdwmz2gHfLc3UEyeIJMoipY6ZBZstmWzbYanjGlcV+4UmaX", + "2sHMvUeo3UgrOhuPTAZ5FnIag9RGn2bBvFH6el6rzZWxfAF5SsNH7AlC/OlxYKge28MARHhhN7qRpsOY", + "eMsUGmtE5e7y8GmysHj19u6y3h1GbCG5y4h75cDf3nBI4Z27pkgvq+xNZy0vlysvPvbuOe5hlGGmIKsr", + "J6nkhQqxyoIafz5nFbec/fL+p+xxXQtZpeQ/F7inSsznkzCw+ydfaJSMHMgcCCRfm6Vy93qj6yzirkCv", + "eA5d/WDn7A29wYxqdAnZ5a15Tq51izxnGtbqREPNrbgB5t6cMr9lLukv5t5kQvbxy2pwd/HMqLqx4Mgi", + "joOiYJ6hO2l+dzPz+TlbCIvcnFCVy0V+NwFqe6Q/bfec8z4hzh3DQQkOTzuHl9/XapbBSNJ0ruj1j8Ni", + "1hcUzbLbyxFvZ3F3o8KXQr+SVm93dywWUuEk/XN2J0wPSUoB9/05my6EpZ9RnXJPiPPDbVk7puwQ0LAV", + "t+XSKQ7IuCqhobRKbxmfqRvo4ttMqRq4dMt0V2oeT8RvMEQVqMeI3xDrZ1sLKAbFkxHS/pfvMtyxmJjt", + "qhbyOpkvWUlAgL5ixW0LDCZMweZ4maJ2yfyIzLrXjOVbEygxkOCUgHXef7kVceKrDnJrJaQ1TKrNEjQU", + "HphwI0oomFHlNSCA12LduWQr4Ri6Z2w44UhJJqDJ5XYNL3GQ9JcfaMD0p7c0eB9d8Rz9jC2g/TEWEecG", + "MPUnYaxb4g6uOqgHtLWwModMDhHr44FOuNZ8O8xn3V0dkbXo8MjO2WilbCofu4futywTtLqRpaPOA/Oh", + "LWLlKMtv1OGGZBvQwDQ4iQSqHN30gI87KyKw0vlzAI9Wni6svT53mDWFF7Nj32Tvfe62m6VzVECuDpgM", + "7sODw60/Vj26cJQlg5SNyzoyZDIyyPg4aww41WuuysZMm7WbviLdmfQqu4Qtm0GtnL6spuytU+WQ2t2X", + "DEU91LZgNSAJbGvF01W3gF1r5f4cvFbhQwc0w0xw+ILdMYV8mHgwFv4A2yUevH8c63ixdGJCBhuqSjio", + "m7wIMBOS622eS1dQw55PVV1d5elcg2NR/rjcK2wGc0d5SMj4cK90ltUqG5PKlbyqoCK5WswF/onLBdJQ", + "cX4/xVWJoMnomwO07WcrEtilwIhAGzyK1qDbPYsW2Lt8iiRLI48s++Xyh5P/xizc2mf4X4fMyAzz1/ow", + "1Py1PgZR4dZmx9jDXXFpS1VXhuwoljQxbYMC5rY0nqP628vDKJ3ZL28I3C+dbrED67UTkbJbGoBXflU0", + "THZqx5ky83Z4R6JgSrRkD9pc/XdEImKxtGzNJTCzVBvzLNU3wotoO/YMhtidzRtqZ4cu8kturi/5zL9t", + "8vjpnhD7dSx73VhS5QsngyldgZ6yyyitumXRFyTARTY8SrBI19OTKwZNN5ceQAPw2zmHQ6wAAUHQyx3+", + "azlTt68trDIMd8wlO6tVee2lsF2Kvs8VPDBRXx9HY6kDWes6ABRRionmFq5qscr7VLpXZhf4YdBzhkrU", + "rWVP3nB9XamNRPsWHkq5VKIEJ7Oji4Qb9o8T9oQdI4v7BzvFf/5L+OdalNcnSqKc/o8T9jf29/S9v93+", + "PX2Pyy3ZMhG9jgsWFlSwzXJ7hcCGqmAKF2zYE+Dl0i/oyOBILR4fF2zV1Fasa2BPrG7I6B7mOXZia6lW", + "K5AVVOwJGvi4voYqbPAIxzsuGI+2GTNlHWCfM/zflKUgP/f25iL5lcaPdmj34IQe0B11XPiR/kNOipwc", + "I5QWdpsigAfGpJgsvNUJzblOo9mKkYrM93GMH2kMsjG/C9MVkx/cWIclKQ1G1Td3xPT40YDG3/GyjmI4", + "SM3v/Wc5tkMwMvswn0Qb91p7/aE/xdtoIgtnVqlrhi5Q/C0MgVZhbq4DVsUPo1IcXUVoPkVZl94XDt2U", + "LMHrxuFLuBXGdjwNxJ4RKKPZMR1uDizR2H8QuGiFP2RyIivw1WiLTYJcqe11hwGnI0che90ia2TGqcgd", + "jLIJL05Rq8WJweuBEKravSE+E0Ff4IrQJCrka/r8af9wevBqpzywXJNTIVpGNnrFiWE6gzj3ueAqqMUN", + "6C9zLbo9DKGhvyU6W93F1b5gMihAe1PX6OO9dB98KiZOjR1JCGE3/qNoGkoP7qASmcOxR0CFkSfRZbv0", + "4DzIFQZkuNufMbjlpa23zAkP5NiJskQrFnjJAs1ByUtOkAgvdfjj6PPvrZPMkciznVhEJgmydiGbmvp1", + "kzVkZw3n/jGvk+852jwSuQrcZRwAkLeR3Q8J+7gW0QzHO4hEl37WGLuz9pInGU2LSSXMShiTFTaTa6Mz", + "BLgBuDQbxwraMfBv06xBG6gG5Nc3ZEsbwm308GSQ7ftazZiovEe3WbsLwv1W7B6nk2ppGDol9COG078b", + "Qg2EO7xBDzAZRYUhSYDLitXcyRNKgpmyX31UUSXmc9CJJ7p1w+LoRccpnYRBRFtNCDvx1rQ8bmUx/w3f", + "shl4yYPCnDxcNLCFuAE5Qgcb0vp/VlbMfZzcOw1zk/MliHKJ8SjCIRKJPAipuZDCLKFC2Bkm3VhbgiZZ", + "DtG2KDRzuIZ+brZ2K5+SxJGac7zccBXINbOO/xkeeVENyTZ8eM6cfjFlb9GlHuJTskYeFPMz43f0ilaw", + "2x1gzxp/VvIkLCmynoFhWt0kM1Cqzuxbi4a10rkBLlE+xnNBSdbJvAgxzkyzWnG9ZU/c4ylpX/jIPyBY", + "Ho+wN2WOLQVPu7wI9e62cxj5jksYMAihBTvrnXC65WYpaqK1mVYbh38bISu1QW8FfhodgX4gBhSS44hS", + "yMhhWpvHCBCENeW3oq0oxZrnLJiVMOuab68GXYkLYa9gxUU9+DR8mgu92YTQSqTEJUdzqAHLOLrERQXS", + "Crt9RtZ/695egGVSMau5qEFnORQ6DA6LUvRa0d1jsuZ0c1nAeU36iwQcDgLYoWYuiM/93otMQER3t5S+", + "gcqpkUtr1+b89PQ/mrOzP5eOtR0ZtlTG4g9wTr+7keiH0+nneTVancwDEBd/UBJ9R3rdo0RueqZ7NdNc", + "DtiLB4TTwRPSsFJ26NFaXY2zP6dgaz/bWXCc7m4RCx7EQ6LQnr2N3sDO2gfWYVWp6gE36Z3MQhxJr2xs", + "CL8mA8x37H+I7/cY3oM1UkMJ4iZ6jXrb5mYgkupQAHY2tCLMe2TYvKlrVoNc2GUnxGKX7MaGVceYs44J", + "xNsvcB8ELL+4g8dicoE8jdcAg4uM1+86b+wuv+cBpiwBBwRyK/CKr50IW6qmrhzbX3NtUEmrBP5bQ6kW", + "UhhgRsgSSDLzgfMFu4YtVE6Kot1Pc+ZQd8LS5k9jpQxO4S5WJ0UXTnYE470co21lXXz+dMAq48EYV5Y7", + "ivfxUulxfOlVjjaWOQl3Tq8Beq1SgLdpY6Dwhkon7jlFONgLjwwjV+muqJtzauwCMUQGt1Gjz1INwFyL", + "9RryYZ73Ye0QmEYvbslJAtU52yy3+Wh4rwB8CTvSgJb2atymNZimziBkpSSkFmWCaDNbCXvlbcxcLxo0", + "w5y3knHwuRwHPacScmEKht4OkpXhBrSw24KV3MICo3CssDUUrALLRV2Qux41StERJVuMjFGHciAlJSjt", + "PU2FAEH7SlDOWFh7vMP8IYxwK5hR7h9h/0xzmcZgteHkDlaTwh+642k0zUhPBlFXL3w8+fEljZ788kOY", + "KPntIsw5OjHmHuiyP+A4OZSsBXwnEDnYt4P374DA4KPpv4btZK3Van1f68mu/IyjjfD/03tZWIRg5fv5", + "fku1coRsljwnx9NT9PRoAPeWTyFZc+21vjRW7siwv7x6/jJmkm24YZZfg/xyXPaG9vRh2Ol+ZND+ZKzP", + "+otqXPREuWX5u9qbQzWADx6hAE1j475Ys27DpkcErwwJ6LsLfqcMSis9RSks0xSEp0+feeY7x/xGmJtT", + "J22cutfN6UdPdZ9O3XenH+Wnu6pJxSSc7cBDsVjkwuZso+UVyOrcuw+9BgeyOlHzE7QFEvpMo+XuSUtf", + "lKGIdmT6PDWCW6VqVvK6NscF/uPK/aNgbg/LZsXJyuhwRDO+XgPXFPSjZAs9QjwnhAHXtXALbERdmZRx", + "h3jGIm7G/Rnmc7wrTOh+d9ON5OSBKC8JeG/iPL0Hl42Wr3DW/gOl6he0ht6T5+b6L35F/W9ogQd4c4Yd", + "y0mHDyQIkRJciwqH+XPIjunFmeblkzZpJsReeoPwBs2mvDYKnRGUnUN3x2cnKQ1q0N21vZVeJ1Nz9tTx", + "gadnZ04+0Ly0oE3BGik+NIACTRL/3cvTyTH8QzAQ0ljdlENulLx+G+7TffdFjF7cCdQVN3CFXCUvShHm", + "+Dzj3P36CuFO9B3SkVExcteE5mjnLJioamiz/oTEVIJqtFLTSRbPXLbV7ArV1pGxhkki7/hI2tpBCveZ", + "gcLzdP9gWNlod1fW25CbnmfQPn5gAPZrr8pdtab30ZqfyZjKD2jKLbpkTAV0ue6E/5Bk7zU+slqP1oUJ", + "MFdobM8QB9rgQ2a/VJuCqbpyiwjq5t30Yjfee5hng0gMXJW1CCeb1Sf0XYWWTJ2B3U3+RW0Y+lJ53Cne", + "oSu+ZQuFDEY1lvFuSQPvHhOWkfrOULRpyus8kv2mVjORI92LQI8+fCwsARU1qViNWWiE0wUFxffQHAl9", + "yQ1WXQjHQuc96lD+Dy7toJUigX9C6zt0nNBT0WVt3TPu0nIfE7tks8sBW4DmD3mXbnMc+ZKb60x4Njdw", + "5bPydiM2hw3EZa3MUPZ/kIQx8stJwSvQTtFFu5YpufaMeKQxW9U5/fG1rOCWCenVqV9eHxmacM1rsBac", + "GGfEQpIXACUJDC7gzA3YaIdAdGL0Gcrl5B12MiK6a5kjBUctjgnwhcpj+7dQW0GGMLmofXi9iN5k3ObX", + "vrdKQhJfLAxCJRnRfZKpcoDYf6cSBw43rkgu3F0CYY63n3xouFlGZZFwzLsGGKFqx/Tm1kNLlsrXmFCE", + "itn10UI+NNDsw+s37i0cVMciDOTP5DKJAxexWgrFrXsiEHIRYeZjF4GWFDLDQ4EU6cAtDFtCfQdaoT2g", + "dWvgON2jiCsdgNLOVvwazHkSuIkftFntwf6KOUu0YW9ls1fJ7HmpVGl75RjOUDaINraXyI7uPPcbeqfZ", + "kziEO8v4j3/99+OCrbkxUAU/iDevOP0N9EpIXrs7693b95d75aJ9NWV6yTjIOyYe5j4qh3jbSO3NMWQS", + "k5+Hsdqf3oRR259etuO76ysc8q4qrSp1JXyc/gEUuHz78u10RYELXZ7tb8C703lQTPKzu92HyZMqBX7i", + "uVa/Qcqv2ZMFWAeCX8O77u53B1yq9fa4V82AFu9FmHzKA60PNc87+CS7UbcI+S6xxbuyP37R3rApAYRr", + "LUm8uosr00GEkt9M/krPAz+wTk2mKCcecR3rcURedIIElhCi564xKqPLdAtyThWhMsZaw41QjUkN3pip", + "nHdM1HeICU6S/nLxiBpyG8fM53k329Yh0Zw0eLoaMCUWVeMqbuTw+vtRNnS0uI6wtcHjG7AqD9P1OMJq", + "K3jENGIfgc/mGuA3TG+nIksoC623fXKkskFCktUQyc3dRDJra+hHP+Dqh/Z8SXlZgYVazSVtw2G8R+cA", + "t2ISGDfSX0jYwMi2JBI90uZopju7TKe95LMXceZLPvvBT+7ea+e/5LN3cQmXfPbar+KSz97HheBLfi1+", + "w7/uPbJ4UDzlfqlV27OBwp8CGrqXZOfuxSjFA8tPFRbysnOw//v5m5+md4jEyVuDkrmzB+/BeOGjAvpe", + "9ZqMdkKKlUOLp7nbWavNwbd2/Mw1alZqIF3hUlUqUw8As0eysh9dezvcj8r9xYuUW7ydepn7SRxg5HPj", + "bAiqUphyd0hh9QsP4w/tOJ++N1PV1hfM281hcwOiobACaelS1rBSN0npvl188x/NG41gw5CHrM0CymGk", + "RbO9Z04SuHZa2BJ4haUxsOpHWF9G7keONSAC7BGQw8JRFnXLLqJA4c4VM+7QeOFD3AN0fJTeWA5ZEMxb", + "AAwd2IUTw/bYuO+yt47kRxKfIjnvsxi7N3PtrPGAS/DhqynmVvuL9PJ6hhOt8hG+L+hB0DhTgYjy0TTI", + "owGFNK9sHCgyEGWiXpE1LIODU1KUe1JuDXW3IH50BywmjaS9WUzYxMsuMYgjDTvGJI0DxuCqklEyTkZu", + "l4Z4Rn62bs2ayQj2vfLWsCAOJOvrViZIF5Y9cp9U9GBRvaODb4uJTKL4r9YhjH/fDbAb9/85MbzZFQwB", + "7ResQLILuntDprfw0VHGu9JLLoogkV8dzgTHHjncE1F/5nm5mxrLJsGHBmSJnBE1AHL0tTn+F8GUEErm", + "RNkJU5D7kliyqo8ZkXShTtyPJ+ZarE9CqtMJ1mVyeGp1A2RwWNxBMSJX6J3SI/0M++B9QVFWGeLZt8m7", + "y4/tYLnVeJN9ZhF2CAf7FQCCvd1f5cG2Tofn/opP/I2SrwVwz4LEo2PHfZqu39guLD4hm56rRE+czLYa", + "8MrTVPl3cjY9m55RJiFIvhaT88mfp2fTP1NhjyWC7pSvxSmvVkKetpatBaCg4UCM3OF1NTmf/Aj2Ilgo", + "Ojm8fzo7+2KFvP0MmUreF6BvQDOqe4w02Bi+gKl791NB+5jVigp4rJXJmG0vLBaca8sZKs0ag7XdYo1h", + "quPhRLIQa6T5ht5/RsU6t2sUKSuwUAazGKWwbC1QvsqqMZbNgL37+ceC/fXdqx8L9uPrH5jS7FeYvZuy", + "55Yibv+NvRHfE+vowvqXGCo2IbwAY7/3onkCZ1zW6ULMuwCOFuK2mswOstGn/1zD4r7fruW9P93AbH3X", + "bzsE4lljDw2ffjE0RMhnkJCiJpSGijlcmzri+o7QPzdcXN9pjIj+7umfR7/dQ+zTj+5/r6tP+0g04sxe", + "Av2DIc7uITpIHhkiWdLrvCkZQYRE7g/3uzscl+Ormq/AgjaT8799nDjZL9RSoptwQmc46WNzkeypv4O/", + "BzSgEnHJ8edY5AmawenVKXvFyyX9g8qtC7KZG/jwjErZei6H1fScNPPXi7c/T+mfxkfK/cSNPcFfTl6/", + "ZE+8LZGefWhAb1nc9jFmuGpw4qcjEooWQGd+WBG6Lf1FWITaWdwGP08og6cBg2Sf0eHUwKv/jnBqq235", + "N70WW/K6xkjxtqhe3AYlUUqG9fjYQvkxVFsxL8eEKUmDhpjkj3YJvELFyh9uB1KTIoenQ0E+nwo/JkK0", + "HRLB/GWGSuKlB3Ft4FNf/HznuzbF8u8HmY6FW0sofOJbQ3Qo+SDdEorSp/cgzkBEZDceoqHXbb50wZZi", + "sQRjWaiW4o1BT2JJpQW3MZ+kYPOtOC4oL9OHC1GYEPtB1A5vmORaeyeXL8igEQ15cA8fGZYWxHfD5jDz", + "J2FsMDzn0LJ3eB2f1Z2P/jOwJkTZj7t001I5I9Bp/+0+vrDMgFF1B/3ecFui1kjJ9MEqKjSLC52yl2Tz", + "Me5UMYPe54H38e+0s7m8uPocyzyYTqAyze0UUgmUE0Ml+ll4WWNQXCetwL0aAqIx1IC8OBgWSV1rKNzU", + "3Qu7ifDIiRsZak7k0NGX+nkdHTJD8ur9BbJOSaFRwuDXRhcnZfjCrREJ7iws3oHBubf//b7s8PSjW+IB", + "0bLd/gMqgAmM8zBFa3UU2FrSexhpjcByP2mNDB9D0HwDDwlGtHQOQNBJSo7EUe7xgGgyiySjn1/nlyfp", + "xK74AAR9X+AEkvXAuRvFtlS1gtN5KIgxhAFUMeMB90kTZDYaS82nqHBkdiqHdrGj33moVLoi5YFSZKiE", + "Tujg1I7VVm0tyGXiFAg+m+4K3ClMvjzCJeB4PFwbPIPElEBVvh/2euigppPmT71IsM9eBrIySfstH2Db", + "Q5pW3yrIhUj9CtIOQjlvLPs11n7BV4VhK1Gd+OKOneJLS6grlFRMSJS0hkm4pU46OYnE5z/9FFo2fXlk", + "6lbTGoVUf/pik3c6vA3gVgf+KUA3PvVU6QhbDJv/HF6HCBVSbAZ1q8t9yBMzdIqe8vScotPQOc5C+yyS", + "ahEnKFqWIgOozNiusuQQIWQcTB5DVOye0DhpsW0L1wVu6qcjam2yxGqN93P1637FIl9d8O+t9ZUTBXYd", + "kA9DWzlH539CAcHdjSdRSth7yW6SoCO6bAW1XkJ9VPZLYLW8devUt6LlskRqJZdHlq3abjk793Bb0eth", + "jrgdf9TRfjcEGqg+4wzSDLiskOZYx7s2refh2UaIGhzBMZ7XdRCropzmb/HuJuiKCiM/0HF2Cio9srMm", + "Ai1PsI4bekB9AVw5/ej/OqAsp/B+IC51YN/dPX9pxThC4X66cQ6cpzEVeJAYL31G3cNTIubIjby4W+Mt", + "7qBfPOlbgn8xIOoT5ZpOwk0b6R+D5aOvu93ybpRbjv1cUqPDh+A9SQj9IzMewpFhruMA+WhqXZ6ifGjz", + "oDhOAqKap1HLod9Y8KwNnTU2jcDGPRQdtXP0P4LF2OoH5IE4/h5LIfVXNxa7qc95baAtz5KJ3pYqAOL3", + "xDZVpVre+dVYR0hdQAdWmk1GPUN0FSxDaMH1WnzMUCtSZa5N2+vEnjNKevBeuBiw8x7mWBm2m3zZyej1", + "Hxq+opHy7l6ubYyMfyBeFeO4/3is6r4OiqAWn370fx2QvILW/YBcZ4z9JTbPfhA+EkFxPz6Sg+mpN7OM", + "YyL3XMAQE3lurolE02ItsXoj8ZQll5Waz9mTheJ1wSoohTcYoZ/Z/cIrBrIyBVk0og+zoLJ6x744PIWb", + "NFr6l9M68MJEG1PlezE6+uiVmURGgqwFw/2w5I/nXZFjheV6TtdlWhn+84LAP4i+j2s+DKtHMCVZwNOv", + "SfanGBmtm/XXQdJX0vs7fIUdMj/vHOTrsMhv5Cgj0PAcYyOZr3uSHe/DYx/kBZEqkTZhOFGtMK2ZPFbx", + "azmDY07BuObFnZZV4Tgilg0q+l4Mqifhswc49ppIwjQwmK71a4R6dNSWuTVvD6DcBciqrfn2h/R3IPiX", + "O4zqW5NmkiKKB+SYqDI/lOq0RyRMxMEvLbnQzu8ntnSBd1q2dQ+yCu6rNtOnW/Ey2jOexHyxIqb8N5L+", + "ohwDiXXk3O9uv1Cid7PtU3/MMOfXDVZ0qyjcoYjCsIKtQk0VrPmNLIm6x7DLbhVUtZGYl3tL/lXHQZpy", + "CZUPtw31C4q2+qgw3XZ5vgIg8OrwRihUGitIDBWCCCvfVwbC1/y1sKaCfU1WLvKk0Obm93BsoISsVWEb", + "PlqINhfOod1QwUzjDsP4ttvTwLND1PQSeDVlPyvLeF2rTey84jeDBcdywY/uzA5FTfYXn0BInmMQRos1", + "bea/kL1Wh5p79xAnPMeA6zTHMrvAUMg3s8ZOLHGm7FT49rwL5Oxp9xo60vkcmVDSN9Sm8EV/JHWxyYeT", + "wvpzg5A/087okTDDNt+BPkFjmFNGvrVgv8dmzOqEN3bp60YOGh/Rk2lCGaGAuxTIHxofRQGqw8DbeiHs", + "CQaFJIWewifIg5a8wnwv9IZTDGsrAbNGVuRUFRpzQI/bPrieTwjdtzY9DynW23S1jiv7gpRYHY0Wn3bt", + "YZy9CGCB6sR97pv25HneC/Xcg/BRHI9J26ORXo/2kL99SaHyTdIHUbGR2Ecf0+nd0WP7REfNM7AbAMk4", + "5tTTdRc9IYvO1TQdurqwRfuoSH2ftz9+w0Mx+1R36LPH8Qfy2eOougpFsPaqaw8Xhhc65Q+58Lgtl39s", + "pl0JPUgibyWwSmgordLbXmG+IGYU8Q0BJrTHx2SY2Ra565S9khYfun9VKEVjdkENc8tUY5+xKFdXDPy7", + "7oVQooKsbVgw1jFibNaBglAN/Aa6rQHc3Fo1iyWbTimpxmxXtZDXha9q35h8NkMkWn1I1ryouVmeGHAv", + "WVIRak5eB9UtMqaVsqEyjVtLW6ouONuGZJ6vSjUvhf5JGLsnjzIiBRbixxP7o1MRlmYcZ9a6z7xDNq33", + "WInJZNVcRzZe5wwljHwNUlIk/fvkpsO3fW6lhhUXGXuTL0D5NU0Uscbm/YwVX8Z+cygjNwEupBm1WGqk", + "H+j6jpNYuebGBF9pzbHo+Ac24yVqgPh9PsjVQWtvturDZZZiwerOUCt+6+uznZ2dFfvLtT1Krh9CZoxs", + "+wu2X6FC8/7QngTrxr+dnR1/+9KuE1wHUfI507Boaq5Jvs0bxqbsEm4tXZS+0A11peHMiN+AoPOMUc49", + "Odf8m0yqkEX/4Fe0E+q+7B194Ba+E5t+UFn2hR9ogDm6E/lj38NYL2KYCGIJGEcDMaptR6J1Elqsgxja", + "c8nKf8xja38zJUwXxsl2qm4spuwO4dozfw1oMKq+cbzeIz5a+EL1mKS9cUuX3ZIyBRaUwSVd/K8fC1Yu", + "lfE9iG4tSAxAGKaf1yvyF41JIf/yBPB7rUFibhb/eruqv3oJE4e6oYTJvWj9z39MzoDmu8eXzzE/J2Ey", + "Sc3wmFin5Fxopxr7rpSW/CMxwQnDTsjMiXdtaKhxImS3OKmQN8JXaLXURtIQI6GS/LkwGCYdGtb1tvC9", + "ZbElwIBCgc02lIx1xHHYxF2U2aOSB3xdwTTas6x6U2redooMmqyuHZtEa5ss2LrBbQif9CYWDlhzbuzJ", + "XOkNDzms7cLKJZTXKjRS9qegE/Uqp1cFy0QLL9/rJMYL7GkKIWJPiCIp4hmy84Ln6kmvL4V7wYA9TiMn", + "fceIdgQh3SwLDcb0Zw2uu9AGsBvn0NdO2rKxwrC5uA2VzzCps+0KgdvDljBmJzJzN0TCG0DianmtgVdb", + "6nWRzevUi8+NJc93osLY4H4Fx4NNNDp9M44MAegcKYMqBnNf6e2//ilp4na4xm46cb743+MFX+xTwlex", + "B0poEqG0b1TyO4i+OA3M8/Fvgxjv2OlSHbrS7OWUbaejJJagSGRJLrd4ldSitMbHGy1AIh3JBfN/Ovp1", + "epsvapoMeWQ6UZhIpSZETHUyuoX1UfMSDhL7IDm//uoBch7m+UjHbxyF16H9wT4TFLXM2UE3uF0rE65t", + "+seVe7VILPbubsV7Rt/4q5mL2pQc8UPfQCci1n3MZLOakf/UN2hdKmMLpm5As79cXr67KPrXhcj3BRso", + "fPWubfjwGAmZ1OthdCYYre3btxGNQZtoFsZXC7zW1qCTaKKOiJA9rfePeFjv73RWv5ujytRV2IXzN1/d", + "oMN5Bmo9fDtHcShyWHY3MhRf6IuWVKR+xG7u3ZyAfEgvRdqmIeRfPqi329b+kVOSxtzMSerF7+M6jt3A", + "xzDWpPF6V9cKty5P++GPb1p1DQ7FbL51++ukc/u4i9i3g1eb4A/01ku75Hbokr6IcHgMfhRmG11pJazu", + "d8D8w1LdeZ3Olb5+MGWlyA4kx4zR8eUdShMN+YZss1QGsnnmKIK3dvkkKpYIooglfamKZYcXo13LIZiQ", + "jbfYJAP4CihK3oA2PGlsZqxSMf+5G7tKXWR8mHXSa/j6mOwiCZs6Srl6KK7cCSGkKlZoW+orTSkhUltd", + "Soul/Le4ptAjWfsSKH5PYXyOff1io+Ycif6g9PVFG/77/9JODzL22IJu2K/6K8wuVHkN1juGqAVpGrXq", + "xygYZ9iqhZkl1LUTHpw00LqdgjEF8bg2Tu1C9ivmsRts0KoZ5ZpibwRCM5I3NqHEX2x6usROtSV1hl9r", + "R7KVN25iT6PGrhsvmTgqA1MwXmOvVO/mDSlbNCc1tA7SjfTf9l711gcjfgPsFI59vOLDpaqpgxdLO8NF", + "6zR48MCtCPZv2ieZF4kMSlUB++7s7CyaM93roUO8z5zSwM1eT1jS4G+EMyw0lBvkivub0+UHxfZ09x60", + "7257SkTcE16xQGOLli3Chq7hf2yfcZN0BMvSeKyb2ZfzqbMQXjwOVwdNd5UCI49sNlI49iN7yMJmYY6h", + "4mb+eWjXNf1PdsJp49RB6XyoGfGYdpzsJ+5EY6iEN71iezIwO42OKyWPrAfzwCX9Y7ff8UMH2sV5hvzN", + "Dggy7uAbFJ3D0vZbSn6Nbz2GatJvoDVCQ8G0xbSHGTb0JWSK77U22lRoiRA4/eiefSIEdxi4C4uX+Psw", + "dmVq7tEn1ZRhHS7id0QbvpGwT5kp1Xp7L/wYyoW9Hw1seT9MY1RgRa973Dmy9OG2ucgDuEyigXx8e8jp", + "fiBK8T3T7mbUavZoZhiwtK55mbi5qXu008PIXlWDE16HgYHdYf568fbnoBlhxR0TPaKODz518sfTs7PU", + "I5piVHACE2Z1VtLHsQzXfNd0sWWM1WwUojxe+dEdnrG3enSPId+puOGnT/83AAD//w==", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/server.go b/internal/server/server.go index 1f63810..2803119 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -281,7 +281,7 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st workflowName = *req.Workflow } - t, err := s.agents.CreateTask(r.Context(), p, req.Title, actor(r), p.DefaultBranch, actor(r), workflowName) + t, err := s.agents.CreateTask(r.Context(), p, req.Title, actor(r), p.DefaultBranch, actor(r), workflowName, "") if err != nil { writeAgentError(w, err) return diff --git a/internal/store/store.go b/internal/store/store.go index 3d94aad..8d38a27 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -347,6 +347,16 @@ CREATE TABLE workflows ( ); ALTER TABLE tasks ADD COLUMN workflow_name TEXT NOT NULL DEFAULT ''; ALTER TABLE tasks ADD COLUMN workflow_definition TEXT NOT NULL DEFAULT ''; +`}, {"run-workflow-stages", ` +ALTER TABLE tasks ADD COLUMN workflow_brief TEXT NOT NULL DEFAULT ''; +ALTER TABLE agent_sessions ADD COLUMN workflow_stage INTEGER NOT NULL DEFAULT 0; +ALTER TABLE agent_sessions ADD COLUMN stage_output TEXT NOT NULL DEFAULT ''; +ALTER TABLE agent_sessions ADD COLUMN stage_finished INTEGER NOT NULL DEFAULT 0; +UPDATE workflows SET definition = json_set(definition, '$.stages', + (SELECT json_group_array(json_remove(value, '$.vendor')) FROM (SELECT value FROM json_each(definition, '$.stages') ORDER BY key))); +UPDATE tasks SET workflow_definition = json_set(workflow_definition, '$.stages', + (SELECT json_group_array(json_remove(value, '$.vendor')) FROM (SELECT value FROM json_each(workflow_definition, '$.stages') ORDER BY key))) + WHERE workflow_definition != ''; `}} const ( @@ -615,6 +625,7 @@ 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 := "" + workflowBrief := "" if frozen != nil { b, err := json.Marshal(frozen.Definition) if err != nil { @@ -622,6 +633,7 @@ func (s *Store) CreateTask(ctx context.Context, t api.Task, frozen *FrozenWorkfl } workflowName = frozen.Name workflowDefinition = string(b) + workflowBrief = frozen.Brief t.Workflow = &workflowName } todoItem := "" @@ -660,9 +672,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) - 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) + 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) + 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) if err != nil { return err } @@ -1005,11 +1017,15 @@ func (s *Store) CreateSession(ctx context.Context, sess api.AgentSession, projec forkedFrom = *sess.ForkedFrom forkedAt = *sess.ForkedAt } + stage := 0 + if sess.WorkflowStage != nil { + stage = *sess.WorkflowStage + } return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { _, err := tx.ExecContext(ctx, ` - INSERT INTO agent_sessions (id, task_id, owner, vendor, model, vendor_session_id, state, role, created_by, created_at, forked_from, forked_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, sess.Id, nullable(sess.TaskId), sess.Owner, string(sess.Vendor), sess.Model, sess.VendorSessionId, string(sess.State), string(sess.Role), sess.CreatedBy, formatTime(sess.CreatedAt), forkedFrom, forkedAt) + INSERT INTO agent_sessions (id, task_id, owner, vendor, model, vendor_session_id, state, role, created_by, created_at, forked_from, forked_at, workflow_stage) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, sess.Id, nullable(sess.TaskId), sess.Owner, string(sess.Vendor), sess.Model, sess.VendorSessionId, string(sess.State), string(sess.Role), sess.CreatedBy, formatTime(sess.CreatedAt), forkedFrom, forkedAt, stage) if err != nil { return err } @@ -1250,7 +1266,7 @@ const projectColumns = `id, name, repo_path, default_branch, remote, created_by, const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, closed_at, merge_commit, todo_item, forked_from, forked_at, merge_queued_at, workflow_name` -const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, created_by, created_at, forked_from, forked_at` +const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, created_by, created_at, forked_from, forked_at, workflow_stage` type scanner interface { Scan(dest ...any) error @@ -1328,10 +1344,14 @@ func scanSession(row scanner) (api.AgentSession, error) { createdAt := "" forkedFrom := "" forkedAt := "" - err := row.Scan(&sess.Id, &sess.TaskId, &sess.Owner, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.CreatedBy, &createdAt, &forkedFrom, &forkedAt) + stage := 0 + err := row.Scan(&sess.Id, &sess.TaskId, &sess.Owner, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.CreatedBy, &createdAt, &forkedFrom, &forkedAt, &stage) if err != nil { return sess, err } + if stage > 0 { + sess.WorkflowStage = &stage + } if forkedFrom != "" { sess.ForkedFrom = &forkedFrom sess.ForkedAt = &forkedAt diff --git a/internal/store/workflow.go b/internal/store/workflow.go index fc766dc..1726815 100644 --- a/internal/store/workflow.go +++ b/internal/store/workflow.go @@ -13,6 +13,7 @@ import ( type FrozenWorkflow struct { Name string Definition api.WorkflowDefinition + Brief string } func (s *Store) ListWorkflows(ctx context.Context) ([]api.WorkflowSummary, error) { @@ -89,19 +90,104 @@ func (s *Store) FreezeWorkflow(ctx context.Context, name string) (FrozenWorkflow } func (s *Store) TaskWorkflow(ctx context.Context, taskID string) (FrozenWorkflow, error) { - name := "" + f := FrozenWorkflow{} def := "" - err := s.db.QueryRowContext(ctx, `SELECT workflow_name, workflow_definition FROM tasks WHERE id = ?`, taskID).Scan(&name, &def) + err := s.db.QueryRowContext(ctx, `SELECT workflow_name, workflow_definition, workflow_brief FROM tasks WHERE id = ?`, taskID).Scan(&f.Name, &def, &f.Brief) if errors.Is(err, sql.ErrNoRows) { return FrozenWorkflow{}, fmt.Errorf("%w: no task %s", ErrNotFound, taskID) } if err != nil { return FrozenWorkflow{}, err } - if name == "" { + if f.Name == "" { return FrozenWorkflow{}, fmt.Errorf("%w: task %s has no workflow", ErrNotFound, taskID) } - f := FrozenWorkflow{Name: name} err = json.Unmarshal([]byte(def), &f.Definition) return f, err } + +func (s *Store) SetStageOutput(ctx context.Context, sessionID, output string) error { + return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { + _, err := tx.ExecContext(ctx, `UPDATE agent_sessions SET stage_output = ? WHERE id = ?`, output, sessionID) + return err + }) +} + +func (s *Store) FinishStage(ctx context.Context, sessionID string) (bool, error) { + finished := false + err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { + res, err := tx.ExecContext(ctx, `UPDATE agent_sessions SET stage_finished = 1 WHERE id = ? AND stage_output != ''`, sessionID) + if err != nil { + return err + } + n, err := res.RowsAffected() + finished = n > 0 + return err + }) + return finished, err +} + +func (s *Store) DropStageOutput(ctx context.Context, sessionID string) error { + return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { + _, err := tx.ExecContext(ctx, `UPDATE agent_sessions SET stage_output = '' WHERE id = ? AND stage_finished = 0`, sessionID) + return err + }) +} + +func (s *Store) StageOutput(ctx context.Context, sessionID string) (string, error) { + output := "" + err := s.db.QueryRowContext(ctx, `SELECT stage_output FROM agent_sessions WHERE id = ?`, sessionID).Scan(&output) + return output, err +} + +func (s *Store) FinishedStages(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id FROM agent_sessions WHERE stage_finished = 1 ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + ids := []string{} + for rows.Next() { + id := "" + err = rows.Scan(&id) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (s *Store) ClearStage(ctx context.Context, sessionID string) error { + return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { + return clearStage(ctx, tx, sessionID) + }) +} + +func clearStage(ctx context.Context, tx *sql.Tx, sessionID string) error { + _, err := tx.ExecContext(ctx, `UPDATE agent_sessions SET stage_output = '', stage_finished = 0 WHERE id = ?`, sessionID) + return err +} + +func (s *Store) FinishWorkflow(ctx context.Context, task api.Task, sessionID, workflowName, output string) error { + return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { + err := clearStage(ctx, tx, sessionID) + if err != nil { + return err + } + return emit(api.Event{ + Kind: "task.workflow_finished", + ProjectId: &task.ProjectId, + TaskId: &task.Id, + Actor: "byre", + Payload: map[string]any{"id": task.Id, "workflow": workflowName, "output": output}, + }) + }) +} + +func (s *Store) WorkflowFinished(ctx context.Context, taskID string) (bool, error) { + finished := false + err := s.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM events WHERE task_id = ? AND kind = 'task.workflow_finished')`, taskID).Scan(&finished) + return finished, err +} diff --git a/internal/workflow/file.go b/internal/workflow/file.go index 60261db..5ce4987 100644 --- a/internal/workflow/file.go +++ b/internal/workflow/file.go @@ -12,7 +12,7 @@ import ( "g.fc.run/misc/byre/internal/api" ) -var keyOrder = []string{"description", "stages", "name", "vendor", "model", "prompt"} +var keyOrder = []string{"description", "stages", "name", "model", "prompt"} func Parse(data []byte) (api.WorkflowDefinition, error) { raw := any(nil) diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 8188fb3..5feb83d 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -62,13 +62,8 @@ func Validate(def api.WorkflowDefinition) error { } seen[s.Name] = true - switch { - case !s.Vendor.Valid(): - problems = append(problems, fmt.Sprintf("%s: vendor must be claude or codex, not %q", where, s.Vendor)) - case strings.TrimSpace(s.Model) == "": + if strings.TrimSpace(s.Model) == "" { problems = append(problems, fmt.Sprintf("%s: model is required", where)) - case string(VendorOf(s.Model)) != string(s.Vendor): - problems = append(problems, fmt.Sprintf("%s: %s is a %s model, not %s", where, s.Model, VendorOf(s.Model), s.Vendor)) } if strings.TrimSpace(s.Prompt) == "" { diff --git a/web/src/api.gen.ts b/web/src/api.gen.ts index a32c13b..43d284c 100644 --- a/web/src/api.gen.ts +++ b/web/src/api.gen.ts @@ -923,11 +923,7 @@ export interface components { Stage: { /** @description One line of 1 to 100 characters, unique within the workflow. */ name: string; - /** - * @description Must be the vendor that runs model (gpt-* models run on Codex, every other model on Claude Code). - * @enum {string} - */ - vendor: "claude" | "codex"; + /** @description The stage agent's model, which also picks its vendor: gpt-* models run on Codex, every other model on Claude Code. */ model: string; /** @description The stage agent's instructions. */ prompt: string; @@ -1076,6 +1072,8 @@ export interface components { forked_from?: string; /** @description With forked_from: the vendor's id of the last thing the fork keeps, a message for Claude Code, a turn for Codex. */ forked_at?: string; + /** @description The stage (from 1) of the task's workflow this session runs; absent for a session outside a workflow. */ + workflow_stage?: number; }; SessionCreate: { prompt: string;