Plan 3
This commit is contained in:
+1
-1
@@ -17,7 +17,7 @@ State lives in the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `
|
||||
|
||||
## Closing a task
|
||||
|
||||
A task ends `merged` or `discarded`; either way its sessions are stopped, its worktree and branch are removed, 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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+17
-16
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
+16
-15
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -190,6 +190,37 @@ func (s *Store) Respond(ctx context.Context, actor string, resolutions []Resolut
|
||||
})
|
||||
}
|
||||
|
||||
func dismissTaskInbox(ctx context.Context, tx *sql.Tx, emit emitFunc, taskID, actor string) error {
|
||||
ids := []string{}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id FROM inbox_items WHERE task_id = ? AND state = ?`, taskID, string(api.Open))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
id := ""
|
||||
err = rows.Scan(&id)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, id := range ids {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ?, snoozed_until = NULL WHERE id = ?`,
|
||||
string(api.Dismissed), actor, now(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = emitItem(ctx, tx, emit, "inbox.updated", id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AwaitingHuman(ctx context.Context, sessionID string) (int, error) {
|
||||
n := 0
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestClosingTaskDismissesOpenInbox(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
events := []api.Event{}
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(ev api.Event) { events = append(events, ev) })
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
for _, id := range []string{"t1", "t2"} {
|
||||
err = st.CreateTask(ctx, api.Task{Id: id, ProjectId: "p1", Title: id, Branch: id, WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
err = st.CreateSession(ctx, api.AgentSession{Id: "s" + id, TaskId: id, Vendor: api.Claude, VendorSessionId: "s" + id, State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range []struct{ id, task string }{{"q1", "t1"}, {"r1", "t1"}, {"q2", "t2"}} {
|
||||
err = st.CreateInboxItem(ctx, api.InboxItem{Id: item.id, ProjectId: "p1", TaskId: item.task, AgentSessionId: "s" + item.task, ThreadId: item.id,
|
||||
Kind: api.Question, Priority: api.Blocked, Blocking: true, Payload: map[string]any{}, State: api.Open, CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create inbox item: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
events = events[:0]
|
||||
_, err = st.DiscardTask(ctx, "t1", "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
|
||||
for _, id := range []string{"q1", "r1"} {
|
||||
item, err := st.GetInboxItem(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", id, err)
|
||||
}
|
||||
if item.State != api.Dismissed || item.ResolvedBy == nil || *item.ResolvedBy != "alice" || item.ResolvedAt == nil {
|
||||
t.Fatalf("%s not dismissed by alice: %+v", id, item)
|
||||
}
|
||||
}
|
||||
other, err := st.GetInboxItem(ctx, "q2")
|
||||
if err != nil {
|
||||
t.Fatalf("get q2: %v", err)
|
||||
}
|
||||
if other.State != api.Open {
|
||||
t.Fatalf("other task's item changed: %+v", other)
|
||||
}
|
||||
|
||||
kinds := []string{}
|
||||
for _, ev := range events {
|
||||
kinds = append(kinds, ev.Kind)
|
||||
}
|
||||
if len(kinds) != 3 || kinds[0] != "task.discarded" || kinds[1] != "inbox.updated" || kinds[2] != "inbox.updated" {
|
||||
t.Fatalf("unexpected events %v", kinds)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,9 @@ UPDATE inbox_items SET payload = json_object('text', coalesce((SELECT group_conc
|
||||
ALTER TABLE tasks RENAME COLUMN completed_at TO closed_at;
|
||||
ALTER TABLE tasks ADD COLUMN merge_commit TEXT NOT NULL DEFAULT '';
|
||||
UPDATE tasks SET status = 'discarded' WHERE status = 'completed';
|
||||
`, `
|
||||
UPDATE inbox_items SET state = 'dismissed', resolved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), snoozed_until = NULL
|
||||
WHERE state = 'open' AND task_id IN (SELECT id FROM tasks WHERE status != 'active');
|
||||
`}
|
||||
|
||||
type Store struct {
|
||||
@@ -345,7 +348,11 @@ func (s *Store) closeTask(ctx context.Context, id string, status api.TaskStatus,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{Kind: kind, ProjectId: &projectID, TaskId: &id, Actor: actor, Payload: payload})
|
||||
err = emit(api.Event{Kind: kind, ProjectId: &projectID, TaskId: &id, Actor: actor, Payload: payload})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dismissTaskInbox(ctx, tx, emit, id, actor)
|
||||
})
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
|
||||
Reference in New Issue
Block a user