Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions cmd/sin-code/checkpoint_cmd.go
Original file line number Diff line number Diff line change
@@ -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,

Check failure on line 100 in cmd/sin-code/checkpoint_cmd.go

View workflow job for this annotation

GitHub Actions / golangci-lint

File is not properly formatted (gofmt)
".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
}
26 changes: 26 additions & 0 deletions cmd/sin-code/internal/agentloop/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()})
Expand Down Expand Up @@ -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 ""
}
187 changes: 187 additions & 0 deletions cmd/sin-code/internal/checkpoint/store.go
Original file line number Diff line number Diff line change
@@ -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 {

Check failure

Code scanning / gosec

Expect directory permissions to be 0750 or less Error

Expect directory permissions to be 0750 or less
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 {

Check failure

Code scanning / gosec

Path traversal via taint analysis Error

Path traversal via taint analysis
return h, nil
}
return h, os.WriteFile(dst, content, 0o644)

Check failure

Code scanning / gosec

Expect WriteFile permissions to be 0600 or less Error

Expect WriteFile permissions to be 0600 or less

Check failure

Code scanning / gosec

Path traversal via taint analysis Error

Path traversal via taint analysis
}

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 {

Check failure

Code scanning / gosec

Potential file inclusion via variable Error

Potential file inclusion via variable
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 {

Check failure

Code scanning / gosec

Expect directory permissions to be 0750 or less Error

Expect directory permissions to be 0750 or less
return err
}
if err := os.WriteFile(abs, b, 0o644); err != nil {

Check failure

Code scanning / gosec

Expect WriteFile permissions to be 0600 or less Error

Expect WriteFile permissions to be 0600 or less

Check failure

Code scanning / gosec

Path traversal via taint analysis Error

Path traversal via taint analysis
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))
}
Loading
Loading