Skip to content

Commit 2d389f3

Browse files
committed
Fix query QA regressions
1 parent bcd513a commit 2d389f3

19 files changed

Lines changed: 504 additions & 74 deletions

cmd/afs/afs_mcp_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,34 @@ func TestAFSMCPCheckpointCreateAllowsUnchangedWorkspace(t *testing.T) {
257257
}
258258
}
259259

260+
func TestAFSMCPCheckpointCreateGeneratesCheckpointNameWhenOmitted(t *testing.T) {
261+
t.Helper()
262+
263+
server, closeFn := setupAFSMCPTestServer(t)
264+
defer closeFn()
265+
server.profile = controlplane.MCPProfileWorkspaceRWCheckpoint
266+
267+
checkpointResult := server.callTool(context.Background(), "checkpoint_create", map[string]any{})
268+
if checkpointResult.IsError {
269+
t.Fatalf("checkpoint_create without name returned error result: %+v", checkpointResult)
270+
}
271+
272+
var checkpointPayload map[string]any
273+
if err := decodeStructuredContent(checkpointResult.StructuredContent, &checkpointPayload); err != nil {
274+
t.Fatalf("decodeStructuredContent(checkpoint generated) returned error: %v", err)
275+
}
276+
if created, _ := checkpointPayload["created"].(bool); !created {
277+
t.Fatalf("checkpoint_create created = %#v, want true", checkpointPayload["created"])
278+
}
279+
checkpoint, _ := checkpointPayload["checkpoint"].(string)
280+
if !strings.HasPrefix(checkpoint, "save-") {
281+
t.Fatalf("checkpoint_create checkpoint = %#v, want save-*", checkpointPayload["checkpoint"])
282+
}
283+
if _, err := server.store.getSavepointMeta(context.Background(), "repo", checkpoint); err != nil {
284+
t.Fatalf("getSavepointMeta(%s) returned error: %v", checkpoint, err)
285+
}
286+
}
287+
260288
func TestAFSMCPFileWriteDoesNotRematerializeLocalWorkspaceCache(t *testing.T) {
261289
t.Helper()
262290

cmd/afs/config_commands_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,110 @@ func TestCmdConfigResetRemovesConfigAndState(t *testing.T) {
304304
}
305305
}
306306

