2026-10-14 22:52:02 -07:00
|
|
|
package agents
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"g.fc.run/misc/byre/internal/api"
|
|
|
|
|
"g.fc.run/misc/byre/internal/git"
|
|
|
|
|
"g.fc.run/misc/byre/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestSnapshots(t *testing.T) {
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
events := make(chan api.Event, 256)
|
2026-10-15 12:39:46 -07:00
|
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(ev api.Event) { events <- ev })
|
2026-10-14 22:52:02 -07:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("open store: %v", err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { st.Close() })
|
|
|
|
|
|
|
|
|
|
repo := t.TempDir()
|
|
|
|
|
gitIn := func(dir string, args ...string) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
cmd := exec.Command("git", args...)
|
|
|
|
|
cmd.Dir = dir
|
|
|
|
|
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Root", "GIT_AUTHOR_EMAIL=root@byre.invalid", "GIT_COMMITTER_NAME=Root", "GIT_COMMITTER_EMAIL=root@byre.invalid")
|
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(string(out))
|
|
|
|
|
}
|
|
|
|
|
gitIn(repo, "init", "--initial-branch=main")
|
|
|
|
|
err = os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("one\n"), 0o644)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
gitIn(repo, "add", "tracked.txt")
|
|
|
|
|
gitIn(repo, "commit", "--message", "root")
|
|
|
|
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ensure user: %v", err)
|
|
|
|
|
}
|
|
|
|
|
project := api.Project{Id: "p1", Name: "demo", RepoPath: repo, DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now}
|
|
|
|
|
err = st.CreateProject(ctx, project)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("create project: %v", err)
|
|
|
|
|
}
|
|
|
|
|
task := api.Task{Id: "t1", ProjectId: "p1", Title: "snap", Branch: "byre/t1", WorktreePath: filepath.Join(t.TempDir(), "t1"), Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}
|
|
|
|
|
task.BaseRef, err = git.AddWorktree(repo, task.WorktreePath, task.Branch, "main")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("add worktree: %v", err)
|
|
|
|
|
}
|
2026-10-15 10:12:44 -07:00
|
|
|
err = st.CreateTask(ctx, task, "alice")
|
2026-10-14 22:52:02 -07:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("create task: %v", err)
|
|
|
|
|
}
|
|
|
|
|
err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("create session: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 09:40:30 -07:00
|
|
|
m := New(st, DefaultLimits, t.TempDir(), t.TempDir())
|
2026-10-14 22:52:02 -07:00
|
|
|
l := &live{id: "s1", dir: task.WorktreePath, gitName: "Alice Test", gitEmail: "alice@byre.invalid", vendor: api.Claude, taskID: "t1", projectID: "p1"}
|
|
|
|
|
write := func(name, content string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
err := os.WriteFile(filepath.Join(task.WorktreePath, name), []byte(content), 0o644)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
snapshots := func() []api.Snapshot {
|
|
|
|
|
t.Helper()
|
|
|
|
|
snaps, err := st.ListSnapshots(ctx, "t1")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("list snapshots: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return snaps
|
|
|
|
|
}
|
|
|
|
|
indexPath := gitIn(task.WorktreePath, "rev-parse", "--path-format=absolute", "--git-path", "index")
|
|
|
|
|
indexBefore, err := os.ReadFile(indexPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
write("tracked.txt", "two\n")
|
|
|
|
|
write("untracked.txt", "new\n")
|
2026-10-15 11:36:51 -07:00
|
|
|
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 0)
|
2026-10-14 22:52:02 -07:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("snapshot: %v", err)
|
|
|
|
|
}
|
|
|
|
|
snaps := snapshots()
|
2026-10-15 11:36:51 -07:00
|
|
|
if len(snaps) != 1 || snaps[0].N != 1 || snaps[0].Trigger != api.SnapshotTriggerMessage || snaps[0].AgentSessionId != "s1" {
|
2026-10-14 22:52:02 -07:00
|
|
|
t.Fatalf("after the first change: %+v", snaps)
|
|
|
|
|
}
|
|
|
|
|
first := snaps[0]
|
|
|
|
|
if ref := gitIn(repo, "rev-parse", "refs/byre/tasks/t1/snap/1"); ref != first.CommitSha {
|
|
|
|
|
t.Fatalf("snap/1 is %s, want %s", ref, first.CommitSha)
|
|
|
|
|
}
|
|
|
|
|
if tree := gitIn(repo, "rev-parse", first.CommitSha+"^{tree}"); tree != first.TreeSha {
|
|
|
|
|
t.Fatalf("commit tree %s, want %s", tree, first.TreeSha)
|
|
|
|
|
}
|
|
|
|
|
if parent := gitIn(repo, "rev-parse", first.CommitSha+"^"); parent != task.BaseRef {
|
|
|
|
|
t.Fatalf("snapshot parent %s, want the worktree HEAD %s", parent, task.BaseRef)
|
|
|
|
|
}
|
|
|
|
|
if got := gitIn(repo, "show", first.CommitSha+":untracked.txt"); got != "new" {
|
|
|
|
|
t.Fatalf("untracked.txt in snapshot: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := gitIn(repo, "show", first.CommitSha+":tracked.txt"); got != "two" {
|
|
|
|
|
t.Fatalf("tracked.txt in snapshot: %q", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
indexAfter, err := os.ReadFile(indexPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if string(indexBefore) != string(indexAfter) {
|
|
|
|
|
t.Fatalf("snapshot modified the worktree's index")
|
|
|
|
|
}
|
|
|
|
|
if staged := gitIn(task.WorktreePath, "diff", "--cached", "--name-only"); staged != "" {
|
|
|
|
|
t.Fatalf("snapshot staged files in the real index: %s", staged)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 11:36:51 -07:00
|
|
|
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 1)
|
2026-10-14 22:52:02 -07:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unchanged snapshot: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if snaps := snapshots(); len(snaps) != 1 {
|
|
|
|
|
t.Fatalf("an unchanged tree made a snapshot: %+v", snaps)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
write("untracked.txt", "newer\n")
|
2026-10-15 11:36:51 -07:00
|
|
|
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd, 0)
|
2026-10-14 22:52:02 -07:00
|
|
|
deadline := time.After(30 * time.Second)
|
|
|
|
|
for created := false; !created; {
|
|
|
|
|
select {
|
|
|
|
|
case ev := <-events:
|
|
|
|
|
created = ev.Kind == "snapshot.created" && ev.Payload["n"] == float64(2)
|
|
|
|
|
case <-deadline:
|
|
|
|
|
t.Fatalf("no snapshot.created event")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
snaps = snapshots()
|
|
|
|
|
if len(snaps) != 2 || snaps[1].N != 2 || snaps[1].Trigger != api.SnapshotTriggerTurnEnd || snaps[1].EventSeq < first.EventSeq {
|
|
|
|
|
t.Fatalf("after the second change: %+v", snaps)
|
|
|
|
|
}
|
|
|
|
|
if got := gitIn(repo, "show", "refs/byre/tasks/t1/snap/2:untracked.txt"); got != "newer" {
|
|
|
|
|
t.Fatalf("untracked.txt in snap/2: %q", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = m.RemoveTask(ctx, task, project, "alice")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("remove task: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_, err = st.DiscardTask(ctx, "t1", "alice")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("discard: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if refs := gitIn(repo, "for-each-ref", "refs/byre/"); refs != "" {
|
|
|
|
|
t.Fatalf("snapshot refs left after discard:\n%s", refs)
|
|
|
|
|
}
|
|
|
|
|
if snaps := snapshots(); len(snaps) != 2 {
|
|
|
|
|
t.Fatalf("snapshot rows after discard: %+v", snaps)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 11:36:51 -07:00
|
|
|
m.snapshotSoon(l, api.SnapshotTriggerMessage, 0)
|
2026-10-14 22:52:02 -07:00
|
|
|
if refs := gitIn(repo, "for-each-ref", "refs/byre/"); refs != "" {
|
|
|
|
|
t.Fatalf("snapshot taken after discard:\n%s", refs)
|
|
|
|
|
}
|
|
|
|
|
}
|