Why: rollback eligibility was implicit (any struct with a mark field),
restore followed symlinks out of the root, a single failing file
aborted the rewind leaving unreported partial state, and the log grew
unboundedly with an O(N) read per record.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestCheckpointedToolSetIsPinned (eligibility is exactly
{write_file, edit_file, multi_edit}), TestRewindSkipsSymlinkTarget
(link and outside target untouched, classified Skipped),
TestRewindPartialFailureContinues (Failed reported, rest restored),
TestCompactKeepsRecentAnchors (original seqs kept, monotonic after),
plus pinned edges for empty ordered, identical-rewrite=Already, and
per-record roots; all pre-existing rewind tests pass unchanged.
Impact: Result gains Failed (additive); Rewind no longer aborts on a
single file; symlinks classify as external changes; Compact bounds the
log; Seq is computed from a cached counter instead of a full read.
Refs: Agent-System-Documents#43 (N3 plan WP3)
233 lines
7.1 KiB
Go
233 lines
7.1 KiB
Go
package checkpoint
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// ---- N3 WP3:检查点收口 ----
|
||
|
||
// 符号链接目标:恢复会跟出根外——归入 Skipped,链接与目标都不动。
|
||
func TestRewindSkipsSymlinkTarget(t *testing.T) {
|
||
root := t.TempDir()
|
||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
real := filepath.Join(root, "real.txt")
|
||
if err := os.WriteFile(real, []byte("被跟踪写入后"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := log.Record("m1", root, "real.txt", []byte("写入前"), true, []byte("被跟踪写入后")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||
if err := os.WriteFile(outside, []byte("根外内容"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Remove(real); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Symlink(outside, real); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
res, err := log.Rewind("m1", []string{"m1"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(res.Skipped) != 1 || res.Skipped[0] != "real.txt" {
|
||
t.Fatalf("符号链接必须进 Skipped:%+v", res)
|
||
}
|
||
if len(res.Restored) != 0 || len(res.Failed) != 0 {
|
||
t.Fatalf("符号链接不得恢复也不得算失败:%+v", res)
|
||
}
|
||
body, _ := os.ReadFile(outside)
|
||
if string(body) != "根外内容" {
|
||
t.Fatalf("根外目标被写:%q", body)
|
||
}
|
||
if info, _ := os.Lstat(real); info == nil || info.Mode()&os.ModeSymlink == 0 {
|
||
t.Fatal("符号链接本身不得被改动")
|
||
}
|
||
}
|
||
|
||
// 部分恢复:一个文件恢复动作失败(所在目录只读,Remove 失败),
|
||
// 另一个照常恢复,失败进 Result.Failed,不中止。
|
||
func TestRewindPartialFailureContinues(t *testing.T) {
|
||
root := t.TempDir()
|
||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
dirA, dirB := filepath.Join(root, "a"), filepath.Join(root, "b")
|
||
for _, d := range []string{dirA, dirB} {
|
||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
// 两个都是「被跟踪创建」的文件(existed=false,当前内容==写入后 → stateRestore)。
|
||
if err := os.WriteFile(filepath.Join(dirA, "keep.txt"), []byte("A后"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dirB, "fail.txt"), []byte("B后"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := log.Record("m1", root, "a/keep.txt", nil, false, []byte("A后")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := log.Record("m1", root, "b/fail.txt", nil, false, []byte("B后")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// dirB 只读:os.Remove 在其中失败。
|
||
if err := os.Chmod(dirB, 0o500); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer func() { _ = os.Chmod(dirB, 0o755) }()
|
||
res, err := log.Rewind("m1", []string{"m1"})
|
||
if err != nil {
|
||
t.Fatalf("单文件失败不得中止:%v", err)
|
||
}
|
||
if len(res.Failed) != 1 || res.Failed[0] != "b/fail.txt" {
|
||
t.Fatalf("失败文件应进 Failed:%+v", res)
|
||
}
|
||
if len(res.Restored) != 1 || res.Restored[0] != "a/keep.txt" {
|
||
t.Fatalf("其余文件照常恢复:%+v", res)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dirA, "keep.txt")); !os.IsNotExist(err) {
|
||
t.Fatal("keep.txt 应被删除")
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dirB, "fail.txt")); err != nil {
|
||
t.Fatal("fail.txt 应保留")
|
||
}
|
||
}
|
||
|
||
// Compact:只留最近 N 个 anchor 的记录,原 Seq 保留,后续 Record 序号单调。
|
||
func TestCompactKeepsRecentAnchors(t *testing.T) {
|
||
root := t.TempDir()
|
||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, tc := range []struct{ anchor, rel string }{
|
||
{"m1", "a"}, {"m2", "b"}, {"m2", "c"}, {"m3", "d"},
|
||
} {
|
||
if err := log.Record(tc.anchor, root, tc.rel, []byte("x"), true, []byte("y")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if err := log.Compact(2); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
all, err := log.read()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(all) != 3 {
|
||
t.Fatalf("Compact(2) 应留 3 条(m2×2 + m3),得到 %d", len(all))
|
||
}
|
||
for _, rec := range all {
|
||
if rec.Anchor == "m1" {
|
||
t.Fatalf("m1 应被裁掉:%+v", all)
|
||
}
|
||
}
|
||
// Seq 保留原值且后续递增单调。
|
||
maxSeq := 0
|
||
for _, rec := range all {
|
||
if rec.Seq > maxSeq {
|
||
maxSeq = rec.Seq
|
||
}
|
||
}
|
||
if err := log.Record("m4", root, "e", []byte("x"), true, []byte("y")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
all, _ = log.read()
|
||
if all[len(all)-1].Seq <= maxSeq {
|
||
t.Fatalf("Compact 后序号必须单调:%d → %d", maxSeq, all[len(all)-1].Seq)
|
||
}
|
||
}
|
||
|
||
// 空 ordered 边界:只回滚 anchor 自己的记录(既有语义钉住)。
|
||
func TestRewindEmptyOrderedKeepsAnchorOnly(t *testing.T) {
|
||
root := t.TempDir()
|
||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("A后"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(root, "b.txt"), []byte("B后"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := log.Record("m1", root, "a.txt", []byte("A前"), true, []byte("A后")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := log.Record("m2", root, "b.txt", []byte("B前"), true, []byte("B后")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
res, err := log.Rewind("m2", nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(res.Restored) != 1 || res.Restored[0] != "b.txt" {
|
||
t.Fatalf("空 ordered 只回滚 anchor 自己:%+v", res)
|
||
}
|
||
if body, _ := os.ReadFile(filepath.Join(root, "a.txt")); string(body) != "A后" {
|
||
t.Fatalf("m1 的文件不得被动:%q", body)
|
||
}
|
||
}
|
||
|
||
// 「外部同字节改写」分类为 Already(内容等价即视为同步,钉住语义)。
|
||
func TestIdenticalExternalRewriteIsAlready(t *testing.T) {
|
||
root := t.TempDir()
|
||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := log.Record("m1", root, "a.txt", []byte("相同内容"), true, []byte("相同内容")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 外部用相同字节重写一遍(mtime 变、内容同)。
|
||
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
res, err := log.Plan("m1", []string{"m1"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(res.Restored) != 0 || len(res.Skipped) != 0 {
|
||
t.Fatalf("同字节改写应为 Already(不进任何分类):%+v", res)
|
||
}
|
||
}
|
||
|
||
// root 移动:记录按各自 Root 恢复,不窜根。
|
||
func TestRewindUsesPerRecordRoot(t *testing.T) {
|
||
root1, root2 := t.TempDir(), t.TempDir()
|
||
log, err := Open(filepath.Join(root1, "cp.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(root2, "a.txt"), []byte("root2 的内容"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := log.Record("m1", root2, "a.txt", []byte("写入前"), true, []byte("root2 的内容")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
res, err := log.Rewind("m1", []string{"m1"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(res.Restored) != 1 {
|
||
t.Fatalf("应按记录内 Root 恢复:%+v", res)
|
||
}
|
||
if body, _ := os.ReadFile(filepath.Join(root2, "a.txt")); string(body) != "写入前" {
|
||
t.Fatalf("root2 未恢复:%q", body)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(root1, "a.txt")); !os.IsNotExist(err) {
|
||
t.Fatal("不得在 root1 造出同名文件")
|
||
}
|
||
}
|