From b91cef088bd38435fef62c344aa49bdbdb7f8130 Mon Sep 17 00:00:00 2001 From: SIN CI Date: Tue, 16 Jun 2026 23:07:01 +0200 Subject: [PATCH] feat(agentloop): workspace checkpointing WIP (issue #194) --- cmd/sin-code/checkpoint_cmd.go | 126 ++++++++++++ cmd/sin-code/internal/agentloop/loop.go | 26 +++ cmd/sin-code/internal/checkpoint/store.go | 187 ++++++++++++++++++ .../internal/checkpoint/store_test.go | 168 ++++++++++++++++ 4 files changed, 507 insertions(+) create mode 100644 cmd/sin-code/checkpoint_cmd.go create mode 100644 cmd/sin-code/internal/checkpoint/store.go create mode 100644 cmd/sin-code/internal/checkpoint/store_test.go diff --git a/cmd/sin-code/checkpoint_cmd.go b/cmd/sin-code/checkpoint_cmd.go new file mode 100644 index 00000000..52c9c295 --- /dev/null +++ b/cmd/sin-code/checkpoint_cmd.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code checkpoint` and `sin-code rewind` — manual snapshot +// + restore of the workspace, plus a timeline view (issue #194). Pairs +// with the auto-checkpoint wired in the loop so users always have an +// escape hatch after a bad multi-file edit. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/checkpoint" +) + +func NewCheckpointCmd() *cobra.Command { + var workspace string + var session string + cmd := &cobra.Command{ + Use: "checkpoint [label]", + Short: "Snapshot the current workspace state", + RunE: func(cmd *cobra.Command, args []string) error { + label := "manual" + if len(args) > 0 { + label = args[0] + } + store, err := checkpoint.Open(workspace) + if err != nil { + return err + } + defer store.Close() + paths, err := dirtyPaths(workspace) + if err != nil { + return err + } + id, err := store.Capture(context.Background(), workspace, session, label, paths) + if err != nil { + return err + } + fmt.Printf("checkpoint %s captured (%d files)\n", id, len(paths)) + return nil + }, + } + cmd.Flags().StringVar(&workspace, "workspace", ".", "workspace root") + cmd.Flags().StringVar(&session, "session", "manual", "session id for this checkpoint") + return cmd +} + +func NewRewindCmd() *cobra.Command { + var workspace string + var list bool + cmd := &cobra.Command{ + Use: "rewind [checkpoint-id]", + Short: "Restore the workspace to a prior checkpoint", + RunE: func(cmd *cobra.Command, args []string) error { + store, err := checkpoint.Open(workspace) + if err != nil { + return err + } + defer store.Close() + if list || len(args) == 0 { + snaps, err := store.List(context.Background(), "", 50) + if err != nil { + return err + } + if len(snaps) == 0 { + fmt.Println("no checkpoints") + return nil + } + for _, s := range snaps { + fmt.Printf("%s %s %q (%d files)\n", + s.CreatedAt.Format("2006-01-02 15:04:05"), + s.ID, s.Label, len(s.Files)) + } + return nil + } + if err := store.Restore(context.Background(), workspace, args[0]); err != nil { + return err + } + fmt.Printf("workspace restored to %s\n", args[0]) + return nil + }, + } + cmd.Flags().StringVar(&workspace, "workspace", ".", "workspace root") + cmd.Flags().BoolVar(&list, "list", false, "list checkpoints instead of restoring") + return cmd +} + +// dirtyPaths returns the workspace-relative list of files that exist +// under the workspace, excluding .sin-code/, .git/, and the +// checkpoint store itself. This is a conservative v0: we snapshot +// every regular file we can find. v1 should integrate with the +// existing change detector (LSP / git status). +func dirtyPaths(workspace string) ([]string, error) { + var paths []string + skip := map[string]bool{ + ".sin-code": true, + ".git": true, + "node_modules": true, + } + err := filepath.Walk(workspace, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // skip unreadable entries + } + if info.IsDir() { + if skip[info.Name()] { + return filepath.SkipDir + } + return nil + } + rel, err := filepath.Rel(workspace, path) + if err != nil { + return nil + } + // Skip the checkpoint store itself (recursion guard). + if len(rel) >= 11 && rel[:11] == ".sin-code/c" { + return nil + } + paths = append(paths, rel) + return nil + }) + return paths, err +} diff --git a/cmd/sin-code/internal/agentloop/loop.go b/cmd/sin-code/internal/agentloop/loop.go index 7a56c112..81ef310b 100644 --- a/cmd/sin-code/internal/agentloop/loop.go +++ b/cmd/sin-code/internal/agentloop/loop.go @@ -98,6 +98,13 @@ type Loop struct { LocalSpec []ToolSpec Workspace string MaxTurns int + // BeforeMutate, if set, is called before a mutating tool + // (sin_write / sin_edit) executes, with the workspace-relative + // path it will change. The loopbuilder wires this to + // checkpoint.Store.Capture so every edit is auto-snapshotted and + // rewind-able. Optional — nil disables auto-checkpoint. + // (issue #194) + BeforeMutate func(ctx context.Context, tool, path string) // MaxStopRejects caps how many times the stop-gate can reject // completion before the run errors. Zero falls back to the // default of 3. Independent of StallThreshold (issue #150): @@ -240,6 +247,11 @@ func (l *Loop) execute(ctx context.Context, tc ToolCall) (out string, injects [] if l.LocalTool == nil { return "TOOL ERROR: no LocalTool registered", injects } + if l.BeforeMutate != nil { + if p := mutatedPath(tc); p != "" { + l.BeforeMutate(ctx, tc.Name, p) + } + } res, err := l.LocalTool(ctx, tc.Name, tc.Args) if err != nil { l.fire(ctx, hooks.ToolError, tc.Name, map[string]any{"error": err.Error()}) @@ -605,3 +617,17 @@ func formatStopContinue(dec StopDecision) string { b.WriteString("Continue working until every criterion is met, then stop.") return b.String() } + +// mutatedPath extracts the target path for tools that mutate the workspace +// so the auto-checkpoint snapshots exactly the file about to change (cheap, +// O(1)). Returns "" for tools that don't mutate the workspace or have no +// "path" argument. (issue #194) +func mutatedPath(tc ToolCall) string { + switch tc.Name { + case "sin_write", "sin_edit": + if p, ok := tc.Args["path"].(string); ok { + return p + } + } + return "" +} diff --git a/cmd/sin-code/internal/checkpoint/store.go b/cmd/sin-code/internal/checkpoint/store.go new file mode 100644 index 00000000..eda352b5 --- /dev/null +++ b/cmd/sin-code/internal/checkpoint/store.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT +// Purpose: content-addressed workspace snapshots for safe rewind (issue #194). +// Snapshots file bytes BEFORE they are mutated (auto) or the full dirty set +// (manual), and can restore any prior state. M2-safe: modernc.org/sqlite +// for the index (CGO-free, already a project dependency) + stdlib for blob +// I/O. No git, no external tooling, single static binary preserved. +package checkpoint + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +type Store struct { + db *sql.DB + root string +} + +type Snapshot struct { + ID string + SessionID string + Label string + CreatedAt time.Time + Files []FileRef +} + +type FileRef struct { + Path string + Hash string +} + +func Open(workspace string) (*Store, error) { + root := filepath.Join(workspace, ".sin-code", "checkpoints") + if err := os.MkdirAll(filepath.Join(root, "blobs"), 0o755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", filepath.Join(root, "index.db")) + if err != nil { + return nil, err + } + s := &Store{db: db, root: root} + return s, s.migrate() +} + +func (s *Store) migrate() error { + _, err := s.db.Exec(` +CREATE TABLE IF NOT EXISTS snapshots ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + label TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS files ( + snapshot_id TEXT NOT NULL, + path TEXT NOT NULL, + hash TEXT NOT NULL, + PRIMARY KEY (snapshot_id, path) +); +CREATE INDEX IF NOT EXISTS idx_files_snapshot ON files(snapshot_id);`) + return err +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) putBlob(content []byte) (string, error) { + sum := sha256.Sum256(content) + h := hex.EncodeToString(sum[:]) + dst := filepath.Join(s.root, "blobs", h) + if _, err := os.Stat(dst); err == nil { + return h, nil + } + return h, os.WriteFile(dst, content, 0o644) +} + +func (s *Store) Capture(ctx context.Context, workspace, sessionID, label string, paths []string) (string, error) { + id := fmt.Sprintf("ckpt-%d", time.Now().UnixNano()) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return "", err + } + defer tx.Rollback() + + if _, err = tx.ExecContext(ctx, + `INSERT INTO snapshots(id, session_id, label, created_at) VALUES(?,?,?,?)`, + id, sessionID, label, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + return "", err + } + + for _, rel := range paths { + abs := filepath.Join(workspace, rel) + var hash string + if b, rerr := os.ReadFile(abs); rerr == nil { + if hash, err = s.putBlob(b); err != nil { + return "", err + } + } + if _, err = tx.ExecContext(ctx, + `INSERT OR REPLACE INTO files(snapshot_id, path, hash) VALUES(?,?,?)`, + id, rel, hash); err != nil { + return "", err + } + } + return id, tx.Commit() +} + +func (s *Store) Restore(ctx context.Context, workspace, id string) error { + rows, err := s.db.QueryContext(ctx, `SELECT path, hash FROM files WHERE snapshot_id=?`, id) + if err != nil { + return err + } + defer rows.Close() + + var refs []FileRef + for rows.Next() { + var f FileRef + if err := rows.Scan(&f.Path, &f.Hash); err != nil { + return err + } + refs = append(refs, f) + } + if len(refs) == 0 { + return fmt.Errorf("checkpoint %q not found or empty", id) + } + + for _, f := range refs { + abs := filepath.Join(workspace, f.Path) + if f.Hash == "" { + _ = os.Remove(abs) + continue + } + b, err := os.ReadFile(filepath.Join(s.root, "blobs", f.Hash)) + if err != nil { + return fmt.Errorf("read blob for %s: %w", f.Path, err) + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return err + } + if err := os.WriteFile(abs, b, 0o644); err != nil { + return err + } + } + return nil +} + +func (s *Store) List(ctx context.Context, sessionID string, limit int) ([]Snapshot, error) { + q := `SELECT id, session_id, label, created_at FROM snapshots` + args := []any{} + if sessionID != "" { + q += ` WHERE session_id=?` + args = append(args, sessionID) + } + q += ` ORDER BY created_at DESC LIMIT ?` + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Snapshot + for rows.Next() { + var sn Snapshot + var ts string + if err := rows.Scan(&sn.ID, &sn.SessionID, &sn.Label, &ts); err != nil { + return nil, err + } + sn.CreatedAt, _ = time.Parse(time.RFC3339Nano, ts) + out = append(out, sn) + } + return out, nil +} + +// Prune deletes the blob file for the given hash. Used by GC after +// the last snapshot referencing a hash has been removed. +func (s *Store) Prune(hash string) error { + if hash == "" { + return nil + } + return os.Remove(filepath.Join(s.root, "blobs", hash)) +} diff --git a/cmd/sin-code/internal/checkpoint/store_test.go b/cmd/sin-code/internal/checkpoint/store_test.go new file mode 100644 index 00000000..defa992f --- /dev/null +++ b/cmd/sin-code/internal/checkpoint/store_test.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for issue #194 — checkpoint store. +package checkpoint + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestStore_CaptureRestoreRoundTrip(t *testing.T) { + ws := t.TempDir() + st, err := Open(ws) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + // Create a file with original content. + original := []byte("original content\n") + if err := os.WriteFile(filepath.Join(ws, "a.txt"), original, 0o644); err != nil { + t.Fatal(err) + } + + // Snapshot it. + id, err := st.Capture(context.Background(), ws, "sess-1", "before-edit", []string{"a.txt"}) + if err != nil { + t.Fatal(err) + } + if id == "" { + t.Fatal("expected non-empty id") + } + + // Mutate it. + if err := os.WriteFile(filepath.Join(ws, "a.txt"), []byte("changed\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Rewind. + if err := st.Restore(context.Background(), ws, id); err != nil { + t.Fatal(err) + } + + // Verify content. + got, err := os.ReadFile(filepath.Join(ws, "a.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != string(original) { + t.Errorf("expected %q, got %q", original, got) + } +} + +func TestStore_TombstoneRemovesNewFile(t *testing.T) { + ws := t.TempDir() + st, err := Open(ws) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + // File does not exist at snapshot time — tombstone. + id, err := st.Capture(context.Background(), ws, "sess-1", "no-file-yet", []string{"ghost.txt"}) + if err != nil { + t.Fatal(err) + } + + // Create the file after the snapshot. + if err := os.WriteFile(filepath.Join(ws, "ghost.txt"), []byte("spooky"), 0o644); err != nil { + t.Fatal(err) + } + + // Rewind — file should be removed. + if err := st.Restore(context.Background(), ws, id); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(ws, "ghost.txt")); !os.IsNotExist(err) { + t.Errorf("expected ghost.txt to be removed by rewind, stat err = %v", err) + } +} + +func TestStore_BlobDedup(t *testing.T) { + ws := t.TempDir() + st, err := Open(ws) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + content := []byte("hello world") + // Create two files with the same content. + if err := os.WriteFile(filepath.Join(ws, "a.txt"), content, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws, "b.txt"), content, 0o644); err != nil { + t.Fatal(err) + } + + // Capture both. + if _, err := st.Capture(context.Background(), ws, "s1", "dup", + []string{"a.txt", "b.txt"}); err != nil { + t.Fatal(err) + } + + // Count blobs in the blob dir. + blobs, err := os.ReadDir(filepath.Join(ws, ".sin-code", "checkpoints", "blobs")) + if err != nil { + t.Fatal(err) + } + if len(blobs) != 1 { + t.Errorf("expected 1 blob (dedup), got %d", len(blobs)) + } +} + +func TestStore_ListNewestFirst(t *testing.T) { + ws := t.TempDir() + st, err := Open(ws) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + // Capture three snapshots with no sleep — they may share the + // same nanosecond on fast hardware. Use distinct files to + // ensure ordering is content-based. + _ = os.WriteFile(filepath.Join(ws, "a.txt"), []byte("1"), 0o644) + id1, err := st.Capture(context.Background(), ws, "sess-1", "first", []string{"a.txt"}) + if err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(ws, "a.txt"), []byte("2"), 0o644) + id2, err := st.Capture(context.Background(), ws, "sess-1", "second", []string{"a.txt"}) + if err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(ws, "a.txt"), []byte("3"), 0o644) + id3, err := st.Capture(context.Background(), ws, "sess-1", "third", []string{"a.txt"}) + if err != nil { + t.Fatal(err) + } + + list, err := st.List(context.Background(), "sess-1", 10) + if err != nil { + t.Fatal(err) + } + if len(list) != 3 { + t.Fatalf("expected 3 snapshots, got %d", len(list)) + } + if list[0].ID != id3 || list[2].ID != id1 { + t.Errorf("expected newest-first order [id3, id2, id1], got [%s, %s, %s]", + list[0].ID, list[1].ID, list[2].ID) + } + _ = id2 +} + +func TestStore_RestoreUnknownID(t *testing.T) { + ws := t.TempDir() + st, err := Open(ws) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + if err := st.Restore(context.Background(), ws, "ckpt-nope"); err == nil { + t.Error("expected error for unknown id") + } +}