Snapshot task worktrees to hidden refs as agents work

This commit is contained in:
Ian Gulliver
2026-10-14 22:52:02 -07:00
parent c3ed243573
commit 9039860594
22 changed files with 926 additions and 202 deletions
+2
View File
@@ -122,6 +122,8 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
l.asked = true
m.mu.Unlock()
m.snapshotSoon(l, api.SnapshotTriggerAskHuman)
if *args.Blocking {
return fmt.Sprintf("Question %s filed in the human's inbox. It is blocking: end your turn now. The answer will arrive as your next message.", id), nil
}
+7
View File
@@ -51,6 +51,9 @@ type Manager struct {
deliverMu sync.Mutex
capMu sync.Mutex
snapMu sync.Mutex
snaps map[string]*snapState
bad *badLines
watchStop chan struct{}
}
@@ -85,6 +88,7 @@ func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
merges: map[string]string{},
snaps: map[string]*snapState{},
bad: newBadLines(),
watchStop: make(chan struct{}),
}
@@ -565,6 +569,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
Hooks: map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: remindGuidance}},
"Stop": {{Func: m.bounceProseQuestion(l)}},
"PostToolUse": {{Func: m.snapshotAfterTool(l)}},
},
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
}
@@ -588,6 +593,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
m.live[sess.Id] = l
go m.pump(l)
go m.snapshotTimer(l)
return l, nil
}
@@ -712,6 +718,7 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if sha != "" {
payload["commit"] = sha
}
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd)
switch {
case handoff:
payload["handoff"] = true
+18
View File
@@ -187,6 +187,24 @@ func TestTurnAndFollowUp(t *testing.T) {
}
}
if !has(events, "snapshot.created") {
events = append(events, e.waitFor("snapshot", func(ev api.Event) bool { return ev.Kind == "snapshot.created" })...)
}
snaps, err := e.store.ListSnapshots(e.ctx, e.task.Id)
if err != nil || len(snaps) == 0 {
t.Fatalf("no snapshots after a turn that wrote a file: %+v %v", snaps, err)
}
latest := snaps[len(snaps)-1]
if ref := gitOut(t, e.repo, "rev-parse", git.SnapshotRef(e.task.Id, latest.N)); ref != latest.CommitSha {
t.Fatalf("snapshot ref %s, want %s", ref, latest.CommitSha)
}
if got := gitOut(t, e.repo, "show", latest.CommitSha+":hi.txt"); strings.TrimSpace(got) != "hi" {
t.Fatalf("hi.txt in the latest snapshot: %q", got)
}
if snaps[0].Trigger != api.SnapshotTriggerToolCall {
t.Fatalf("first snapshot trigger %s, want tool_call", snaps[0].Trigger)
}
window := 0
for _, ev := range events {
if ev.Kind == "session.context" {
+4
View File
@@ -137,6 +137,10 @@ func (m *Manager) RemoveTask(ctx context.Context, task api.Task, project api.Pro
if err != nil {
return err
}
err = m.dropSnapshots(project.RepoPath, task.Id)
if err != nil {
return err
}
return git.RemoveWorktree(project.RepoPath, task.WorktreePath, task.Branch)
}
+158
View File
@@ -0,0 +1,158 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/store"
)
const snapshotInterval = 30 * time.Second
type snapState struct {
mu sync.Mutex
running bool
pending api.SnapshotTrigger
closed bool
}
func (m *Manager) snapStateLocked(taskID string) *snapState {
st := m.snaps[taskID]
if st == nil {
st = &snapState{}
m.snaps[taskID] = st
}
return st
}
func (m *Manager) snapshotSoon(l *live, trigger api.SnapshotTrigger) {
m.snapMu.Lock()
defer m.snapMu.Unlock()
st := m.snapStateLocked(l.taskID)
if st.closed {
return
}
if st.running {
st.pending = trigger
return
}
st.running = true
go m.runSnapshots(l, st, trigger)
}
func (m *Manager) runSnapshots(l *live, st *snapState, trigger api.SnapshotTrigger) {
for {
st.mu.Lock()
m.snapMu.Lock()
closed := st.closed
m.snapMu.Unlock()
if !closed {
err := m.takeSnapshot(context.Background(), l, trigger)
if err != nil {
slog.Error("take snapshot", "task", l.taskID, "trigger", trigger, "err", err)
}
}
st.mu.Unlock()
m.snapMu.Lock()
trigger = st.pending
st.pending = ""
if trigger == "" || st.closed {
st.running = false
m.snapMu.Unlock()
return
}
m.snapMu.Unlock()
}
}
func (m *Manager) takeSnapshot(ctx context.Context, l *live, trigger api.SnapshotTrigger) error {
seq, err := m.store.LastTaskSeq(ctx, l.taskID)
if err != nil {
return err
}
tree, err := git.Snapshot(l.dir)
if err != nil {
return err
}
last, err := m.store.LastSnapshot(ctx, l.taskID)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return err
}
if err == nil && last.TreeSha == tree {
return nil
}
n := last.N + 1
commit, err := git.CommitSnapshot(l.dir, tree, git.SnapshotRef(l.taskID, n), fmt.Sprintf("byre snapshot %d (%s)", n, trigger), []string{
"GIT_AUTHOR_NAME=" + l.gitName,
"GIT_AUTHOR_EMAIL=" + l.gitEmail,
"GIT_COMMITTER_NAME=" + l.gitName,
"GIT_COMMITTER_EMAIL=" + l.gitEmail,
})
if err != nil {
return err
}
return m.store.CreateSnapshot(ctx, api.Snapshot{
Id: store.NewID(),
TaskId: l.taskID,
AgentSessionId: l.id,
N: n,
CommitSha: commit,
TreeSha: tree,
EventSeq: seq,
Trigger: trigger,
CreatedAt: time.Now().UTC(),
}, l.projectID)
}
func (m *Manager) dropSnapshots(repo, taskID string) error {
m.snapMu.Lock()
st := m.snapStateLocked(taskID)
st.closed = true
m.snapMu.Unlock()
st.mu.Lock()
defer st.mu.Unlock()
return git.DeleteSnapshotRefs(repo, taskID)
}
func (m *Manager) snapshotAfterTool(l *live) claude.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
m.snapshotSoon(l, api.SnapshotTriggerToolCall)
return map[string]any{}, nil
}
}
func (m *Manager) snapshotTimer(l *live) {
ticker := time.NewTicker(snapshotInterval)
defer ticker.Stop()
for {
select {
case <-l.sess.Done():
return
case <-ticker.C:
sess, err := m.store.GetSession(context.Background(), l.id)
if err != nil {
slog.Error("check session for snapshot", "session", l.id, "err", err)
continue
}
if sess.State == api.AgentSessionStateRunning {
m.snapshotSoon(l, api.SnapshotTriggerTimer)
}
}
}
}
+177
View File
@@ -0,0 +1,177 @@
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)
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(ev api.Event) { events <- ev })
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)
}
err = st.CreateTask(ctx, task)
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)
}
m := New(st, DefaultLimits, t.TempDir())
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")
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerToolCall)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
snaps := snapshots()
if len(snaps) != 1 || snaps[0].N != 1 || snaps[0].Trigger != api.SnapshotTriggerToolCall || snaps[0].AgentSessionId != "s1" {
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)
}
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerTimer)
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")
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd)
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)
}
m.snapshotSoon(l, api.SnapshotTriggerToolCall)
if refs := gitIn(repo, "for-each-ref", "refs/byre/"); refs != "" {
t.Fatalf("snapshot taken after discard:\n%s", refs)
}
}
+175 -94
View File
@@ -252,6 +252,30 @@ func (e InboxState) Valid() bool {
}
}
// Defines values for SnapshotTrigger.
const (
SnapshotTriggerAskHuman SnapshotTrigger = "ask_human"
SnapshotTriggerTimer SnapshotTrigger = "timer"
SnapshotTriggerToolCall SnapshotTrigger = "tool_call"
SnapshotTriggerTurnEnd SnapshotTrigger = "turn_end"
)
// Valid indicates whether the value is a known member of the SnapshotTrigger enum.
func (e SnapshotTrigger) Valid() bool {
switch e {
case SnapshotTriggerAskHuman:
return true
case SnapshotTriggerTimer:
return true
case SnapshotTriggerToolCall:
return true
case SnapshotTriggerTurnEnd:
return true
default:
return false
}
}
// Defines values for TaskStatus.
const (
TaskStatusActive TaskStatus = "active"
@@ -579,6 +603,28 @@ type SessionCreate struct {
Prompt string `json:"prompt"`
}
// Snapshot defines model for Snapshot.
type Snapshot struct {
AgentSessionId string `json:"agent_session_id"`
// CommitSha A commit of tree_sha whose parent is the worktree's HEAD when it was taken.
CommitSha string `json:"commit_sha"`
CreatedAt time.Time `json:"created_at"`
// EventSeq The task's latest event when the snapshot was started: the tree holds at least the work up to it.
EventSeq int64 `json:"event_seq"`
Id string `json:"id"`
// N Position in the task's snapshots, from 1; the ref is refs/byre/tasks/{task_id}/snap/{n}.
N int `json:"n"`
TaskId string `json:"task_id"`
TreeSha string `json:"tree_sha"`
Trigger SnapshotTrigger `json:"trigger"`
}
// SnapshotTrigger defines model for Snapshot.Trigger.
type SnapshotTrigger string
// Status defines model for Status.
type Status struct {
ActiveTasks int `json:"active_tasks"`
@@ -861,6 +907,9 @@ type ServerInterface interface {
// (POST /api/tasks/{taskId}/sessions)
CreateSession(w http.ResponseWriter, r *http.Request, taskId string)
// (GET /api/tasks/{taskId}/snapshots)
ListSnapshots(w http.ResponseWriter, r *http.Request, taskId string)
// (GET /api/tasks/{taskId}/unmerged)
GetUnmerged(w http.ResponseWriter, r *http.Request, taskId string)
}
@@ -1741,6 +1790,32 @@ func (siw *ServerInterfaceWrapper) CreateSession(w http.ResponseWriter, r *http.
handler.ServeHTTP(w, r)
}
// ListSnapshots operation middleware
func (siw *ServerInterfaceWrapper) ListSnapshots(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.ListSnapshots(w, r, taskId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetUnmerged operation middleware
func (siw *ServerInterfaceWrapper) GetUnmerged(w http.ResponseWriter, r *http.Request) {
@@ -1906,6 +1981,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/events", wrapper.ListTaskEvents)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/changes", wrapper.GetTaskChanges)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/diff", wrapper.GetTaskDiff)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots", wrapper.ListSnapshots)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.CreateSession)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/sessions/{sessionId}", wrapper.GetSession)
@@ -1928,100 +2004,105 @@ 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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",
"3N7+TwAAAP//",
"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",
"Hcb+fbj9YfYvm3e6vf2fAAAA//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
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+65
View File
@@ -0,0 +1,65 @@
package git
import (
"fmt"
"os/exec"
"strings"
)
func snapshotPrefix(taskID string) string {
return "refs/byre/tasks/" + taskID + "/snap/"
}
func SnapshotRef(taskID string, n int) string {
return fmt.Sprintf("%s%d", snapshotPrefix(taskID), n)
}
func CommitSnapshot(dir, tree, ref, message string, env []string) (string, error) {
head, err := Head(dir)
if err != nil {
return "", err
}
commit, err := runEnv(dir, env, "commit-tree", tree, "-p", head, "-m", message)
if err != nil {
return "", err
}
_, err = run(dir, "update-ref", ref, commit, "")
if err != nil {
return "", err
}
return commit, nil
}
func SnapshotRefs(repo, taskID string) ([]string, error) {
out, err := run(repo, "for-each-ref", "--format=%(refname)", snapshotPrefix(taskID))
if err != nil {
return nil, err
}
if out == "" {
return []string{}, nil
}
return strings.Split(out, "\n"), nil
}
func DeleteSnapshotRefs(repo, taskID string) error {
refs, err := SnapshotRefs(repo, taskID)
if err != nil {
return err
}
if len(refs) == 0 {
return nil
}
b := &strings.Builder{}
for _, ref := range refs {
fmt.Fprintf(b, "delete %s\n", ref)
}
cmd := exec.Command("git", "update-ref", "--stdin")
cmd.Dir = repo
cmd.Stdin = strings.NewReader(b.String())
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git update-ref --stdin: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
+15
View File
@@ -452,6 +452,21 @@ func (s *Server) ListTaskEvents(w http.ResponseWriter, r *http.Request, taskID s
writeJSON(w, http.StatusOK, events)
}
func (s *Server) ListSnapshots(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
snaps, err := s.store.ListSnapshots(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, snaps)
}
func (s *Server) ListSessions(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
+74
View File
@@ -0,0 +1,74 @@
package store
import (
"context"
"database/sql"
"errors"
"g.fc.run/misc/byre/internal/api"
)
const snapshotColumns = `id, task_id, agent_session_id, n, commit_sha, tree_sha, event_seq, trigger, created_at`
func (s *Store) CreateSnapshot(ctx context.Context, snap api.Snapshot, projectID string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `INSERT INTO snapshots (`+snapshotColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
snap.Id, snap.TaskId, snap.AgentSessionId, snap.N, snap.CommitSha, snap.TreeSha, snap.EventSeq, string(snap.Trigger), formatTime(snap.CreatedAt))
if err != nil {
return err
}
return emit(api.Event{
Kind: "snapshot.created",
ProjectId: &projectID,
TaskId: &snap.TaskId,
AgentSessionId: &snap.AgentSessionId,
Actor: "byre",
Payload: ToPayload(snap),
})
})
}
func (s *Store) ListSnapshots(ctx context.Context, taskID string) ([]api.Snapshot, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+snapshotColumns+` FROM snapshots WHERE task_id = ? ORDER BY n`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
snaps := []api.Snapshot{}
for rows.Next() {
snap, err := scanSnapshot(rows)
if err != nil {
return nil, err
}
snaps = append(snaps, snap)
}
return snaps, rows.Err()
}
func (s *Store) LastSnapshot(ctx context.Context, taskID string) (api.Snapshot, error) {
snap, err := scanSnapshot(s.db.QueryRowContext(ctx, `SELECT `+snapshotColumns+` FROM snapshots WHERE task_id = ? ORDER BY n DESC LIMIT 1`, taskID))
if errors.Is(err, sql.ErrNoRows) {
return snap, ErrNotFound
}
return snap, err
}
func (s *Store) LastTaskSeq(ctx context.Context, taskID string) (int64, error) {
seq := int64(0)
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq), 0) FROM events WHERE task_id = ?`, taskID).Scan(&seq)
return seq, err
}
func scanSnapshot(row scanner) (api.Snapshot, error) {
snap := api.Snapshot{}
trigger := ""
createdAt := ""
err := row.Scan(&snap.Id, &snap.TaskId, &snap.AgentSessionId, &snap.N, &snap.CommitSha, &snap.TreeSha, &snap.EventSeq, &trigger, &createdAt)
if err != nil {
return snap, err
}
snap.Trigger = api.SnapshotTrigger(trigger)
snap.CreatedAt, err = parseTime(createdAt)
return snap, err
}
+13
View File
@@ -179,6 +179,19 @@ CREATE TABLE push_subscriptions (
subscriber TEXT NOT NULL,
created_at TEXT NOT NULL
);
`}, {"add-snapshots", `
CREATE TABLE snapshots (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
agent_session_id TEXT NOT NULL REFERENCES agent_sessions(id),
n INTEGER NOT NULL,
commit_sha TEXT NOT NULL,
tree_sha TEXT NOT NULL,
event_seq INTEGER NOT NULL,
trigger TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (task_id, n)
);
`}}
type Store struct {