Skip to content

Commit 02bef34

Browse files
authored
Merge pull request #547 from tiendungdev/feat/guarded-file-lifecycle
feat(mcp): add transactional move_file and delete_file edits
2 parents 662b980 + db8ad1b commit 02bef34

11 files changed

Lines changed: 1029 additions & 76 deletions

docs/mcp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ The in-graph coverage tools above (`analyze kind=coverage*`, `index_health` lang
357357
| Tool | Description |
358358
|------|-------------|
359359
| `scaffold` | Generate code, registration wiring, and test stubs from an example symbol |
360-
| `batch_edit` | Apply multiple edits in dependency order, re-index between steps |
360+
| `batch_edit` | Atomically apply `edit_symbol`, `edit_file`, `move_file`, and `delete_file` operations with durable rollback receipts |
361361
| `diff_context` | Git diff enriched with callers, callees, community, processes, per-file risk |
362362
| `prefetch_context` | Predict needed symbols from task description and recent activity. Accepts `max_bytes` / `max_tokens` budget caps |
363363

internal/mcp/batch_edit_hetero_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,21 @@ func TestBatchEditItemKind(t *testing.T) {
5454
require.Equal(t, "edit_file", batchEditItem{Path: "p"}.kind(), "a path infers edit_file")
5555
require.Equal(t, "edit_file", batchEditItem{Op: "edit_file", Path: "p"}.kind())
5656
require.Equal(t, "edit_symbol", batchEditItem{Op: "edit_symbol", Path: "p"}.kind(), "explicit op wins over inference")
57+
require.Equal(t, "move_file", batchEditItem{Op: "move_file"}.kind())
58+
require.Equal(t, "delete_file", batchEditItem{Op: "delete_file"}.kind())
5759
}
5860

5961
func TestBatchEditItemsSchemaOneOf(t *testing.T) {
6062
schema := batchEditItemsSchema()
6163
branches, ok := schema["oneOf"].([]any)
6264
require.True(t, ok, "items schema must be a oneOf")
63-
require.Len(t, branches, 2)
65+
require.Len(t, branches, 4)
6466
for _, b := range branches {
6567
m := b.(map[string]any)
6668
require.Equal(t, "object", m["type"])
6769
props := m["properties"].(map[string]any)
6870
op := props["op"].(map[string]any)
69-
require.Contains(t, []any{"edit_symbol", "edit_file"}, op["const"], "each branch is discriminated by an op const")
71+
require.Contains(t, []any{"edit_symbol", "edit_file", "move_file", "delete_file"}, op["const"], "each branch is discriminated by an op const")
7072
require.NotEmpty(t, m["required"], "each branch declares required fields")
7173
}
7274
}

internal/mcp/batch_edit_validation_test.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ func TestParseBatchEditsInfersCompleteLegacyShapes(t *testing.T) {
4040
require.Equal(t, "edit_symbol", legacy[0].Op)
4141
}
4242

43+
func TestParseBatchEditsAcceptsExplicitLifecycleShapes(t *testing.T) {
44+
const digest = "0000000000000000000000000000000000000000000000000000000000000000"
45+
branches := batchEditItemsSchema()["oneOf"].([]any)
46+
for _, index := range []int{2, 3} {
47+
required := branches[index].(map[string]any)["required"].([]any)
48+
require.Contains(t, required, "op", "lifecycle operations must be explicitly discriminated")
49+
}
50+
51+
items, err := parseBatchEdits([]any{
52+
map[string]any{
53+
"op": "move_file", "source": "old.txt", "destination": "new.txt",
54+
"expected_sha256": digest,
55+
},
56+
map[string]any{"op": "delete_file", "path": "obsolete.txt"},
57+
})
58+
require.NoError(t, err)
59+
require.Len(t, items, 2)
60+
require.Equal(t, "move_file", items[0].Op)
61+
require.Equal(t, "old.txt", items[0].SourcePath)
62+
require.Equal(t, "new.txt", items[0].DestinationPath)
63+
require.Equal(t, digest, items[0].ExpectedSHA256)
64+
require.Equal(t, "delete_file", items[1].Op)
65+
require.Equal(t, "obsolete.txt", items[1].Path)
66+
}
67+
4368
func TestParseBatchEditsRejectsUnknownOpInLegacyJSONString(t *testing.T) {
4469
_, err := parseBatchEdits(`[{"op":"replace_file","path":"a.go","old_string":"before","new_string":"after"}]`)
4570
require.Error(t, err)
@@ -72,6 +97,18 @@ func TestParseBatchEditsRejectsAmbiguousAndIncompleteShapes(t *testing.T) {
7297
item: map[string]any{"file": "a.go"},
7398
want: "does not match a supported batch edit shape",
7499
},
100+
{
101+
name: "lifecycle-without-discriminator",
102+
item: map[string]any{"source": "old.txt", "destination": "new.txt"},
103+
want: "move_file and delete_file require an explicit op",
104+
},
105+
{
106+
name: "mixed-lifecycle",
107+
item: map[string]any{
108+
"op": "move_file", "source": "old.txt", "destination": "new.txt", "path": "other.txt",
109+
},
110+
want: "mixes fields from multiple batch edit operations",
111+
},
75112
} {
76113
t.Run(test.name, func(t *testing.T) {
77114
_, err := parseBatchEdits([]any{test.item})
@@ -110,8 +147,8 @@ func TestBatchEditUnknownDiscriminatorIsStructuredAndWritesNothing(t *testing.T)
110147
require.Contains(t, payload["message"], `unknown op "replace_file"`)
111148
data := payload["data"].(map[string]any)
112149
require.Equal(t, float64(1), data["item_index"])
113-
require.ElementsMatch(t, []any{"edit_file", "edit_symbol"}, data["accepted_values"])
114-
require.Len(t, data["accepted_shapes"], 2)
150+
require.ElementsMatch(t, []any{"edit_file", "edit_symbol", "move_file", "delete_file"}, data["accepted_values"])
151+
require.Len(t, data["accepted_shapes"], 4)
115152

116153
content, err := os.ReadFile(path)
117154
require.NoError(t, err)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/zzet/gortex/internal/agents"
11+
)
12+
13+
func TestAtomicBatchLifecycleRecoveryRollsBackPartialMove(t *testing.T) {
14+
s := newAtomicBatchTestServer(t, mutationTestWatcher{})
15+
dir := t.TempDir()
16+
source := writeAtomicBatchFixture(t, dir, "source.txt", "source\n")
17+
destination := filepath.Join(dir, "destination.txt")
18+
buffers := map[string]*batchFileBuffer{
19+
source: {
20+
absPath: source, relPath: "source.txt", mode: 0o644,
21+
original: []byte("source\n"), content: []byte("source\n"),
22+
existsBefore: true, existsAfter: false, existenceSet: true,
23+
},
24+
destination: {
25+
absPath: destination, relPath: "destination.txt", mode: 0o644,
26+
content: []byte("source\n"), existsAfter: true, existenceSet: true,
27+
},
28+
}
29+
results := []batchEditResult{{
30+
Op: "move_file", FilePath: "source.txt", DestinationPath: "destination.txt", Status: "validated",
31+
}}
32+
receipt := batchTransactionReceipt{
33+
Version: batchTransactionVersion, TransactionID: "recover-partial-move", Fingerprint: "recovery-fixture",
34+
Status: "preparing", DiskStatus: "unchanged", GraphStatus: "not_started",
35+
Results: results, Summary: batchSummary(results),
36+
}
37+
if err := s.prepareBatchJournal(&receipt, buffers, []string{destination, source}); err != nil {
38+
t.Fatal(err)
39+
}
40+
if err := agents.AtomicWriteFile(destination, []byte("source\n"), 0o644); err != nil {
41+
t.Fatal(err)
42+
}
43+
44+
restarted := &Server{watcher: mutationTestWatcher{}, session: newSessionState()}
45+
recovered, err := restarted.batchTransactionStatus(context.Background(), "recover-partial-move")
46+
if err != nil {
47+
t.Fatal(err)
48+
}
49+
if !recovered.Recovered || recovered.Status != "aborted" || recovered.DiskStatus != "rolled_back" {
50+
t.Fatalf("recovered receipt = %+v", recovered)
51+
}
52+
if got := readAtomicBatchFixture(t, source); got != "source\n" {
53+
t.Fatalf("source = %q", got)
54+
}
55+
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
56+
t.Fatalf("destination survived rollback: %v", err)
57+
}
58+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestAtomicBatchLifecycleOutsideRootRefused(t *testing.T) {
13+
repoRoot := t.TempDir()
14+
outsideRoot := t.TempDir()
15+
t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions"))
16+
s := newReadGuardServer(t, repoRoot)
17+
source := writeAtomicBatchFixture(t, repoRoot, "source.txt", "source\n")
18+
destination := filepath.Join(outsideRoot, "destination.txt")
19+
20+
receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{
21+
atomicFileMove(source, destination, ""),
22+
}, "file-lifecycle-outside-root")
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" || !strings.Contains(receipt.Error, "outside") {
27+
t.Fatalf("receipt = %+v", receipt)
28+
}
29+
if got := readAtomicBatchFixture(t, source); got != "source\n" {
30+
t.Fatalf("source = %q", got)
31+
}
32+
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
33+
t.Fatalf("destination was created: %v", err)
34+
}
35+
}
36+
37+
func TestAtomicBatchLifecycleInvalidDigestAndOverlapRefused(t *testing.T) {
38+
t.Run("invalid digest", func(t *testing.T) {
39+
s := newAtomicBatchTestServer(t, mutationTestWatcher{})
40+
path := writeAtomicBatchFixture(t, t.TempDir(), "source.txt", "source\n")
41+
receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{
42+
atomicFileDelete(path, "not-a-sha256"),
43+
}, "file-lifecycle-invalid-digest")
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" {
48+
t.Fatalf("receipt = %+v", receipt)
49+
}
50+
if got := readAtomicBatchFixture(t, path); got != "source\n" {
51+
t.Fatalf("source = %q", got)
52+
}
53+
})
54+
55+
t.Run("overlapping path ownership", func(t *testing.T) {
56+
s := newAtomicBatchTestServer(t, mutationTestWatcher{})
57+
dir := t.TempDir()
58+
path := writeAtomicBatchFixture(t, dir, "source.txt", "source\n")
59+
receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{
60+
atomicFileEdit(path, "source", "edited"),
61+
atomicFileDelete(path, ""),
62+
}, "file-lifecycle-overlap")
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" || !strings.Contains(receipt.Error, "overlaps") {
67+
t.Fatalf("receipt = %+v", receipt)
68+
}
69+
if got := readAtomicBatchFixture(t, path); got != "source\n" {
70+
t.Fatalf("source = %q", got)
71+
}
72+
})
73+
}
74+
75+
func TestPrepareBatchJournalRequiresExplicitExistenceState(t *testing.T) {
76+
t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions"))
77+
s := newAtomicBatchTestServer(t, mutationTestWatcher{})
78+
path := filepath.Join(t.TempDir(), "empty.txt")
79+
receipt := batchTransactionReceipt{TransactionID: "unset-existence-state"}
80+
err := s.prepareBatchJournal(&receipt, map[string]*batchFileBuffer{
81+
path: {absPath: path, relPath: "empty.txt"},
82+
}, []string{path})
83+
if err == nil || !strings.Contains(err.Error(), "existence state is unset") {
84+
t.Fatalf("prepareBatchJournal error = %v", err)
85+
}
86+
}
87+
88+
func TestAtomicBatchLifecycleDestinationGuards(t *testing.T) {
89+
t.Run("destination symlink", func(t *testing.T) {
90+
if os.PathSeparator == '\\' {
91+
t.Skip("symlink creation is not reliably available on Windows CI")
92+
}
93+
repoRoot := t.TempDir()
94+
t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions"))
95+
s := newReadGuardServer(t, repoRoot)
96+
source := writeAtomicBatchFixture(t, repoRoot, "source.txt", "source\n")
97+
target := writeAtomicBatchFixture(t, repoRoot, "target.txt", "target\n")
98+
destination := filepath.Join(repoRoot, "destination.txt")
99+
if err := os.Symlink(target, destination); err != nil {
100+
t.Fatal(err)
101+
}
102+
103+
receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{
104+
atomicFileMove(source, destination, ""),
105+
}, "file-lifecycle-destination-symlink")
106+
if err != nil {
107+
t.Fatal(err)
108+
}
109+
if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" || !strings.Contains(receipt.Error, "symlink") {
110+
t.Fatalf("receipt = %+v", receipt)
111+
}
112+
if got := readAtomicBatchFixture(t, source); got != "source\n" {
113+
t.Fatalf("source = %q", got)
114+
}
115+
if got := readAtomicBatchFixture(t, target); got != "target\n" {
116+
t.Fatalf("target = %q", got)
117+
}
118+
})
119+
120+
t.Run("symlinked destination parent", func(t *testing.T) {
121+
if os.PathSeparator == '\\' {
122+
t.Skip("symlink creation is not reliably available on Windows CI")
123+
}
124+
repoRoot := t.TempDir()
125+
t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions"))
126+
s := newReadGuardServer(t, repoRoot)
127+
source := writeAtomicBatchFixture(t, repoRoot, "source.txt", "source\n")
128+
realParent := filepath.Join(repoRoot, "real-parent")
129+
if err := os.Mkdir(realParent, 0o755); err != nil {
130+
t.Fatal(err)
131+
}
132+
linkedParent := filepath.Join(repoRoot, "linked-parent")
133+
if err := os.Symlink(realParent, linkedParent); err != nil {
134+
t.Fatal(err)
135+
}
136+
destination := filepath.Join(linkedParent, "destination.txt")
137+
138+
receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{
139+
atomicFileMove(source, destination, ""),
140+
}, "file-lifecycle-symlinked-parent")
141+
if err != nil {
142+
t.Fatal(err)
143+
}
144+
if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" || !strings.Contains(receipt.Error, "symlink") {
145+
t.Fatalf("receipt = %+v", receipt)
146+
}
147+
if got := readAtomicBatchFixture(t, source); got != "source\n" {
148+
t.Fatalf("source = %q", got)
149+
}
150+
if _, err := os.Stat(filepath.Join(realParent, "destination.txt")); !errors.Is(err, os.ErrNotExist) {
151+
t.Fatalf("destination was created: %v", err)
152+
}
153+
})
154+
155+
t.Run("dot-dot traversal destination", func(t *testing.T) {
156+
repoRoot := t.TempDir()
157+
t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions"))
158+
s := newReadGuardServer(t, repoRoot)
159+
source := writeAtomicBatchFixture(t, repoRoot, "source.txt", "source\n")
160+
outsideName := "traversal-destination-" + filepath.Base(repoRoot) + ".txt"
161+
destination := repoRoot + string(os.PathSeparator) + ".." + string(os.PathSeparator) + outsideName
162+
163+
receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{
164+
atomicFileMove(source, destination, ""),
165+
}, "file-lifecycle-dot-dot-destination")
166+
if err != nil {
167+
t.Fatal(err)
168+
}
169+
if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" || !strings.Contains(receipt.Error, "outside") {
170+
t.Fatalf("receipt = %+v", receipt)
171+
}
172+
if got := readAtomicBatchFixture(t, source); got != "source\n" {
173+
t.Fatalf("source = %q", got)
174+
}
175+
if _, err := os.Stat(filepath.Clean(destination)); !errors.Is(err, os.ErrNotExist) {
176+
t.Fatalf("destination was created: %v", err)
177+
}
178+
})
179+
180+
t.Run("delete directory", func(t *testing.T) {
181+
repoRoot := t.TempDir()
182+
t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions"))
183+
s := newReadGuardServer(t, repoRoot)
184+
directory := filepath.Join(repoRoot, "directory")
185+
if err := os.Mkdir(directory, 0o755); err != nil {
186+
t.Fatal(err)
187+
}
188+
189+
receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{
190+
atomicFileDelete(directory, ""),
191+
}, "file-lifecycle-delete-directory")
192+
if err != nil {
193+
t.Fatal(err)
194+
}
195+
if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" {
196+
t.Fatalf("receipt = %+v", receipt)
197+
}
198+
info, err := os.Stat(directory)
199+
if err != nil || !info.IsDir() {
200+
t.Fatalf("directory was changed: info=%v err=%v", info, err)
201+
}
202+
})
203+
}

0 commit comments

Comments
 (0)