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
9 changes: 8 additions & 1 deletion pkg/compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,19 @@ func Compile(ctx context.Context, st *store.Store, opts ...Option) (*Result, err
}

deltas := diff.DiffFlat(flat, prev)
cursorHash := diff.CursorHashFlat(flat)

// Emit and relations share one transaction so the snapshot, node mutations,
// cursor advance, and relation writes commit or roll back together. A relations
// failure must not leave HEAD ahead of the error returned to the caller.
err = st.Tx(ctx, func(tx *sql.Tx) error {
// Fold the in-transaction HEAD id into the cursor hash so a compile whose
// post-state matches an earlier snapshot doesn't collide on cursor_hash.
prevHeadID, err := st.GetHeadSnapshotIDTx(ctx, tx)
if err != nil {
return fmt.Errorf("failed to fetch: head snapshot id: %w", err)
}
cursorHash := diff.CursorHashForCompile(prevHeadID, flat)

if err := emitter.EmitTx(ctx, st, tx,
emitter.WithRoots(roots),
emitter.WithDeltas(deltas),
Expand Down
30 changes: 30 additions & 0 deletions pkg/compiler/compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,36 @@ func TestCompile_Recompile(t *testing.T) {
}
}

// Reverting a file to a prior content state lands the compile post-state on an
// earlier snapshot's content; folding the parent snapshot id keeps cursor_hash
// unique so the revert-compile doesn't hit UNIQUE constraint failed. Regression for #244.
func TestCompile_RevertToPriorStateSucceeds(t *testing.T) {
st := testutil.OpenTestDB(t)
ctx := context.Background()
dir := t.TempDir()

p := writeFile(t, dir, "doc.md", "# Hello\n\nState A.\n")
if _, err := Compile(ctx, st, WithPaths([]string{p}), WithMessage("v1")); err != nil {
t.Fatalf("Compile v1: %v", err)
}

writeFile(t, dir, "doc.md", "# Hello\n\nState B.\n")
if _, err := Compile(ctx, st, WithPaths([]string{p}), WithMessage("v2")); err != nil {
t.Fatalf("Compile v2: %v", err)
}

// Revert to the exact content of v1; the post-state matches snapshot 1.
writeFile(t, dir, "doc.md", "# Hello\n\nState A.\n")
if _, err := Compile(ctx, st, WithPaths([]string{p}), WithMessage("v3")); err != nil {
t.Fatalf("Compile v3 (revert to prior state): %v", err)
}

snaps, _ := st.ListSnapshots(ctx, 10)
if len(snaps) != 3 {
t.Errorf("snapshots = %d, want 3 (each compile lands its own snapshot)", len(snaps))
}
}

func TestCompile_SingleFileRescanAfterBatch(t *testing.T) {
st := testutil.OpenTestDB(t)
ctx := context.Background()
Expand Down
99 changes: 43 additions & 56 deletions pkg/diff/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,87 +14,74 @@ func CursorHash(roots []*parser.ContextNode) string {
}

func CursorHashFlat(flat []*parser.ContextNode) string {
pairs := make([]string, len(flat))
for i, n := range flat {
pairs[i] = n.ID + ":" + n.ContentHash
}
sort.Strings(pairs)

h := xxhash.New()
for _, s := range pairs {
_, _ = h.WriteString(s)
}

var buf [8]byte
binary.BigEndian.PutUint64(buf[:], h.Sum64())
return hex.EncodeToString(buf[:])
writeFlatPairs(h, flat)
return sumHex(h)
}

// Hash a delta set when roots alone don't characterize the post-state (e.g., deletions).
func CursorHashForDeltas(deltas []Delta) string {
pairs := make([]string, len(deltas))
for i, d := range deltas {
pairs[i] = string(d.Op) + ":" + d.NodeID + ":" + d.OldHash + ":" + d.NewHash
}

sort.Strings(pairs)

// Hash a compiled post-state against its parent snapshot so a compile that lands
// on a prior content state (edit, compile, revert, compile) still yields a unique
// digest. prevHeadID is the HEAD snapshot id (monotonic, never reused).
func CursorHashForCompile(prevHeadID int64, flat []*parser.ContextNode) string {
h := xxhash.New()
for _, p := range pairs {
_, _ = h.WriteString(p)
}

var buf [8]byte
binary.BigEndian.PutUint64(buf[:], h.Sum64())
return hex.EncodeToString(buf[:])
writeID(h, prevHeadID)
writeFlatPairs(h, flat)
return sumHex(h)
}

// Hash a change against its parent snapshot so reverting to a prior post-state
// still yields a unique digest. prevHeadID is the HEAD snapshot id (monotonic,
// never reused), so the result is unique per snapshot even when the deltas repeat.
func CursorHashForChange(prevHeadID int64, deltas []Delta) string {
h := xxhash.New()

var idBuf [8]byte
binary.BigEndian.PutUint64(idBuf[:], uint64(prevHeadID))
_, _ = h.Write(idBuf[:])

pairs := make([]string, len(deltas))
for i, d := range deltas {
pairs[i] = string(d.Op) + ":" + d.NodeID + ":" + d.OldHash + ":" + d.NewHash
}

sort.Strings(pairs)
for _, p := range pairs {
_, _ = h.WriteString(p)
}

var out [8]byte
binary.BigEndian.PutUint64(out[:], h.Sum64())
return hex.EncodeToString(out[:])
writeID(h, prevHeadID)
writeDeltaPairs(h, deltas)
return sumHex(h)
}

func CursorHashForRollback(prevHeadID, targetID int64, deltas []Delta) string {
h := xxhash.New()
writeID(h, prevHeadID)
writeID(h, targetID)
writeDeltaPairs(h, deltas)
return sumHex(h)
}

var idBuf [8]byte
binary.BigEndian.PutUint64(idBuf[:], uint64(prevHeadID))
_, _ = h.Write(idBuf[:])
// Fold a monotonic snapshot id into the digest.
func writeID(h *xxhash.Digest, id int64) {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], uint64(id))
_, _ = h.Write(buf[:])
}

binary.BigEndian.PutUint64(idBuf[:], uint64(targetID))
_, _ = h.Write(idBuf[:])
// Write the sorted id:contentHash pairs that characterize a flat post-state.
func writeFlatPairs(h *xxhash.Digest, flat []*parser.ContextNode) {
pairs := make([]string, len(flat))
for i, n := range flat {
pairs[i] = n.ID + ":" + n.ContentHash
}
sort.Strings(pairs)

for _, s := range pairs {
_, _ = h.WriteString(s)
}
}

// Write the sorted op:id:old:new pairs that characterize a delta set.
func writeDeltaPairs(h *xxhash.Digest, deltas []Delta) {
pairs := make([]string, len(deltas))
for i, d := range deltas {
pairs[i] = string(d.Op) + ":" + d.NodeID + ":" + d.OldHash + ":" + d.NewHash
}

sort.Strings(pairs)

for _, p := range pairs {
_, _ = h.WriteString(p)
}
}

var out [8]byte
binary.BigEndian.PutUint64(out[:], h.Sum64())
return hex.EncodeToString(out[:])
func sumHex(h *xxhash.Digest) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], h.Sum64())
return hex.EncodeToString(buf[:])
}
30 changes: 30 additions & 0 deletions pkg/diff/hash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,36 @@ func TestCursorHashForChange_OrderStable(t *testing.T) {
}
}

