buttons
This commit is contained in:
+25
-6
@@ -131,15 +131,34 @@ paths:
|
||||
post:
|
||||
operationId: mergeTask
|
||||
description: >
|
||||
Squashes the task's worktree, uncommitted changes included, into one commit on the project's
|
||||
default branch, fast-forwards the project's checkout to it, then removes the worktree and branch.
|
||||
Nothing is pushed. Refused while a turn is running or queued, and on merge conflicts.
|
||||
Starts the same agent turn as merge-in. When that turn ends normally, byre squashes the task's worktree
|
||||
into one commit on the project's default branch, fast-forwards the project's checkout to it, then removes
|
||||
the worktree and branch and marks the task merged. Nothing is pushed. Refused while a turn is running or queued.
|
||||
responses:
|
||||
"200":
|
||||
description: The merged task.
|
||||
"202":
|
||||
description: The merge turn was started or queued.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Task" }
|
||||
schema: { $ref: "#/components/schemas/AgentSession" }
|
||||
"400": { $ref: "#/components/responses/Error" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/tasks/{taskId}/merge-in:
|
||||
parameters:
|
||||
- { name: taskId, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
operationId: mergeIn
|
||||
description: >
|
||||
Asks the task's agent to merge the project's default branch into the task branch, resolving any conflicts
|
||||
and regenerating generated files. Sent to the task's idle session, or starts a session when it has none.
|
||||
Refused while a turn is running or queued.
|
||||
responses:
|
||||
"202":
|
||||
description: The merge turn was started or queued.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AgentSession" }
|
||||
"400": { $ref: "#/components/responses/Error" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
+4
-2
@@ -19,9 +19,11 @@ Schema changes are the `migrations` slice in `internal/store/store.go`: named en
|
||||
|
||||
## Closing a task
|
||||
|
||||
A task ends `merged` or `discarded`; either way its sessions are stopped, its worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI shows it in the confirm dialog of both buttons.
|
||||
A task ends `merged` or `discarded`; either way its sessions are stopped, its worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI shows it in the confirm dialog of both buttons, which are disabled while the task's session is `running` or `queued`.
|
||||
|
||||
`POST /api/tasks/{id}/merge` squashes the worktree as it stands, uncommitted and untracked files included (captured through a temporary index, so the worktree isn't touched), onto the default branch with `git merge-tree --merge-base`, makes one commit with `git commit-tree` authored and committed by the merging user, and runs `git merge --ff-only` in the project's checkout, which must have the default branch checked out. The commit message is the task title. Nothing is pushed. It is refused (409) while a session is `running` or `queued`, on merge conflicts (the message lists the files; the task stays active), and if the default branch moved between the merge and the fast-forward. When the merge changes nothing, no commit is made. `POST /api/tasks/{id}/discard` removes everything without merging.
|
||||
`POST /api/tasks/{id}/merge-in` (the transcript's **Merge main in** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. It is refused (409) while a session is `running` or `queued`.
|
||||
|
||||
`POST /api/tasks/{id}/merge` starts the same turn and records, in memory, that the task is to be merged by the requesting user, who must have a git identity (400 otherwise). When that turn ends normally (not interrupted, not an error), byre squashes the worktree, now containing the default branch, onto it with `git merge-tree --merge-base`, makes one commit with `git commit-tree` authored and committed by the merging user, runs `git merge --ff-only` in the project's checkout (which must have the default branch checked out), then closes the task. The commit message is the task title. Nothing is pushed. When the merge changes nothing, no commit is made. If the squash fails, for example because the default branch moved again during the turn, byre files an error in the inbox and the task stays active. An interrupted or failed turn, or a byre restart during it, leaves the task active without squashing. `POST /api/tasks/{id}/discard` removes everything without merging.
|
||||
|
||||
## Running as a service
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ type Manager struct {
|
||||
live map[string]*live
|
||||
closing bool
|
||||
rateLimits map[api.AgentSessionVendor]map[string]any
|
||||
merges map[string]string
|
||||
|
||||
deliverMu sync.Mutex
|
||||
capMu sync.Mutex
|
||||
@@ -80,6 +81,7 @@ func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
|
||||
worktreeRoot: worktreeRoot,
|
||||
live: map[string]*live{},
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
merges: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +720,11 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
go m.continueFromHandoff(l.id, res.Result)
|
||||
return nil
|
||||
}
|
||||
mergeBy, merging := m.takeMerge(l.taskID)
|
||||
if merging && finished {
|
||||
go m.finishMerge(l, mergeBy)
|
||||
return nil
|
||||
}
|
||||
err = m.Deliver(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
|
||||
@@ -530,6 +530,110 @@ func TestCompactCarriesHandoff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
_, err := m.Create(e.ctx, e.task, e.user, `Create a file task.txt containing exactly the word task.`, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
|
||||
err = os.WriteFile(filepath.Join(e.repo, "main.txt"), []byte("main\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write main.txt: %v", err)
|
||||
}
|
||||
gitOut(t, e.repo, "add", "main.txt")
|
||||
gitOut(t, e.repo, "commit", "--message", "main change")
|
||||
mainHead := gitOut(t, e.repo, "rev-parse", "HEAD")
|
||||
|
||||
_, err = m.MergeIn(e.ctx, e.task, e.user, "main")
|
||||
if err != nil {
|
||||
t.Fatalf("merge in: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
|
||||
parents := strings.Fields(gitOut(t, e.task.WorktreePath, "log", "-1", "--format=%P"))
|
||||
if len(parents) != 2 || parents[1] != mainHead {
|
||||
t.Fatalf("HEAD parents %v, want a merge of main %s", parents, mainHead)
|
||||
}
|
||||
for _, f := range []string{"task.txt", "main.txt"} {
|
||||
_, err = os.Stat(filepath.Join(e.task.WorktreePath, f))
|
||||
if err != nil {
|
||||
t.Fatalf("%s missing after merge: %v", f, err)
|
||||
}
|
||||
}
|
||||
if status := gitOut(t, e.task.WorktreePath, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
||||
t.Fatalf("worktree dirty after the merge turn:\n%s", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeResolvesConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
_, err := m.Create(e.ctx, e.task, e.user, `Create a file notes.txt containing exactly the line "task".`, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
err = os.WriteFile(filepath.Join(e.task.WorktreePath, "uncommitted"), []byte("three\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write uncommitted: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(e.repo, "notes.txt"), []byte("main\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write notes.txt: %v", err)
|
||||
}
|
||||
gitOut(t, e.repo, "add", "notes.txt")
|
||||
gitOut(t, e.repo, "commit", "--message", "main change")
|
||||
moved := gitOut(t, e.repo, "rev-parse", "HEAD")
|
||||
|
||||
_, err = m.Merge(e.ctx, e.task, e.project, e.user)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
e.waitFor("task merged", func(ev api.Event) bool { return ev.Kind == "task.merged" })
|
||||
|
||||
task, err := e.store.GetTask(e.ctx, e.task.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if task.Status != api.TaskStatusMerged || task.MergeCommit == nil {
|
||||
t.Fatalf("task after merge %+v", task)
|
||||
}
|
||||
if head := gitOut(t, e.repo, "rev-parse", "main"); head != *task.MergeCommit {
|
||||
t.Fatalf("main at %s, want merge commit %s", head, *task.MergeCommit)
|
||||
}
|
||||
if parent := gitOut(t, e.repo, "rev-parse", "main^"); parent != moved {
|
||||
t.Fatalf("merge commit parent %s, want %s", parent, moved)
|
||||
}
|
||||
if log := gitOut(t, e.repo, "log", "-1", "--format=%s|%an|%cn"); log != task.Title+"|"+gitName+"|"+gitName {
|
||||
t.Fatalf("unexpected merge commit %s", log)
|
||||
}
|
||||
notes, err := os.ReadFile(filepath.Join(e.repo, "notes.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read notes.txt: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(notes), "task") || !strings.Contains(string(notes), "main") || strings.Contains(string(notes), "<<<<<<<") {
|
||||
t.Fatalf("notes.txt on main doesn't hold both sides:\n%s", notes)
|
||||
}
|
||||
_, err = os.Stat(filepath.Join(e.repo, "uncommitted"))
|
||||
if err != nil {
|
||||
t.Fatalf("uncommitted file not on main: %v", err)
|
||||
}
|
||||
if status := gitOut(t, e.repo, "status", "--porcelain"); status != "" {
|
||||
t.Fatalf("main checkout dirty after merge: %s", status)
|
||||
}
|
||||
if branches := gitOut(t, e.repo, "branch", "--list", e.task.Branch); branches != "" {
|
||||
t.Fatalf("branch still exists: %s", branches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
)
|
||||
|
||||
func mergeInPrompt(target string) string {
|
||||
return fmt.Sprintf("Merge %[1]s into this branch: run `git merge --no-ff --no-commit %[1]s`. "+
|
||||
"Resolve any conflicts keeping both sides' intent; regenerate generated files instead of hand-merging them. "+
|
||||
"Check that the build and tests pass. Leave the merge uncommitted: byre concludes it when the turn ends.", target)
|
||||
}
|
||||
|
||||
func (m *Manager) MergeIn(ctx context.Context, task api.Task, user api.User, target string) (api.AgentSession, error) {
|
||||
sessions, err := m.store.ListSessions(ctx, task.Id)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
for _, sess := range sessions {
|
||||
if sess.State != api.AgentSessionStateCompleted {
|
||||
return m.Send(ctx, sess.Id, "byre", mergeInPrompt(target), "")
|
||||
}
|
||||
}
|
||||
|
||||
return m.create(ctx, task, user, mergeInPrompt(target), "", "byre", nil)
|
||||
}
|
||||
|
||||
func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project, user api.User) (api.AgentSession, error) {
|
||||
if user.GitName == "" || user.GitEmail == "" {
|
||||
return api.AgentSession{}, ErrNoGitIdentity
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.merges[task.Id] = user.Login
|
||||
m.mu.Unlock()
|
||||
|
||||
sess, err := m.MergeIn(ctx, task, user, project.DefaultBranch)
|
||||
if err != nil {
|
||||
m.mu.Lock()
|
||||
delete(m.merges, task.Id)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return sess, err
|
||||
}
|
||||
|
||||
func (m *Manager) takeMerge(taskID string) (string, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
login, ok := m.merges[taskID]
|
||||
delete(m.merges, taskID)
|
||||
return login, ok
|
||||
}
|
||||
|
||||
func (m *Manager) finishMerge(l *live, login string) {
|
||||
ctx := context.Background()
|
||||
err := m.squashAndClose(ctx, l.taskID, login)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Error("finish merge", "task", l.taskID, "err", err)
|
||||
m.fileError(l, "The agent merged the default branch in, but squashing onto it failed: "+err.Error())
|
||||
err = m.Deliver(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) error {
|
||||
task, err := m.store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := m.store.GetProject(ctx, task.ProjectId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := m.store.GetUser(ctx, login)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commit, err := git.SquashMerge(project.RepoPath, task.WorktreePath, project.DefaultBranch, task.Title, []string{
|
||||
"GIT_AUTHOR_NAME=" + user.GitName,
|
||||
"GIT_AUTHOR_EMAIL=" + user.GitEmail,
|
||||
"GIT_COMMITTER_NAME=" + user.GitName,
|
||||
"GIT_COMMITTER_EMAIL=" + user.GitEmail,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.RemoveTask(ctx, task, project, login)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = m.store.MergeTask(ctx, taskID, commit, login)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) RemoveTask(ctx context.Context, task api.Task, project api.Project, actor string) error {
|
||||
err := m.StopTask(ctx, task.Id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return git.RemoveWorktree(project.RepoPath, task.WorktreePath, task.Branch)
|
||||
}
|
||||
+103
-71
@@ -665,6 +665,9 @@ type ServerInterface interface {
|
||||
// (POST /api/tasks/{taskId}/merge)
|
||||
MergeTask(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
|
||||
// (POST /api/tasks/{taskId}/merge-in)
|
||||
MergeIn(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
|
||||
// (GET /api/tasks/{taskId}/sessions)
|
||||
ListSessions(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
|
||||
@@ -1398,6 +1401,32 @@ func (siw *ServerInterfaceWrapper) MergeTask(w http.ResponseWriter, r *http.Requ
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// MergeIn operation middleware
|
||||
func (siw *ServerInterfaceWrapper) MergeIn(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "taskId" -------------
|
||||
var taskId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.MergeIn(w, r, taskId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ListSessions operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1606,6 +1635,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}", wrapper.GetTask)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/unmerged", wrapper.GetUnmerged)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/merge", wrapper.MergeTask)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/merge-in", wrapper.MergeIn)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/discard", wrapper.DiscardTask)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/events", wrapper.ListTaskEvents)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/changes", wrapper.GetTaskChanges)
|
||||
@@ -1632,77 +1662,79 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
// const string: with thousands of chunks the chained `+` fold is several
|
||||
// times slower for the Go compiler than parsing a slice literal.
|
||||
var swaggerSpec = []string{
|
||||
"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",
|
||||
"cpN/2wrn4eFfAQAA//8=",
|
||||
"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",
|
||||
"//8=",
|
||||
}
|
||||
|
||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
-17
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
-16
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
-53
@@ -291,49 +291,33 @@ func (s *Server) MergeTask(w http.ResponseWriter, r *http.Request, taskID string
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if u.GitName == "" || u.GitEmail == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("set your git name and email (PUT /api/me) before merging"))
|
||||
|
||||
sess, err := s.agents.Merge(r.Context(), t, p, u)
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, sess)
|
||||
}
|
||||
|
||||
func (s *Server) MergeIn(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
t, p, ok := s.activeTask(w, r, taskID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := s.store.ListSessions(r.Context(), t.Id)
|
||||
u, err := s.store.GetUser(r.Context(), actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
for _, sess := range sessions {
|
||||
if sess.State == api.AgentSessionStateRunning || sess.State == api.AgentSessionStateQueued {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("session %s is %s; wait for the turn to end or interrupt it", sess.Id, sess.State))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
commit, err := git.SquashMerge(p.RepoPath, t.WorktreePath, p.DefaultBranch, squashMessage(t), []string{
|
||||
"GIT_AUTHOR_NAME=" + u.GitName,
|
||||
"GIT_AUTHOR_EMAIL=" + u.GitEmail,
|
||||
"GIT_COMMITTER_NAME=" + u.GitName,
|
||||
"GIT_COMMITTER_EMAIL=" + u.GitEmail,
|
||||
})
|
||||
conflict := &git.ConflictError{}
|
||||
if errors.As(err, &conflict) {
|
||||
writeError(w, http.StatusConflict, err)
|
||||
return
|
||||
}
|
||||
sess, err := s.agents.MergeIn(r.Context(), t, u, p.DefaultBranch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.removeTask(w, r, t, p) {
|
||||
return
|
||||
}
|
||||
|
||||
t, err = s.store.MergeTask(r.Context(), taskID, commit, actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
writeJSON(w, http.StatusAccepted, sess)
|
||||
}
|
||||
|
||||
func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
@@ -342,11 +326,13 @@ func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID stri
|
||||
return
|
||||
}
|
||||
|
||||
if !s.removeTask(w, r, t, p) {
|
||||
err := s.agents.RemoveTask(r.Context(), t, p, actor(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.store.DiscardTask(r.Context(), taskID, actor(r))
|
||||
t, err = s.store.DiscardTask(r.Context(), taskID, actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
@@ -354,10 +340,6 @@ func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID stri
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func squashMessage(t api.Task) string {
|
||||
return t.Title
|
||||
}
|
||||
|
||||
func (s *Server) activeTask(w http.ResponseWriter, r *http.Request, taskID string) (api.Task, api.Project, bool) {
|
||||
t, err := s.store.GetTask(r.Context(), taskID)
|
||||
if err != nil {
|
||||
@@ -378,21 +360,6 @@ func (s *Server) activeTask(w http.ResponseWriter, r *http.Request, taskID strin
|
||||
return t, p, true
|
||||
}
|
||||
|
||||
func (s *Server) removeTask(w http.ResponseWriter, r *http.Request, t api.Task, p api.Project) bool {
|
||||
err := s.agents.StopTask(r.Context(), t.Id, actor(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return false
|
||||
}
|
||||
|
||||
err = git.RemoveWorktree(p.RepoPath, t.WorktreePath, t.Branch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := s.store.Counts(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -259,80 +259,11 @@ func TestMergeTask(t *testing.T) {
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 merging without a git identity, got %d", code)
|
||||
}
|
||||
h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil)
|
||||
|
||||
writeFile(t, filepath.Join(task.WorktreePath, "committed"), "one\ntwo\n")
|
||||
gitOut(t, task.WorktreePath, "add", "committed")
|
||||
gitOut(t, task.WorktreePath, "commit", "--message", "wip")
|
||||
writeFile(t, filepath.Join(task.WorktreePath, "uncommitted"), "three\n")
|
||||
|
||||
writeFile(t, filepath.Join(p.RepoPath, "other"), "main moved\n")
|
||||
gitOut(t, p.RepoPath, "add", "other")
|
||||
gitOut(t, p.RepoPath, "commit", "--message", "main moved")
|
||||
moved := gitOut(t, p.RepoPath, "rev-parse", "main")
|
||||
|
||||
unmerged := api.Unmerged{}
|
||||
h.do("GET", "/api/tasks/"+task.Id+"/unmerged", nil, &unmerged)
|
||||
if unmerged != (api.Unmerged{Commits: 1, Files: 2, Insertions: 3, Deletions: 0, Uncommitted: 1}) {
|
||||
t.Fatalf("unexpected unmerged %+v", unmerged)
|
||||
}
|
||||
|
||||
merged := api.Task{}
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, &merged)
|
||||
if code != http.StatusOK || merged.Status != api.TaskStatusMerged || merged.ClosedAt == nil || merged.MergeCommit == nil {
|
||||
t.Fatalf("merge: %d %+v", code, merged)
|
||||
}
|
||||
|
||||
head := gitOut(t, p.RepoPath, "rev-parse", "main")
|
||||
if head != *merged.MergeCommit {
|
||||
t.Fatalf("main at %s, want merge commit %s", head, *merged.MergeCommit)
|
||||
}
|
||||
if parent := gitOut(t, p.RepoPath, "rev-parse", "main^"); parent != moved {
|
||||
t.Fatalf("merge commit parent %s, want %s", parent, moved)
|
||||
}
|
||||
if log := gitOut(t, p.RepoPath, "log", "-1", "--format=%s|%an|%cn"); log != "Add notes|Alice|Alice" {
|
||||
t.Fatalf("unexpected merge commit %s", log)
|
||||
}
|
||||
for _, name := range []string{"committed", "uncommitted", "other"} {
|
||||
_, err := os.Stat(filepath.Join(p.RepoPath, name))
|
||||
if err != nil {
|
||||
t.Fatalf("%s not checked out on main: %v", name, err)
|
||||
}
|
||||
}
|
||||
if status := gitOut(t, p.RepoPath, "status", "--porcelain"); status != "" {
|
||||
t.Fatalf("main checkout dirty after merge: %s", status)
|
||||
}
|
||||
if branches := gitOut(t, p.RepoPath, "branch", "--list", task.Branch); branches != "" {
|
||||
t.Fatalf("branch still exists: %s", branches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConflict(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
p := h.createProject("demo")
|
||||
h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil)
|
||||
|
||||
task := api.Task{}
|
||||
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "Edit readme"}, &task)
|
||||
writeFile(t, filepath.Join(task.WorktreePath, "README"), "task\n")
|
||||
|
||||
writeFile(t, filepath.Join(p.RepoPath, "README"), "main\n")
|
||||
gitOut(t, p.RepoPath, "commit", "--all", "--message", "main edit")
|
||||
head := gitOut(t, p.RepoPath, "rev-parse", "main")
|
||||
|
||||
code := h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, nil)
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("want 409 on conflict, got %d", code)
|
||||
}
|
||||
|
||||
got := api.Task{}
|
||||
h.do("GET", "/api/tasks/"+task.Id, nil, &got)
|
||||
if got.Status != api.TaskStatusActive || gitOut(t, p.RepoPath, "rev-parse", "main") != head {
|
||||
t.Fatalf("conflicted merge changed state: %+v", got)
|
||||
}
|
||||
_, err := os.Stat(task.WorktreePath)
|
||||
if err != nil {
|
||||
t.Fatalf("worktree removed after conflict: %v", err)
|
||||
if got.Status != api.TaskStatusActive {
|
||||
t.Fatalf("refused merge changed the task: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+25
-9
@@ -87,15 +87,32 @@ export function mountTranscript(root) {
|
||||
}
|
||||
const sess = currentSession(task.id);
|
||||
if (task.status !== "active") {
|
||||
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, task.status));
|
||||
replace(head, h("div", { class: "head-actions" }, task.status));
|
||||
return;
|
||||
}
|
||||
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
|
||||
const merge = h("button", { title: `Squash into one commit on ${target}; nothing is pushed` }, "Merge");
|
||||
merge.onclick = () => closeTask(task, (u) => `Merge "${task.title}" into ${target}? ${changesText(u)} become one commit on ${target}. Nothing is pushed.`, "/api/tasks/{taskId}/merge");
|
||||
const discard = h("button", { class: "danger", title: "Delete the worktree and branch without merging" }, "Discard");
|
||||
discard.onclick = () => closeTask(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, "/api/tasks/{taskId}/discard");
|
||||
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), merge, discard));
|
||||
const busy = sess?.state === "running" || sess?.state === "queued";
|
||||
const merge = h("button", { disabled: busy, title: `The agent merges ${target} in, then byre squashes into one commit on ${target}; nothing is pushed` }, "Merge");
|
||||
merge.onclick = () => confirmChanges(task, (u) => `Merge "${task.title}" into ${target}? The agent first merges ${target} in and resolves any conflicts; when that turn ends, ${changesText(u)} become one commit on ${target} and the task closes. Nothing is pushed.`, async () => {
|
||||
await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } });
|
||||
});
|
||||
const mergeIn = h("button", { disabled: busy, title: `Ask the agent to merge ${target} into this branch and resolve any conflicts` }, `Merge ${target} in`);
|
||||
mergeIn.onclick = async () => {
|
||||
mergeIn.disabled = true;
|
||||
try {
|
||||
await post("/api/tasks/{taskId}/merge-in", { path: { taskId: task.id } });
|
||||
}
|
||||
catch (err) {
|
||||
alert(errorText(err));
|
||||
mergeIn.disabled = false;
|
||||
}
|
||||
};
|
||||
const discard = h("button", { class: "danger", disabled: busy, title: "Delete the worktree and branch without merging" }, "Discard");
|
||||
discard.onclick = () => confirmChanges(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, async () => {
|
||||
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
|
||||
location.hash = "#/";
|
||||
});
|
||||
replace(head, h("div", { class: "head-actions session-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), mergeIn), h("div", { class: "head-actions" }, merge, discard));
|
||||
};
|
||||
let composerModel = "";
|
||||
const renderComposer = () => {
|
||||
@@ -610,14 +627,13 @@ function messageImages(ids) {
|
||||
return h("a", { href: src, target: "_blank", rel: "noopener" }, h("img", { src, alt: "attached image", loading: "lazy" }));
|
||||
}));
|
||||
}
|
||||
async function closeTask(task, prompt, path) {
|
||||
async function confirmChanges(task, prompt, act) {
|
||||
try {
|
||||
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId: task.id } });
|
||||
if (!confirm(prompt(u))) {
|
||||
return;
|
||||
}
|
||||
await post(path, { path: { taskId: task.id } });
|
||||
location.hash = "#/";
|
||||
await act();
|
||||
}
|
||||
catch (err) {
|
||||
alert(errorText(err));
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+48
-4
@@ -121,7 +121,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Squashes the task's worktree, uncommitted changes included, into one commit on the project's default branch, fast-forwards the project's checkout to it, then removes the worktree and branch. Nothing is pushed. Refused while a turn is running or queued, and on merge conflicts. */
|
||||
/** @description Starts the same agent turn as merge-in. When that turn ends normally, byre squashes the task's worktree into one commit on the project's default branch, fast-forwards the project's checkout to it, then removes the worktree and branch and marks the task merged. Nothing is pushed. Refused while a turn is running or queued. */
|
||||
post: operations["mergeTask"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -129,6 +129,25 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/merge-in": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Asks the task's agent to merge the project's default branch into the task branch, resolving any conflicts and regenerating generated files. Sent to the task's idle session, or starts a session when it has none. Refused while a turn is running or queued. */
|
||||
post: operations["mergeIn"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/discard": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -893,13 +912,38 @@ export interface operations {
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The merged task. */
|
||||
200: {
|
||||
/** @description The merge turn was started or queued. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Task"];
|
||||
"application/json": components["schemas"]["AgentSession"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["Error"];
|
||||
404: components["responses"]["Error"];
|
||||
409: components["responses"]["Error"];
|
||||
};
|
||||
};
|
||||
mergeIn: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The merge turn was started or queued. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AgentSession"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["Error"];
|
||||
|
||||
+34
-10
@@ -111,20 +111,45 @@ export function mountTranscript(root: HTMLElement): () => void {
|
||||
}
|
||||
const sess = currentSession(task.id);
|
||||
if (task.status !== "active") {
|
||||
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, task.status));
|
||||
replace(head, h("div", { class: "head-actions" }, task.status));
|
||||
return;
|
||||
}
|
||||
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
|
||||
const merge = h("button", { title: `Squash into one commit on ${target}; nothing is pushed` }, "Merge");
|
||||
const busy = sess?.state === "running" || sess?.state === "queued";
|
||||
const merge = h(
|
||||
"button",
|
||||
{ disabled: busy, title: `The agent merges ${target} in, then byre squashes into one commit on ${target}; nothing is pushed` },
|
||||
"Merge",
|
||||
);
|
||||
merge.onclick = () =>
|
||||
closeTask(task, (u) => `Merge "${task.title}" into ${target}? ${changesText(u)} become one commit on ${target}. Nothing is pushed.`, "/api/tasks/{taskId}/merge");
|
||||
const discard = h("button", { class: "danger", title: "Delete the worktree and branch without merging" }, "Discard");
|
||||
confirmChanges(
|
||||
task,
|
||||
(u) =>
|
||||
`Merge "${task.title}" into ${target}? The agent first merges ${target} in and resolves any conflicts; when that turn ends, ${changesText(u)} become one commit on ${target} and the task closes. Nothing is pushed.`,
|
||||
async () => {
|
||||
await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } });
|
||||
},
|
||||
);
|
||||
const mergeIn = h("button", { disabled: busy, title: `Ask the agent to merge ${target} into this branch and resolve any conflicts` }, `Merge ${target} in`);
|
||||
mergeIn.onclick = async () => {
|
||||
mergeIn.disabled = true;
|
||||
try {
|
||||
await post("/api/tasks/{taskId}/merge-in", { path: { taskId: task.id } });
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
mergeIn.disabled = false;
|
||||
}
|
||||
};
|
||||
const discard = h("button", { class: "danger", disabled: busy, title: "Delete the worktree and branch without merging" }, "Discard");
|
||||
discard.onclick = () =>
|
||||
closeTask(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, "/api/tasks/{taskId}/discard");
|
||||
confirmChanges(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, async () => {
|
||||
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
|
||||
location.hash = "#/";
|
||||
});
|
||||
replace(
|
||||
head,
|
||||
h("div", { class: "meta" }, h("code", {}, task.branch)),
|
||||
h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), merge, discard),
|
||||
h("div", { class: "head-actions session-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), mergeIn),
|
||||
h("div", { class: "head-actions" }, merge, discard),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -706,14 +731,13 @@ function messageImages(ids: unknown): HTMLElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
async function closeTask(task: Task, prompt: (u: Unmerged) => string, path: "/api/tasks/{taskId}/merge" | "/api/tasks/{taskId}/discard") {
|
||||
async function confirmChanges(task: Task, prompt: (u: Unmerged) => string, act: () => Promise<void>) {
|
||||
try {
|
||||
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId: task.id } });
|
||||
if (!confirm(prompt(u))) {
|
||||
return;
|
||||
}
|
||||
await post(path, { path: { taskId: task.id } });
|
||||
location.hash = "#/";
|
||||
await act();
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
}
|
||||
|
||||
+4
-14
@@ -432,20 +432,6 @@ textarea {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -453,6 +439,10 @@ textarea {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.context-meter {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user