Skip to content

Commit e3ccff7

Browse files
Fix query index cleanup UX and docs (#18)
* Fix query index cleanup contract * Keep query QA plan local * Fix query QA regressions * Handle post-QA edge cases * Fix UI lint after rebase * Align UI doc test with volume routes --------- Co-authored-by: Rowan Trollope <rowantrollope@gmail.com>
1 parent 40dd16b commit e3ccff7

36 files changed

Lines changed: 1064 additions & 100 deletions

cmd/afs/afs_commands_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ func (s stubAFSControlPlane) DownloadQueryModel(context.Context, controlplane.Qu
146146
return controlplane.QueryModelDownloadResult{}, fmt.Errorf("unexpected DownloadQueryModel call")
147147
}
148148

149+
func (s stubAFSControlPlane) CleanQueryIndex(context.Context, string, controlplane.WorkspaceQueryIndexCleanRequest) (controlplane.WorkspaceQueryIndexCleanResponse, error) {
150+
return controlplane.WorkspaceQueryIndexCleanResponse{}, fmt.Errorf("unexpected CleanQueryIndex call")
151+
}
152+
149153
func (s stubAFSControlPlane) DiffWorkspace(context.Context, string, string, string) (controlplane.WorkspaceDiffResponse, error) {
150154
return controlplane.WorkspaceDiffResponse{}, fmt.Errorf("unexpected DiffWorkspace call")
151155
}

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/afs_query_commands_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,92 @@ func findLineWithPrefix(output, prefix string) string {
738738
return ""
739739
}
740740

741+
func TestCmdQueryIndexCleanClearsGeneratedData(t *testing.T) {
742+
_, store, closeStore := setupAFSGrepTest(t)
743+
defer closeStore()
744+
745+
writeLiveAFSFile(t, store, "repo", "/docs/checkpoints.md", "checkpoint recovery guide\n")
746+
if _, err := captureStdout(t, func() error {
747+
return cmdQuery([]string{"query", "index", "status", "--json"})
748+
}); err != nil {
749+
t.Fatalf("cmdQuery(index status) returned error: %v", err)
750+
}
751+
752+
output, err := captureStdout(t, func() error {
753+
return cmdQuery([]string{"query", "index", "clean", "--yes", "--json"})
754+
})
755+
if err != nil {
756+
t.Fatalf("cmdQuery(index clean) returned error: %v", err)
757+
}
758+
759+
var response controlplane.WorkspaceQueryIndexCleanResponse
760+
if err := json.Unmarshal([]byte(output), &response); err != nil {
761+
t.Fatalf("Unmarshal(clean response) returned error: %v\n%s", err, output)
762+
}
763+
if !response.Cleared || response.RemovedFiles != 1 || response.RemovedChunks == 0 {
764+
t.Fatalf("clean response = %+v, want cleared response with removed files/chunks", response)
765+
}
766+
if response.Status.Keyword.Chunks != 0 {
767+
t.Fatalf("post-clean chunks = %d, want 0", response.Status.Keyword.Chunks)
768+
}
769+
}
770+
771+
func TestCmdQueryIndexCleanPromptsBeforeClearing(t *testing.T) {
772+
_, store, closeStore := setupAFSGrepTest(t)
773+
defer closeStore()
774+
775+
writeLiveAFSFile(t, store, "repo", "/docs/checkpoints.md", "checkpoint recovery guide\n")
776+
if _, err := captureStdout(t, func() error {
777+
return cmdQuery([]string{"query", "index", "status", "--json"})
778+
}); err != nil {
779+
t.Fatalf("cmdQuery(index status) returned error: %v", err)
780+
}
781+
782+
input, err := os.CreateTemp(t.TempDir(), "query-index-clean-stdin")
783+
if err != nil {
784+
t.Fatalf("CreateTemp() returned error: %v", err)
785+
}
786+
if _, err := input.WriteString("n\n"); err != nil {
787+
t.Fatalf("WriteString() returned error: %v", err)
788+
}
789+
if _, err := input.Seek(0, 0); err != nil {
790+
t.Fatalf("Seek() returned error: %v", err)
791+
}
792+
origStdin := os.Stdin
793+
os.Stdin = input
794+
t.Cleanup(func() {
795+
os.Stdin = origStdin
796+
_ = input.Close()
797+
})
798+
799+
output, err := captureStdout(t, func() error {
800+
return cmdQuery([]string{"query", "index", "clean"})
801+
})
802+
if err != nil {
803+
t.Fatalf("cmdQuery(index clean) returned error: %v", err)
804+
}
805+
if !strings.Contains(output, "Are you sure you want to clear generated query index data for repo?") {
806+
t.Fatalf("cmdQuery(index clean) output = %q, want confirmation prompt", output)
807+
}
808+
if !strings.Contains(output, "Query index clean cancelled.") {
809+
t.Fatalf("cmdQuery(index clean) output = %q, want cancellation message", output)
810+
}
811+
812+
statusOutput, err := captureStdout(t, func() error {
813+
return cmdQuery([]string{"query", "index", "status", "--json"})
814+
})
815+
if err != nil {
816+
t.Fatalf("cmdQuery(index status after cancel) returned error: %v", err)
817+
}
818+
var status controlplane.WorkspaceQueryIndexStatus
819+
if err := json.Unmarshal([]byte(statusOutput), &status); err != nil {
820+
t.Fatalf("Unmarshal(status) returned error: %v\n%s", err, statusOutput)
821+
}
822+
if status.Keyword.Chunks == 0 {
823+
t.Fatalf("status after cancelled clean = %+v, want indexed chunks to remain", status)
824+
}
825+
}
826+
741827
func TestWorkspaceQueryConfigFallsBackWhenConfigRouteIsMissing(t *testing.T) {
742828
cfg, err := workspaceQueryConfig(context.Background(), stubAFSControlPlane{
743829
workspaceConfigErr: os.ErrNotExist,

cmd/afs/afs_query_index_commands.go

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"bufio"
45
"context"
56
"encoding/json"
67
"flag"
@@ -142,20 +143,42 @@ func runWorkspaceQueryIndexClean(workspace string, args []string) error {
142143
fs := flag.NewFlagSet("query index clean", flag.ContinueOnError)
143144
fs.SetOutput(io.Discard)
144145
var jsonOut bool
146+
var yes bool
145147
fs.BoolVar(&jsonOut, "json", false, "write JSON output")
148+
fs.BoolVar(&yes, "yes", false, "confirm removal of generated query index data")
149+
fs.BoolVar(&yes, "y", false, "confirm removal of generated query index data")
146150
if err := fs.Parse(args); err != nil {
147151
return fmt.Errorf("%s", workspaceQueryIndexUsageText(filepath.Base(os.Args[0])))
148152
}
149153
if fs.NArg() != 0 {
150154
return fmt.Errorf("%s", workspaceQueryIndexUsageText(filepath.Base(os.Args[0])))
151155
}
152-
status, err := workspaceQueryIndexStatusForWorkspace(workspace, "/")
156+
ctx := context.Background()
157+
remote, err := openFSRemoteWorkspace(ctx, workspace)
153158
if err != nil {
154159
return err
155160
}
156-
status.State = "clean"
157-
status.Message = "No query index data was removed."
158-
return writeWorkspaceQueryIndexStatus(status, jsonOut)
161+
defer remote.close()
162+
if !yes {
163+
ok, err := confirmWorkspaceQueryIndexClean(remote.selection.Name)
164+
if err != nil {
165+
return err
166+
}
167+
if !ok {
168+
fmt.Println()
169+
fmt.Println("Query index clean cancelled.")
170+
fmt.Println()
171+
return nil
172+
}
173+
}
174+
response, err := remote.controlPlane.CleanQueryIndex(ctx, remote.selection.ID, controlplane.WorkspaceQueryIndexCleanRequest{
175+
Workspace: remote.selection.Name,
176+
Confirm: true,
177+
})
178+
if err != nil {
179+
return err
180+
}
181+
return writeWorkspaceQueryIndexClean(response, jsonOut)
159182
}
160183

161184
func workspaceQueryIndexStatusForWorkspace(workspace, path string) (controlplane.WorkspaceQueryIndexStatus, error) {
@@ -271,6 +294,47 @@ func embeddingBackfillLabel(result controlplane.QueryEmbeddingBackfillResult) st
271294
return "off"
272295
}
273296

297+
func writeWorkspaceQueryIndexClean(response controlplane.WorkspaceQueryIndexCleanResponse, jsonOut bool) error {
298+
if jsonOut {
299+
enc := json.NewEncoder(os.Stdout)
300+
enc.SetIndent("", " ")
301+
return enc.Encode(response)
302+
}
303+
fmt.Fprintln(os.Stdout, "Query index clean")
304+
fmt.Fprintln(os.Stdout)
305+
fmt.Fprintf(os.Stdout, "workspace %s\n", response.Workspace)
306+
fmt.Fprintf(os.Stdout, "cleared %t\n", response.Cleared)
307+
fmt.Fprintf(os.Stdout, "files %d\n", response.RemovedFiles)
308+
fmt.Fprintf(os.Stdout, "chunks %d\n", response.RemovedChunks)
309+
fmt.Fprintf(os.Stdout, "state %s\n", response.Status.State)
310+
if response.Message != "" {
311+
fmt.Fprintln(os.Stdout)
312+
fmt.Fprintln(os.Stdout, response.Message)
313+
}
314+
return nil
315+
}
316+
317+
func confirmWorkspaceQueryIndexClean(workspace string) (bool, error) {
318+
return confirmWorkspaceQueryIndexCleanWithReader(workspace, bufio.NewReader(os.Stdin))
319+
}
320+
321+
func confirmWorkspaceQueryIndexCleanWithReader(workspace string, reader *bufio.Reader) (bool, error) {
322+
workspace = strings.TrimSpace(workspace)
323+
if workspace == "" {
324+
return false, nil
325+
}
326+
fmt.Println()
327+
fmt.Printf("Are you sure you want to clear generated query index data for %s? Workspace files will not change. [y/N] ", workspace)
328+
raw, err := reader.ReadString('\n')
329+
if err != nil && strings.TrimSpace(raw) == "" {
330+
fmt.Println()
331+
return false, nil
332+
}
333+
fmt.Println()
334+
answer := strings.ToLower(strings.TrimSpace(raw))
335+
return answer == "y" || answer == "yes", nil
336+
}
337+
274338
func workspaceQueryIndexUsageText(bin string) string {
275339
return brandHeaderString() + fmt.Sprintf(`Usage:
276340
%[1]s query index <status|create|rebuild|clean> [flags]
@@ -282,18 +346,20 @@ Subcommands:
282346
status Show keyword query projection and embedding state
283347
create Build keyword chunks and semantic embeddings
284348
rebuild Enqueue existing files for keyword query indexing
285-
clean Remove stale query index data
349+
clean Clear generated query index data for the workspace
286350
287351
Flags:
288352
--json Write JSON output
289353
--path <path> Scope status or rebuild to a workspace path
290354
--wait Wait for rebuild completion
291355
--force Rebuild existing chunks
292356
--embeddings Build semantic embeddings
357+
--yes, -y Confirm full-workspace query index cleanup
293358
294359
Examples:
295360
%[1]s query index status
296361
%[1]s fs repo query index create --embeddings --wait
297362
%[1]s fs repo query index rebuild --path /cmd/afs --wait
363+
%[1]s query index clean --yes
298364
`, bin)
299365
}

cmd/afs/backend.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ type afsControlPlane interface {
146146
RebuildQueryIndex(ctx context.Context, workspace string, request controlplane.WorkspaceQueryIndexRebuildRequest) (controlplane.WorkspaceQueryIndexRebuildResponse, error)
147147
QueryModelStatus(ctx context.Context, request controlplane.QueryModelStatusRequest) (controlplane.QueryModelStatus, error)
148148
DownloadQueryModel(ctx context.Context, request controlplane.QueryModelDownloadRequest) (controlplane.QueryModelDownloadResult, error)
149+
CleanQueryIndex(ctx context.Context, workspace string, request controlplane.WorkspaceQueryIndexCleanRequest) (controlplane.WorkspaceQueryIndexCleanResponse, error)
149150
DiffWorkspace(ctx context.Context, workspace, baseView, headView string) (controlplane.WorkspaceDiffResponse, error)
150151
RestoreCheckpoint(ctx context.Context, workspace, checkpointID string) error
151152
SaveCheckpoint(ctx context.Context, input controlplane.SaveCheckpointRequest) (bool, error)

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

0 commit comments

Comments
 (0)