307+
func TestCmdConfigResetWithAlternateConfigPreservesDefaultState(t *testing.T) {
308+
t.Helper()
309+
310+
withTempHome(t)
311+
configFile := filepath.Join(t.TempDir(), "afs.config.json")
312+
origConfigPath := cfgPathOverride
313+
cfgPathOverride = configFile
314+
t.Cleanup(func() {
315+
cfgPathOverride = origConfigPath
316+
})
317+
318+
if err := os.WriteFile(configFile, []byte(`{"mode":"sync"}`), 0o600); err != nil {
319+
t.Fatalf("WriteFile(config) returned error: %v", err)
320+
}
321+
322+
rawDefaultState, err := json.MarshalIndent(state{
323+
Mode: modeSync,
324+
CurrentWorkspace: "default-workspace",
325+
LocalPath: t.TempDir(),
326+
}, "", " ")
327+
if err != nil {
328+
t.Fatalf("json.MarshalIndent(default state) returned error: %v", err)
329+
}
330+
if err := os.MkdirAll(filepath.Dir(defaultStatePath()), 0o700); err != nil {
331+
t.Fatalf("MkdirAll(default state dir) returned error: %v", err)
332+
}
333+
if err := os.WriteFile(defaultStatePath(), rawDefaultState, 0o600); err != nil {
334+
t.Fatalf("WriteFile(defaultStatePath) returned error: %v", err)
335+
}
336+
337+
out, err := captureStdout(t, func() error {
338+
return cmdConfig([]string{"config", "reset"})
339+
})
340+
if err != nil {
341+
t.Fatalf("cmdConfig(reset) returned error: %v", err)
342+
}
343+
if strings.Contains(out, "Unmounted workspace default-workspace") {
344+
t.Fatalf("cmdConfig(reset) output = %q, should not unmount default runtime for an alternate config", out)
345+
}
346+
if _, err := os.Stat(defaultStatePath()); err != nil {
347+
t.Fatalf("defaultStatePath removed by alternate-config reset: %v", err)
348+
}
349+
}
350+
351+
func TestCmdConfigResetWithAlternateConfigRemovesOnlyScopedState(t *testing.T) {
352+
t.Helper()
353+
354+
withTempHome(t)
355+
configFile := filepath.Join(t.TempDir(), "afs.config.json")
356+
origConfigPath := cfgPathOverride
357+
cfgPathOverride = configFile
358+
t.Cleanup(func() {
359+
cfgPathOverride = origConfigPath
360+
})
361+
362+
if err := os.WriteFile(configFile, []byte(`{"mode":"sync"}`), 0o600); err != nil {
363+
t.Fatalf("WriteFile(config) returned error: %v", err)
364+
}
365+
366+
scopedStatePath := statePath()
367+
rawScopedState, err := json.MarshalIndent(state{
368+
Mode: modeSync,
369+
CurrentWorkspace: "scoped-workspace",
370+
LocalPath: t.TempDir(),
371+
}, "", " ")
372+
if err != nil {
373+
t.Fatalf("json.MarshalIndent(scoped state) returned error: %v", err)
374+
}
375+
if err := os.MkdirAll(filepath.Dir(scopedStatePath), 0o700); err != nil {
376+
t.Fatalf("MkdirAll(scoped state dir) returned error: %v", err)
377+
}
378+
if err := os.WriteFile(scopedStatePath, rawScopedState, 0o600); err != nil {
379+
t.Fatalf("WriteFile(scopedStatePath) returned error: %v", err)
380+
}
381+
382+
rawDefaultState, err := json.MarshalIndent(state{
383+
Mode: modeSync,
384+
CurrentWorkspace: "default-workspace",
385+
LocalPath: t.TempDir(),
386+
}, "", " ")
387+
if err != nil {
388+
t.Fatalf("json.MarshalIndent(default state) returned error: %v", err)
389+
}
390+
if err := os.MkdirAll(filepath.Dir(defaultStatePath()), 0o700); err != nil {
391+
t.Fatalf("MkdirAll(default state dir) returned error: %v", err)
392+
}
393+
if err := os.WriteFile(defaultStatePath(), rawDefaultState, 0o600); err != nil {
394+
t.Fatalf("WriteFile(defaultStatePath) returned error: %v", err)
395+
}
396+
397+
if _, err := captureStdout(t, func() error {
398+
return cmdConfig([]string{"config", "reset"})
399+
}); err != nil {
400+
t.Fatalf("cmdConfig(reset) returned error: %v", err)
401+
}
402+
403+
if _, err := os.Stat(scopedStatePath); !errors.Is(err, os.ErrNotExist) {
404+
t.Fatalf("scoped state still exists after reset: %v", err)
405+
}
406+
if _, err := os.Stat(defaultStatePath()); err != nil {
407+
t.Fatalf("default state removed by scoped reset: %v", err)
408+
}
409+
}
410+
307411
func TestCmdConfigSetAgentNamePersistsFriendlyAgentName(t *testing.T) {
308412
t.Helper()
309413

cmd/afs/controlplane_http_client.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ type httpSaveCheckpointRequest struct {
8585
}
8686

8787
type httpSaveCheckpointResponse struct {
88-
Saved bool `json:"saved"`
88+
Saved bool `json:"saved"`
89+
CheckpointID string `json:"checkpoint_id,omitempty"`
8990
}
9091

9192
func newHTTPControlPlaneClient(ctx context.Context, cfg config) (*httpControlPlaneClient, string, error) {

cmd/afs/mount_commands.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,9 +1076,10 @@ With no workspace, lists workspaces and prompts for a selection.
10761076
With no directory, prompts for a local folder.
10771077
Use --readonly to make this mount refuse local writes.
10781078
Use --session to name this mount session separately from agent.name.
1079-
When mounting to a populated local folder, AFS shows the safe reconciliation
1080-
plan and asks before uploading or downloading files. Use --yes to accept a
1081-
safe plan non-interactively; conflicts still block mount.
1079+
When mounting to a populated local folder, AFS shows a reconciliation plan.
1080+
Disjoint local-only and remote-only files are presented as a safe import plan
1081+
that you can confirm or accept with --yes. Same-path conflicts still block
1082+
mount until you move one side aside.
10821083
Live Mount mode requires an empty local folder unless --yes is passed, because
10831084
the NFS/FUSE mount hides any local files that already exist there.
10841085
The directory is preserved on unmount unless --delete is used.

cmd/afs/reset_command.go

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ package main
22

33
import (
44
"errors"
5+
"fmt"
56
"os"
67
"path/filepath"
8+
"strings"
9+
"syscall"
10+
"time"
711
)
812

913
func cmdReset() error {
10-
if st, err := loadState(); err == nil {
11-
if st.MountPID > 0 || st.SyncPID > 0 {
12-
if err := unmountAllActive(false); err != nil {
13-
return err
14-
}
14+
targetStatePath := statePath()
15+
if st, err := loadStateFromPath(targetStatePath); err == nil {
16+
if err := stopRuntimeForReset(st, targetStatePath); err != nil {
17+
return err
1518
}
1619
} else if !errors.Is(err, os.ErrNotExist) {
1720
return err
@@ -24,13 +27,10 @@ func cmdReset() error {
2427
return err
2528
}
2629

27-
removedState := false
28-
if err := os.RemoveAll(stateDir()); err != nil {
30+
removedState, err := removeResetScopedState(targetStatePath)
31+
if err != nil {
2932
return err
3033
}
31-
if _, err := os.Stat(stateDir()); errors.Is(err, os.ErrNotExist) {
32-
removedState = true
33-
}
3434

3535
rows := []outputRow{
3636
{Label: "config", Value: ternaryString(removedConfig, compactDisplayPath(configPath()), "already clear")},
@@ -40,3 +40,113 @@ func cmdReset() error {
4040
printSection(markerSuccess+" "+clr(ansiBold, "local state reset"), rows)
4141
return nil
4242
}
43+
44+
func stopRuntimeForReset(st state, targetStatePath string) error {
45+
reg, err := loadMountRegistry()
46+
if err != nil {
47+
return err
48+
}
49+
if localPath := st.LocalPath; localPath != "" {
50+
if rec, ok := removeMountByPath(&reg, localPath); ok {
51+
return unmountMountRecord(reg, rec, false)
52+
}
53+
}
54+
if handled, err := stopSyncServicesIfActiveAtPath(st, targetStatePath, false); handled || err != nil {
55+
return err
56+
}
57+
return nil
58+
}
59+
60+
func stopSyncServicesIfActiveAtPath(st state, targetStatePath string, deleteLocal bool) (bool, error) {
61+
if strings.TrimSpace(st.Mode) != modeSync {
62+
return false, nil
63+
}
64+
65+
fmt.Println()
66+
67+
if st.SyncPID > 0 && processAlive(st.SyncPID) {
68+
s := startStep("Stopping sync daemon")
69+
if err := terminatePID(st.SyncPID, 5*time.Second); err != nil {
70+
s.fail(err.Error())
71+
} else {
72+
s.succeed(fmt.Sprintf("pid %d", st.SyncPID))
73+
}
74+
}
75+
if localPath := strings.TrimSpace(st.LocalPath); localPath != "" && deleteLocal {
76+
if err := os.RemoveAll(localPath); err != nil {
77+
fmt.Printf(" %s local sync folder preserved at %s (%v)\n", clr(ansiYellow, "!"), localPath, err)
78+
}
79+
}
80+
81+
if deleteLocal {
82+
workspace := strings.TrimSpace(st.CurrentWorkspace)
83+
_ = removeSyncState(workspace)
84+
}
85+
closeManagedWorkspaceSession(configFromState(st), strings.TrimSpace(st.CurrentWorkspace), strings.TrimSpace(st.SessionID))
86+
87+
if err := os.Remove(targetStatePath); err != nil && !errors.Is(err, os.ErrNotExist) {
88+
return true, err
89+
}
90+
local := "preserved"
91+
if deleteLocal {
92+
local = "deleted"
93+
}
94+
fmt.Printf("Unmounted workspace %s\n", currentWorkspaceLabel(st.CurrentWorkspace))
95+
fmt.Printf("path %s\n", homeRelativeDisplayPath(st.LocalPath))
96+
fmt.Printf("local %s\n", local)
97+
return true, nil
98+
}
99+
100+
func removeResetScopedState(targetStatePath string) (bool, error) {
101+
removed := false
102+
if err := os.Remove(targetStatePath); err == nil {
103+
removed = true
104+
} else if !errors.Is(err, os.ErrNotExist) {
105+
return false, err
106+
}
107+
108+
reg, err := loadMountRegistry()
109+
if err != nil {
110+
return removed, err
111+
}
112+
if len(reg.Mounts) == 0 {
113+
if err := os.Remove(mountRegistryPath()); err == nil {
114+
removed = true
115+
} else if !errors.Is(err, os.ErrNotExist) {
116+
return false, err
117+
}
118+
if err := os.Remove(legacyMountRegistryPath()); err == nil {
119+
removed = true
120+
} else if !errors.Is(err, os.ErrNotExist) {
121+
return false, err
122+
}
123+
}
124+
125+
for _, dir := range []string{filepath.Dir(targetStatePath), syncStateDir(), stateDir()} {
126+
if err := removeDirIfEmpty(dir); err != nil {
127+
return false, err
128+
}
129+
}
130+
if _, err := os.Stat(stateDir()); errors.Is(err, os.ErrNotExist) {
131+
removed = true
132+
}
133+
return removed, nil
134+
}
135+
136+
func removeDirIfEmpty(path string) error {
137+
if strings.TrimSpace(path) == "" {
138+
return nil
139+
}
140+
err := os.Remove(path)
141+
if err == nil || errors.Is(err, os.ErrNotExist) {
142+
return nil
143+
}
144+
if errors.Is(err, os.ErrExist) || errors.Is(err, syscall.ENOTEMPTY) {
145+
return nil
146+
}
147+
var pathErr *os.PathError
148+
if errors.As(err, &pathErr) && pathErr.Err == syscall.ENOTEMPTY {
149+
return nil
150+
}
151+
return err
152+
}

cmd/afs/sync_daemon.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ func (d *syncDaemon) validateInitialSyncSafety(ctx context.Context) error {
365365
}
366366

367367
return fmt.Errorf(
368-
"Mount blocked for workspace %q: local path %q is already populated and the remote workspace is not empty.\nUse an empty directory, import the local directory into a new workspace, or move conflicting files aside first.",
368+
"Mount blocked for workspace %q: local path %q is already populated and the remote workspace is not empty.\nUse an empty directory, import the local directory into a new workspace, or rerun `afs ws mount --dry-run` to inspect the reconciliation plan and move overlapping files aside if needed.",
369369
d.cfg.Workspace,
370370
d.cfg.LocalRoot,
371371
)

cmd/afs/sync_integration_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,51 @@ func TestMountReconcileAllowsApprovedSafeUnion(t *testing.T) {
171171
}
172172
}
173173

174+
func TestMountReconcileReportsEmptyRemoteImport(t *testing.T) {
175+
t.Helper()
176+
env := newSyncTestEnv(t)
177+
env.writeLocalFile(t, "local-only.txt", "hello")
178+
179+
daemonClient := client.New(env.rdb, env.mountKey)
180+
cfg := syncDaemonConfig{
181+
Workspace: env.workspace,
182+
LocalRoot: env.localRoot,
183+
FS: daemonClient,
184+
Store: env.store,
185+
MaxFileBytes: 16 * 1024 * 1024,
186+
WatcherDebounce: 20 * time.Millisecond,
187+
}
188+
d, err := newSyncDaemon(cfg)
189+
if err != nil {
190+
t.Fatalf("newSyncDaemon: %v", err)
191+
}
192+
plan, err := buildMountReconcilePlan(context.Background(), d)
193+
if err != nil {
194+
t.Fatalf("buildMountReconcilePlan: %v", err)
195+
}
196+
if plan.ImportCount != 1 || plan.DownloadCount != 0 || plan.ConflictCount != 0 {
197+
t.Fatalf("plan counts = import:%d download:%d conflict:%d, want 1/0/0", plan.ImportCount, plan.DownloadCount, plan.ConflictCount)
198+
}
199+
if !plan.requiresConfirmation() {
200+
t.Fatal("requiresConfirmation() = false, want true for empty-remote local import")
201+
}
202+
requireMountOp(t, plan, "I", "local-only.txt")
203+
204+
approveMountReconcilePlan(d, plan)
205+
if err := d.Start(context.Background()); err != nil {
206+
t.Fatalf("Start() after approved empty-remote import: %v", err)
207+
}
208+
env.daemon = d
209+
defer env.stopDaemon()
210+
211+
assertEventually(t, 3*time.Second, "local-only.txt to upload", func() bool {
212+
return env.remoteExists(t, "local-only.txt")
213+
})
214+
if got := env.readRemoteFile(t, "local-only.txt"); got != "hello" {
215+
t.Fatalf("remote local-only.txt = %q, want hello", got)
216+
}
217+
}
218+
174219
func TestMountReconcileReportsOfflineLocalCreate(t *testing.T) {
175220
t.Helper()
176221
env := newSyncTestEnv(t)

0 commit comments

Comments
 (0)