// A compile that lands on a prior content state (edit→compile→revert→compile)
// reproduces the same flat post-state; folding the parent snapshot id keeps the
// digest unique so it doesn't collide on snapshots.cursor_hash. Regression for #244.
func TestCursorHashForCompile_DistinctAcrossRepeatState(t *testing.T) {
flat := []*parser.ContextNode{{ID: "n1", ContentHash: "aaaa"}}

a := CursorHashForCompile(2, flat)
b := CursorHashForCompile(4, flat)
if a == b {
t.Errorf("repeated post-state shares hash across parents: %q", a)
}
if len(a) != 16 {
t.Errorf("len = %d, want 16", len(a))
}
}

func TestCursorHashForCompile_OrderIndependent(t *testing.T) {
a := CursorHashForCompile(3, []*parser.ContextNode{
{ID: "n1", ContentHash: "aaaa"},
{ID: "n2", ContentHash: "bbbb"},
})
b := CursorHashForCompile(3, []*parser.ContextNode{
{ID: "n2", ContentHash: "bbbb"},
{ID: "n1", ContentHash: "aaaa"},
})
if a != b {
t.Errorf("order-dependent: %q vs %q", a, b)
}
}

func TestSnapshotFromNodes(t *testing.T) {
roots := []*parser.ContextNode{
{
Expand Down
7 changes: 6 additions & 1 deletion pkg/mcp/tools/forget.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@ func (d *Deps) HandleForget(ctx context.Context, _ *gomcp.CallToolRequest, input
return nil, nil, err
}

prevHeadID, err := d.Store.GetHeadSnapshotID(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch: head snapshot id: %w", err)
}

if err := emitter.Emit(ctx, d.Store,
emitter.WithRoots(roots),
emitter.WithDeltas(deltas),
emitter.WithCursorHash(diff.CursorHashForDeltas(deltas)),
emitter.WithCursorHash(diff.CursorHashForChange(prevHeadID, deltas)),
emitter.WithMessage("forget:"+mode.String()+":"+input.NodeID),
); err != nil {
return nil, nil, fmt.Errorf("failed to forget: %w", err)
Expand Down
26 changes: 26 additions & 0 deletions pkg/mcp/tools/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1857,6 +1857,32 @@ func TestHandleForget_Reparent_EmitsSingleSnapshot(t *testing.T) {
}
}

// A re-add-then-forget sequence reproduces an identical delta set (same op, id,
// and old hash); folding the parent snapshot id keeps each forget's cursor_hash
// unique instead of colliding on snapshots.cursor_hash. Regression for #244.
func TestHandleForget_ReAddThenForget(t *testing.T) {
d, st := setup(t)
ctx := context.Background()

const anchor = "dupanchor001"
for i := 0; i < 2; i++ {
if _, _, err := d.HandleWrite(ctx, &gomcp.CallToolRequest{}, WriteInput{
Anchor: anchor, Payload: "same payload",
}); err != nil {
t.Fatalf("HandleWrite #%d: %v", i+1, err)
}
if _, _, err := d.HandleForget(ctx, &gomcp.CallToolRequest{}, ForgetInput{NodeID: anchor}); err != nil {
t.Fatalf("HandleForget #%d: %v", i+1, err)
}
}

snaps, err := st.ListSnapshots(ctx, 100)
must(t, err)
if len(snaps) != 4 {
t.Fatalf("snapshots = %d, want 4 (write+forget twice, each lands its own snapshot)", len(snaps))
}
}

func TestHandleForget_MissingNode(t *testing.T) {
d, _ := setup(t)
ctx := context.Background()
Expand Down
Loading