From 517896533401670b8d70ceaf1d70f9051173cbf5 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 7 May 2026 22:45:35 -0700 Subject: [PATCH 1/6] Fix query index cleanup contract --- cmd/afs/afs_commands_test.go | 4 + cmd/afs/afs_query_commands_test.go | 86 ++++ cmd/afs/afs_query_index_commands.go | 76 +++- cmd/afs/backend.go | 1 + cmd/afs/controlplane_http_client.go | 6 + docs/reference/cli.md | 15 +- docs/reference/control-plane-api.md | 40 ++ internal/controlplane/database_manager.go | 18 + internal/controlplane/http.go | 34 ++ internal/controlplane/http_test.go | 18 + internal/controlplane/workspace_query.go | 69 +++ internal/controlplane/workspace_query_test.go | 70 +++ internal/queryindex/queryindex.go | 19 + plans/query-manual-qa.md | 424 ++++++++++++++++++ 14 files changed, 868 insertions(+), 12 deletions(-) create mode 100644 plans/query-manual-qa.md diff --git a/cmd/afs/afs_commands_test.go b/cmd/afs/afs_commands_test.go index a769c5a..23636d6 100644 --- a/cmd/afs/afs_commands_test.go +++ b/cmd/afs/afs_commands_test.go @@ -146,6 +146,10 @@ func (s stubAFSControlPlane) DownloadQueryModel(context.Context, controlplane.Qu return controlplane.QueryModelDownloadResult{}, fmt.Errorf("unexpected DownloadQueryModel call") } +func (s stubAFSControlPlane) CleanQueryIndex(context.Context, string, controlplane.WorkspaceQueryIndexCleanRequest) (controlplane.WorkspaceQueryIndexCleanResponse, error) { + return controlplane.WorkspaceQueryIndexCleanResponse{}, fmt.Errorf("unexpected CleanQueryIndex call") +} + func (s stubAFSControlPlane) DiffWorkspace(context.Context, string, string, string) (controlplane.WorkspaceDiffResponse, error) { return controlplane.WorkspaceDiffResponse{}, fmt.Errorf("unexpected DiffWorkspace call") } diff --git a/cmd/afs/afs_query_commands_test.go b/cmd/afs/afs_query_commands_test.go index 1d39450..67ecbb0 100644 --- a/cmd/afs/afs_query_commands_test.go +++ b/cmd/afs/afs_query_commands_test.go @@ -738,6 +738,92 @@ func findLineWithPrefix(output, prefix string) string { return "" } +func TestCmdQueryIndexCleanClearsGeneratedData(t *testing.T) { + _, store, closeStore := setupAFSGrepTest(t) + defer closeStore() + + writeLiveAFSFile(t, store, "repo", "/docs/checkpoints.md", "checkpoint recovery guide\n") + if _, err := captureStdout(t, func() error { + return cmdQuery([]string{"query", "index", "status", "--json"}) + }); err != nil { + t.Fatalf("cmdQuery(index status) returned error: %v", err) + } + + output, err := captureStdout(t, func() error { + return cmdQuery([]string{"query", "index", "clean", "--yes", "--json"}) + }) + if err != nil { + t.Fatalf("cmdQuery(index clean) returned error: %v", err) + } + + var response controlplane.WorkspaceQueryIndexCleanResponse + if err := json.Unmarshal([]byte(output), &response); err != nil { + t.Fatalf("Unmarshal(clean response) returned error: %v\n%s", err, output) + } + if !response.Cleared || response.RemovedFiles != 1 || response.RemovedChunks == 0 { + t.Fatalf("clean response = %+v, want cleared response with removed files/chunks", response) + } + if response.Status.Keyword.Chunks != 0 { + t.Fatalf("post-clean chunks = %d, want 0", response.Status.Keyword.Chunks) + } +} + +func TestCmdQueryIndexCleanPromptsBeforeClearing(t *testing.T) { + _, store, closeStore := setupAFSGrepTest(t) + defer closeStore() + + writeLiveAFSFile(t, store, "repo", "/docs/checkpoints.md", "checkpoint recovery guide\n") + if _, err := captureStdout(t, func() error { + return cmdQuery([]string{"query", "index", "status", "--json"}) + }); err != nil { + t.Fatalf("cmdQuery(index status) returned error: %v", err) + } + + input, err := os.CreateTemp(t.TempDir(), "query-index-clean-stdin") + if err != nil { + t.Fatalf("CreateTemp() returned error: %v", err) + } + if _, err := input.WriteString("n\n"); err != nil { + t.Fatalf("WriteString() returned error: %v", err) + } + if _, err := input.Seek(0, 0); err != nil { + t.Fatalf("Seek() returned error: %v", err) + } + origStdin := os.Stdin + os.Stdin = input + t.Cleanup(func() { + os.Stdin = origStdin + _ = input.Close() + }) + + output, err := captureStdout(t, func() error { + return cmdQuery([]string{"query", "index", "clean"}) + }) + if err != nil { + t.Fatalf("cmdQuery(index clean) returned error: %v", err) + } + if !strings.Contains(output, "Are you sure you want to clear generated query index data for repo?") { + t.Fatalf("cmdQuery(index clean) output = %q, want confirmation prompt", output) + } + if !strings.Contains(output, "Query index clean cancelled.") { + t.Fatalf("cmdQuery(index clean) output = %q, want cancellation message", output) + } + + statusOutput, err := captureStdout(t, func() error { + return cmdQuery([]string{"query", "index", "status", "--json"}) + }) + if err != nil { + t.Fatalf("cmdQuery(index status after cancel) returned error: %v", err) + } + var status controlplane.WorkspaceQueryIndexStatus + if err := json.Unmarshal([]byte(statusOutput), &status); err != nil { + t.Fatalf("Unmarshal(status) returned error: %v\n%s", err, statusOutput) + } + if status.Keyword.Chunks == 0 { + t.Fatalf("status after cancelled clean = %+v, want indexed chunks to remain", status) + } +} + func TestWorkspaceQueryConfigFallsBackWhenConfigRouteIsMissing(t *testing.T) { cfg, err := workspaceQueryConfig(context.Background(), stubAFSControlPlane{ workspaceConfigErr: os.ErrNotExist, diff --git a/cmd/afs/afs_query_index_commands.go b/cmd/afs/afs_query_index_commands.go index 4e86926..0a898a4 100644 --- a/cmd/afs/afs_query_index_commands.go +++ b/cmd/afs/afs_query_index_commands.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "encoding/json" "flag" @@ -142,20 +143,42 @@ func runWorkspaceQueryIndexClean(workspace string, args []string) error { fs := flag.NewFlagSet("query index clean", flag.ContinueOnError) fs.SetOutput(io.Discard) var jsonOut bool + var yes bool fs.BoolVar(&jsonOut, "json", false, "write JSON output") + fs.BoolVar(&yes, "yes", false, "confirm removal of generated query index data") + fs.BoolVar(&yes, "y", false, "confirm removal of generated query index data") if err := fs.Parse(args); err != nil { return fmt.Errorf("%s", workspaceQueryIndexUsageText(filepath.Base(os.Args[0]))) } if fs.NArg() != 0 { return fmt.Errorf("%s", workspaceQueryIndexUsageText(filepath.Base(os.Args[0]))) } - status, err := workspaceQueryIndexStatusForWorkspace(workspace, "/") + ctx := context.Background() + remote, err := openFSRemoteWorkspace(ctx, workspace) if err != nil { return err } - status.State = "clean" - status.Message = "No query index data was removed." - return writeWorkspaceQueryIndexStatus(status, jsonOut) + defer remote.close() + if !yes { + ok, err := confirmWorkspaceQueryIndexClean(remote.selection.Name) + if err != nil { + return err + } + if !ok { + fmt.Println() + fmt.Println("Query index clean cancelled.") + fmt.Println() + return nil + } + } + response, err := remote.controlPlane.CleanQueryIndex(ctx, remote.selection.ID, controlplane.WorkspaceQueryIndexCleanRequest{ + Workspace: remote.selection.Name, + Confirm: true, + }) + if err != nil { + return err + } + return writeWorkspaceQueryIndexClean(response, jsonOut) } func workspaceQueryIndexStatusForWorkspace(workspace, path string) (controlplane.WorkspaceQueryIndexStatus, error) { @@ -271,6 +294,47 @@ func embeddingBackfillLabel(result controlplane.QueryEmbeddingBackfillResult) st return "off" } +func writeWorkspaceQueryIndexClean(response controlplane.WorkspaceQueryIndexCleanResponse, jsonOut bool) error { + if jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(response) + } + fmt.Fprintln(os.Stdout, "Query index clean") + fmt.Fprintln(os.Stdout) + fmt.Fprintf(os.Stdout, "workspace %s\n", response.Workspace) + fmt.Fprintf(os.Stdout, "cleared %t\n", response.Cleared) + fmt.Fprintf(os.Stdout, "files %d\n", response.RemovedFiles) + fmt.Fprintf(os.Stdout, "chunks %d\n", response.RemovedChunks) + fmt.Fprintf(os.Stdout, "state %s\n", response.Status.State) + if response.Message != "" { + fmt.Fprintln(os.Stdout) + fmt.Fprintln(os.Stdout, response.Message) + } + return nil +} + +func confirmWorkspaceQueryIndexClean(workspace string) (bool, error) { + return confirmWorkspaceQueryIndexCleanWithReader(workspace, bufio.NewReader(os.Stdin)) +} + +func confirmWorkspaceQueryIndexCleanWithReader(workspace string, reader *bufio.Reader) (bool, error) { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return false, nil + } + fmt.Println() + fmt.Printf("Are you sure you want to clear generated query index data for %s? Workspace files will not change. [y/N] ", workspace) + raw, err := reader.ReadString('\n') + if err != nil && strings.TrimSpace(raw) == "" { + fmt.Println() + return false, nil + } + fmt.Println() + answer := strings.ToLower(strings.TrimSpace(raw)) + return answer == "y" || answer == "yes", nil +} + func workspaceQueryIndexUsageText(bin string) string { return brandHeaderString() + fmt.Sprintf(`Usage: %[1]s query index [flags] @@ -282,7 +346,7 @@ Subcommands: status Show keyword query projection and embedding state create Build keyword chunks and semantic embeddings rebuild Enqueue existing files for keyword query indexing - clean Remove stale query index data + clean Clear generated query index data for the workspace Flags: --json Write JSON output @@ -290,10 +354,12 @@ Flags: --wait Wait for rebuild completion --force Rebuild existing chunks --embeddings Build semantic embeddings + --yes, -y Confirm full-workspace query index cleanup Examples: %[1]s query index status %[1]s fs repo query index create --embeddings --wait %[1]s fs repo query index rebuild --path /cmd/afs --wait + %[1]s query index clean --yes `, bin) } diff --git a/cmd/afs/backend.go b/cmd/afs/backend.go index bab182b..aa237bd 100644 --- a/cmd/afs/backend.go +++ b/cmd/afs/backend.go @@ -146,6 +146,7 @@ type afsControlPlane interface { RebuildQueryIndex(ctx context.Context, workspace string, request controlplane.WorkspaceQueryIndexRebuildRequest) (controlplane.WorkspaceQueryIndexRebuildResponse, error) QueryModelStatus(ctx context.Context, request controlplane.QueryModelStatusRequest) (controlplane.QueryModelStatus, error) DownloadQueryModel(ctx context.Context, request controlplane.QueryModelDownloadRequest) (controlplane.QueryModelDownloadResult, error) + CleanQueryIndex(ctx context.Context, workspace string, request controlplane.WorkspaceQueryIndexCleanRequest) (controlplane.WorkspaceQueryIndexCleanResponse, error) DiffWorkspace(ctx context.Context, workspace, baseView, headView string) (controlplane.WorkspaceDiffResponse, error) RestoreCheckpoint(ctx context.Context, workspace, checkpointID string) error SaveCheckpoint(ctx context.Context, input controlplane.SaveCheckpointRequest) (bool, error) diff --git a/cmd/afs/controlplane_http_client.go b/cmd/afs/controlplane_http_client.go index 4ec4c4c..a697e63 100644 --- a/cmd/afs/controlplane_http_client.go +++ b/cmd/afs/controlplane_http_client.go @@ -505,6 +505,12 @@ func (c *httpControlPlaneClient) DownloadQueryModel(ctx context.Context, request return out, err } +func (c *httpControlPlaneClient) CleanQueryIndex(ctx context.Context, workspace string, request controlplane.WorkspaceQueryIndexCleanRequest) (controlplane.WorkspaceQueryIndexCleanResponse, error) { + var out controlplane.WorkspaceQueryIndexCleanResponse + err := c.doJSON(ctx, http.MethodPost, c.workspacePath(workspace, "query", "index", "clean"), request, &out, http.StatusOK) + return out, err +} + func (c *httpControlPlaneClient) DiffWorkspace(ctx context.Context, workspace, baseView, headView string) (controlplane.WorkspaceDiffResponse, error) { params := url.Values{} if strings.TrimSpace(baseView) != "" { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 411f4f1..1557e71 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -465,6 +465,7 @@ afs fs [workspace] query index status afs fs [workspace] query index rebuild --wait afs query model status afs query model download +afs fs [workspace] query index clean --yes ``` Ranks workspace files for a concept or natural-language question. Plain @@ -480,12 +481,12 @@ model cache for the local provider path. Redis vector KNN is used when available, with a direct vector-ranking fallback. Use `grep` when you know the exact text. -Semantic queries do not backfill embeddings. Imports start embedding creation -in the background when the global provider is available. Use -`query index status --json` to inspect files, ready chunks, pending work, -skipped files, and unindexed files. Use `query index create --embeddings --wait` -to explicitly build keyword chunks and semantic embeddings for an existing -workspace. +Existing workspaces are backfilled automatically on first query when Redis +Search is available. `query index status --json` can also catch up pending work +while it inspects files, ready chunks, pending work, skipped files, and +unindexed files. Use `query index rebuild --wait` to force an immediate +backfill. Use `query index clean --yes` to clear generated query chunks and +restart indexing from scratch without changing workspace files. Flags: @@ -507,7 +508,7 @@ Flags: | `--candidate-limit ` | Candidate result limit. | | `--no-rerank` | Disable reranking when available. | | `--keyword` | Keyword-ranked retrieval only. | -| `--semantic` | Vector-only semantic retrieval through the global embedding provider. | +| `--semantic` | Vector-only semantic retrieval through the global embedding provider. Requires a build with semantic retrieval enabled. | | `--intent ` | Extra search intent. | | `--chunk-strategy ` | Chunk strategy. | diff --git a/docs/reference/control-plane-api.md b/docs/reference/control-plane-api.md index a97ecbd..472c0c3 100644 --- a/docs/reference/control-plane-api.md +++ b/docs/reference/control-plane-api.md @@ -187,6 +187,46 @@ The browser-facing read paths accept view/path/depth query parameters where applicable. File mutation still happens through checkpoint/save/import flows rather than a standalone `PUT /files/content` route. +### Query + +- `POST /workspaces/{workspace_id}/query` +- `GET /workspaces/{workspace_id}/query/index/status` +- `POST /workspaces/{workspace_id}/query/index/rebuild` +- `POST /workspaces/{workspace_id}/query/index/clean` + +`POST /workspaces/{workspace_id}/query` accepts the ranked retrieval request +shape used by `file_query`, including: + +- `query` +- `mode` +- `searches` +- `intent` +- `path` +- `limit` +- `all` +- `min_score` +- `candidate_limit` +- `rerank` +- `full` + +`GET /workspaces/{workspace_id}/query/index/status` accepts: + +- `path` + +`POST /workspaces/{workspace_id}/query/index/rebuild` accepts: + +- `path` +- `force` +- `wait` + +`POST /workspaces/{workspace_id}/query/index/clean` accepts: + +- `confirm` + +Clean removes only generated query index state. It does not change workspace +files. Database-scoped equivalents are available under +`/databases/{database_id}/workspaces/{workspace_id}/...`. + ### Activity, Changes, And Agents - `GET /activity` diff --git a/internal/controlplane/database_manager.go b/internal/controlplane/database_manager.go index 6320f30..3e55da6 100644 --- a/internal/controlplane/database_manager.go +++ b/internal/controlplane/database_manager.go @@ -1846,6 +1846,15 @@ func (m *DatabaseManager) RebuildQueryIndex(ctx context.Context, databaseID, wor return service.RebuildQueryIndex(ctx, route.WorkspaceID, request) } +func (m *DatabaseManager) CleanQueryIndex(ctx context.Context, databaseID, workspace string, request WorkspaceQueryIndexCleanRequest) (WorkspaceQueryIndexCleanResponse, error) { + service, _, route, err := m.resolveScopedWorkspace(ctx, databaseID, workspace) + if err != nil { + return WorkspaceQueryIndexCleanResponse{}, err + } + request.Workspace = route.Name + return service.CleanQueryIndex(ctx, route.WorkspaceID, request) +} + func (m *DatabaseManager) QueryResolvedWorkspace(ctx context.Context, workspace string, request mcptools.FileQueryRequest) (mcptools.FileQueryResponse, error) { service, _, route, err := m.resolveWorkspace(ctx, workspace) if err != nil { @@ -1873,6 +1882,15 @@ func (m *DatabaseManager) RebuildResolvedQueryIndex(ctx context.Context, workspa return service.RebuildQueryIndex(ctx, route.WorkspaceID, request) } +func (m *DatabaseManager) CleanResolvedQueryIndex(ctx context.Context, workspace string, request WorkspaceQueryIndexCleanRequest) (WorkspaceQueryIndexCleanResponse, error) { + service, _, route, err := m.resolveWorkspace(ctx, workspace) + if err != nil { + return WorkspaceQueryIndexCleanResponse{}, err + } + request.Workspace = route.Name + return service.CleanQueryIndex(ctx, route.WorkspaceID, request) +} + func (m *DatabaseManager) GetResolvedFileVersionContent(ctx context.Context, workspace, versionID string) (FileVersionContentResponse, error) { service, _, route, err := m.resolveWorkspace(ctx, workspace) if err != nil { diff --git a/internal/controlplane/http.go b/internal/controlplane/http.go index e574c49..61da84d 100644 --- a/internal/controlplane/http.go +++ b/internal/controlplane/http.go @@ -1635,6 +1635,23 @@ func handleWorkspaceRoute( return } writeJSON(w, http.StatusOK, response) + case strings.HasSuffix(workspacePath, "/query/index/clean"): + workspace := strings.TrimSuffix(workspacePath, "/query/index/clean") + if r.Method != http.MethodPost { + writeError(w, fmt.Errorf("%s not allowed", r.Method)) + return + } + var input WorkspaceQueryIndexCleanRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil && !errors.Is(err, io.EOF) { + writeError(w, fmt.Errorf("decode query index clean request: %w", err)) + return + } + response, err := manager.CleanQueryIndex(r.Context(), databaseID, workspace, input) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, response) case strings.HasSuffix(workspacePath, "/query"): workspace := strings.TrimSuffix(workspacePath, "/query") if r.Method != http.MethodPost { @@ -2228,6 +2245,23 @@ func handleResolvedWorkspaceRoute( return } writeJSON(w, http.StatusOK, response) + case strings.HasSuffix(workspacePath, "/query/index/clean"): + workspace := strings.TrimSuffix(workspacePath, "/query/index/clean") + if r.Method != http.MethodPost { + writeError(w, fmt.Errorf("%s not allowed", r.Method)) + return + } + var input WorkspaceQueryIndexCleanRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil && !errors.Is(err, io.EOF) { + writeError(w, fmt.Errorf("decode query index clean request: %w", err)) + return + } + response, err := manager.CleanResolvedQueryIndex(r.Context(), workspace, input) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, response) case strings.HasSuffix(workspacePath, "/query"): workspace := strings.TrimSuffix(workspacePath, "/query") if r.Method != http.MethodPost { diff --git a/internal/controlplane/http_test.go b/internal/controlplane/http_test.go index 8f5ad63..67d9d2d 100644 --- a/internal/controlplane/http_test.go +++ b/internal/controlplane/http_test.go @@ -710,6 +710,24 @@ func TestHTTPResolvedWorkspaceQueryRoutes(t *testing.T) { body, _ := io.ReadAll(queryResp.Body) t.Fatalf("POST resolved query status = %d, want %d, body=%s", queryResp.StatusCode, http.StatusOK, body) } + + cleanBody := `{"confirm":true}` + cleanResp, err := http.Post(server.URL+"/v1/workspaces/"+workspaceID+"/query/index/clean", "application/json", strings.NewReader(cleanBody)) + if err != nil { + t.Fatalf("POST resolved query index clean returned error: %v", err) + } + defer cleanResp.Body.Close() + if cleanResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(cleanResp.Body) + t.Fatalf("POST resolved query index clean status = %d, want %d, body=%s", cleanResp.StatusCode, http.StatusOK, body) + } + var cleaned WorkspaceQueryIndexCleanResponse + if err := json.NewDecoder(cleanResp.Body).Decode(&cleaned); err != nil { + t.Fatalf("Decode(query index clean) returned error: %v", err) + } + if !cleaned.Cleared { + t.Fatalf("query index clean response = %+v, want cleared=true", cleaned) + } } func TestHTTPFileHistoryAndVersionContentRoutes(t *testing.T) { diff --git a/internal/controlplane/workspace_query.go b/internal/controlplane/workspace_query.go index 3b7ece1..8a0ed01 100644 --- a/internal/controlplane/workspace_query.go +++ b/internal/controlplane/workspace_query.go @@ -30,6 +30,11 @@ type WorkspaceQueryIndexRebuildRequest struct { Embeddings bool `json:"embeddings,omitempty"` } +type WorkspaceQueryIndexCleanRequest struct { + Workspace string `json:"workspace,omitempty"` + Confirm bool `json:"confirm,omitempty"` +} + type WorkspaceQueryIndexStatus struct { Workspace string `json:"workspace"` Path string `json:"path,omitempty"` @@ -68,6 +73,31 @@ type QueryEmbeddingBackfillResult struct { Message string `json:"message,omitempty"` } +type WorkspaceQueryIndexCleanResponse struct { + Workspace string `json:"workspace"` + Cleared bool `json:"cleared"` + RemovedFiles int `json:"removed_files"` + RemovedChunks int `json:"removed_chunks"` + Status WorkspaceQueryIndexStatus `json:"status"` + Message string `json:"message,omitempty"` +} + +func SemanticQueryDisabledMessage(workspace string) string { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return "Semantic query is disabled for this workspace." + } + return fmt.Sprintf("Semantic query is disabled for workspace %q.", workspace) +} + +func SemanticQueryUnavailableMessage(workspace string) string { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return "Semantic query is not available in this build yet." + } + return fmt.Sprintf("Semantic query is not available in this build yet for workspace %q.", workspace) +} + func (s *Service) QueryWorkspace(ctx context.Context, workspace string, request mcptools.FileQueryRequest) (mcptools.FileQueryResponse, error) { displayWorkspace := strings.TrimSpace(request.Workspace) if displayWorkspace == "" { @@ -450,6 +480,45 @@ func queryIndexRebuildMessage(enqueued int, embeddings *QueryEmbeddingBackfillRe return fmt.Sprintf("Enqueued %d file(s) for keyword query indexing.", enqueued) } +func (s *Service) CleanQueryIndex(ctx context.Context, workspace string, request WorkspaceQueryIndexCleanRequest) (WorkspaceQueryIndexCleanResponse, error) { + displayWorkspace := strings.TrimSpace(request.Workspace) + if displayWorkspace == "" { + displayWorkspace = workspace + } + if !request.Confirm { + return WorkspaceQueryIndexCleanResponse{}, fmt.Errorf("query index clean requires confirm=true") + } + before, err := s.QueryIndexStatus(ctx, workspace, WorkspaceQueryIndexStatusRequest{ + Workspace: displayWorkspace, + Path: "/", + }) + if err != nil { + return WorkspaceQueryIndexCleanResponse{}, err + } + fsKey, err := s.workspaceQueryFSKey(ctx, workspace) + if err != nil { + return WorkspaceQueryIndexCleanResponse{}, err + } + if err := queryindex.ResetWorkspace(ctx, s.store.rdb, fsKey); err != nil { + return WorkspaceQueryIndexCleanResponse{}, err + } + status, err := s.QueryIndexStatus(ctx, workspace, WorkspaceQueryIndexStatusRequest{ + Workspace: displayWorkspace, + Path: "/", + }) + if err != nil { + return WorkspaceQueryIndexCleanResponse{}, err + } + return WorkspaceQueryIndexCleanResponse{ + Workspace: displayWorkspace, + Cleared: true, + RemovedFiles: before.Keyword.Files, + RemovedChunks: before.Keyword.Chunks, + Status: status, + Message: fmt.Sprintf("Cleared generated query index data for %d file(s) and %d chunk(s). Workspace files were not changed.", before.Keyword.Files, before.Keyword.Chunks), + }, nil +} + func (s *Service) workspaceQueryFSKey(ctx context.Context, workspace string) (string, error) { if _, _, _, err := EnsureWorkspaceRoot(ctx, s.store, workspace); err != nil { return "", err diff --git a/internal/controlplane/workspace_query_test.go b/internal/controlplane/workspace_query_test.go index 2049b60..1f30184 100644 --- a/internal/controlplane/workspace_query_test.go +++ b/internal/controlplane/workspace_query_test.go @@ -189,3 +189,73 @@ func TestRebuildQueryIndexCanCreateSemanticEmbeddings(t *testing.T) { t.Fatalf("embedding_model = %q, %v; want stored model", model, err) } } + +func TestCleanQueryIndexResetsGeneratedData(t *testing.T) { + ctx := context.Background() + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { + _ = rdb.Close() + }) + + store := NewStore(rdb) + service := NewService(Config{}, store) + now := time.Now().UTC() + meta := WorkspaceMeta{ + Version: formatVersion, + Name: "repo", + CreatedAt: now, + UpdatedAt: now, + HeadSavepoint: initialCheckpointName, + DefaultSavepoint: initialCheckpointName, + } + if err := store.PutWorkspaceMeta(ctx, meta); err != nil { + t.Fatalf("PutWorkspaceMeta() returned error: %v", err) + } + + content := []byte("checkpoint recovery\n") + manifestValue := Manifest{ + Version: formatVersion, + Workspace: "repo", + Savepoint: initialCheckpointName, + Entries: map[string]ManifestEntry{ + "/": {Type: "dir", Mode: 0o755, MtimeMs: now.UnixMilli()}, + "/docs/checkpoints.md": {Type: "file", Mode: 0o644, MtimeMs: now.UnixMilli(), Size: int64(len(content)), Inline: base64.StdEncoding.EncodeToString(content)}, + }, + } + savepoint := SavepointMeta{ + Version: formatVersion, + ID: initialCheckpointName, + Name: initialCheckpointName, + Workspace: "repo", + CreatedAt: now, + FileCount: 1, + DirCount: 1, + TotalBytes: int64(len(content)), + } + if err := store.PutSavepoint(ctx, savepoint, manifestValue); err != nil { + t.Fatalf("PutSavepoint() returned error: %v", err) + } + + if _, _, _, err := EnsureWorkspaceRoot(ctx, store, "repo"); err != nil { + t.Fatalf("EnsureWorkspaceRoot() returned error: %v", err) + } + before, err := service.QueryIndexStatus(ctx, "repo", WorkspaceQueryIndexStatusRequest{Path: "/"}) + if err != nil { + t.Fatalf("QueryIndexStatus(before) returned error: %v", err) + } + if before.Keyword.Chunks == 0 { + t.Fatalf("QueryIndexStatus(before) = %+v, want indexed chunks", before.Keyword) + } + + cleaned, err := service.CleanQueryIndex(ctx, "repo", WorkspaceQueryIndexCleanRequest{Confirm: true}) + if err != nil { + t.Fatalf("CleanQueryIndex() returned error: %v", err) + } + if !cleaned.Cleared || cleaned.RemovedFiles != 1 || cleaned.RemovedChunks == 0 { + t.Fatalf("CleanQueryIndex() = %+v, want cleared response with removed files/chunks", cleaned) + } + if cleaned.Status.Keyword.Chunks != 0 { + t.Fatalf("CleanQueryIndex() status keyword = %+v, want zero chunks after clean", cleaned.Status.Keyword) + } +} diff --git a/internal/queryindex/queryindex.go b/internal/queryindex/queryindex.go index 6d5b514..976827c 100644 --- a/internal/queryindex/queryindex.go +++ b/internal/queryindex/queryindex.go @@ -250,6 +250,25 @@ func ResetWorkspace(ctx context.Context, rdb *redis.Client, fsKey string) error } } } + pipe := rdb.Pipeline() + err := scanFiles(ctx, rdb, fsKey, "/", func(fields map[string]string) error { + pipe.HDel(ctx, InodeKey(fsKey, strings.TrimSpace(fields["id"])), + "query_state", + "query_index_version", + "query_skip_reason", + "query_error", + "query_content_hash", + "query_chunk_count", + "query_indexed_at_ms", + ) + return nil + }) + if err != nil { + return err + } + if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) { + return err + } return rdb.Del(ctx, DirtySetKey(fsKey), ReadyKey(fsKey)).Err() } diff --git a/plans/query-manual-qa.md b/plans/query-manual-qa.md new file mode 100644 index 0000000..e300d9a --- /dev/null +++ b/plans/query-manual-qa.md @@ -0,0 +1,424 @@ +# AFS Query Manual QA + +Status: complete +Owner: Codex +Created: 2026-05-07 +Updated: 2026-05-08 + +## Goal + +Manually QA the new AFS `query` feature across every exposed user surface so we +can answer three questions with evidence: + +1. Does the shipped keyword and hybrid-fallback behavior work end to end? +2. Are intentionally unavailable semantic paths reported clearly and + consistently? +3. Do CLI, control-plane HTTP, MCP, and UI surfaces agree on the same contract, + defaults, warnings, and recovery guidance? + +## Scope + +In scope: + +- Top-level and workspace-scoped CLI query commands: + - `afs query` + - `afs fs query` + - `afs query --keyword` + - `afs query --semantic` + - `afs query index ` +- Query argument parsing and typed query document handling: + - plain text + - `expand:` + - `lex:` + - `vec:` + - `hyde:` + - `intent:` +- Query output formats: + - plain + - `--json` + - `--files` + - `--paths` + - `--md` + - `--csv` + - `--xml` +- Workspace query config under `afs ws config`, especially + `query.embeddings.enabled`, `query.embeddings.model`, and + `query.embeddings.chunkStrategy` +- Query index status and rebuild behavior +- Control-plane HTTP workspace query routes +- Local and hosted MCP `file_query` +- Workspace Studio Search tab behavior in the UI +- Contract/documentation alignment for query-specific docs + +Out of scope: + +- Tuning ranking relevance beyond obvious correctness failures +- Performance benchmarking beyond basic responsiveness notes +- Implementing fixes +- Broader grep/manual-QA coverage unrelated to query behavior + +## Current Observations + +Grounded in `main` at `834f311` plus targeted passing tests: + +- `go test ./cmd/afs -run Query` +- `go test ./internal/controlplane -run Query` +- `go test ./internal/mcptools -run FileQuery` + +Observed implementation shape: + +- Hybrid and keyword requests are live and route through control-plane query + handlers. +- Semantic-only mode is intentionally unavailable today. +- When embeddings are disabled, hybrid plain queries fall back to keyword + retrieval. +- When embeddings are disabled, `vec:` and `hyde:` typed clauses on hybrid + queries are expected to warn and degrade to keyword text. +- Query index status and rebuild are implemented; `query index clean` currently + reports a no-op status rather than removing data. +- The UI now exposes query in Workspace Studio even though the early feature + plan described UI as out of scope. + +These are the expected baselines the QA run must validate. + +## Environments + +Use the lightest environment that can prove each surface: + +- E1 local standalone CLI + - Redis 8 + - local config + - at least one seeded workspace +- E2 self-managed control plane + - control plane running against a disposable database + - browser-accessible UI + - ability to hit `/v1/...` routes directly +- E3 MCP clients + - local stdio MCP against a seeded workspace + - hosted/control-plane MCP token if hosted MCP parity is in scope for the run + +## Fixtures + +Seed one workspace with content crafted to expose ranking, scoping, and +typed-clause behavior: + +- `/docs/checkpoints.md` + - checkpoint, savepoint, restore, snapshot language +- `/docs/index.md` + - text beginning with `Index` to test `query index ...` disambiguation +- `/notes/auth.md` + - auth, token, tenant scope language +- `/mount/setup.md` + - NFS, FUSE, mount backend language +- `/archive/checkpoints.md` + - checkpoint language outside `/docs` to verify path scoping +- one binary or non-text file + - validate it does not break indexing or query responses + +## Scenario Matrix + +### QRY-ENV: Environment And Fixture Readiness + +- Confirm Redis Search is available in the chosen Redis 8 environment. +- Confirm the seeded workspace is reachable from CLI, HTTP, MCP, and UI. +- Record workspace id, database id, and seed file inventory in the run log. + +### QRY-CLI-01: Help And Command Discovery + +- Verify `afs query --help` and `afs fs query --help`. +- Verify usage mentions: + - hybrid default + - `--keyword` + - `--semantic` + - typed query clauses + - supported output formats + - index subcommands +- Verify natural queries beginning with `index` are not treated as index + subcommands. + +Expected: + +- Help matches the documented command family. +- No stale `search` or `vsearch` terminology appears. +- `query index` is only entered when `index` is the actual subcommand. + +### QRY-CLI-02: Plain And Typed Query Parsing + +- Run a plain natural-language query. +- Run an explicit `expand:` query. +- Run a typed document with `lex:` + `vec:`. +- Run a typed document with `intent:` + `lex:` + `hyde:`. +- Verify trailing flags after query text still parse correctly. +- Verify `--intent` conflicts with `intent:` typed clauses. +- Verify `--keyword` and `--semantic` reject typed documents. +- Verify `--keyword` and `--semantic` together fail cleanly. +- Verify invalid multi-line plain text and malformed typed docs return useful + parse errors. + +Expected: + +- Parsed behavior matches the typed-document rules from the tests and MCP parser. +- Failures are actionable, not generic flag parser noise. + +### QRY-CLI-03: Result Quality, Path Scoping, And Warnings + +- Run hybrid plain-text queries expected to hit `/docs/checkpoints.md`. +- Run scoped queries with `--path /docs`. +- Verify results do not leak from `/archive` when scope is `/docs`. +- Run typed hybrid queries with `vec:` or `hyde:` while embeddings are off. +- Capture stderr warnings for: + - plain hybrid fallback with embeddings disabled + - typed semantic-clause degradation to keyword text +- Verify `--min-score`, `--limit`, `--all`, `--candidate-limit`, `--full`, and + `--explain`. + +Expected: + +- Relevant files rank first or near first for the seeded dataset. +- Path scoping is enforced. +- Warning semantics differ correctly between plain fallback and typed semantic + clause fallback. +- `--explain` includes backend/fallback details when requested. + +### QRY-CLI-04: Output Formats + +- Verify plain output block formatting and line-number rendering. +- Verify empty-result behavior for: + - plain + - `--md` + - `--files` + - `--paths` + - `--csv` + - `--xml` +- Verify `--files` emits QMD-style `#id,score,afs://...`. +- Verify `--paths` deduplicates file paths and omits snippets. +- Verify markdown, CSV, and XML are structurally valid and consistent with the + same result set. +- Verify overlapping chunks coalesce instead of duplicating adjacent results. + +Expected: + +- Every format matches the CLI contract encoded by the tests. +- Empty outputs are format-appropriate and stable. + +### QRY-CLI-05: Semantic-Only Unavailable Paths + +- With embeddings disabled, run `afs query --semantic ...`. +- Enable embeddings in workspace config, then run `afs query --semantic ...` + again. +- Repeat with `afs fs query --semantic ...`. +- Verify both human-readable and JSON behaviors. + +Expected: + +- With embeddings disabled: the user gets explicit enablement guidance. +- With embeddings enabled: the user gets a clear “not ready yet” or + unavailable response instead of a silent fallback to keyword. +- JSON mode returns `status: "unavailable"` with empty results and warnings. + +### QRY-IDX-01: Query Index Status + +- Run `query index status` before any query. +- Run a first query and re-check status. +- Verify status fields: + - state + - files + - ready + - pending + - stale + - unindexed + - skipped + - errors + - chunks + - embeddings/model/strategy +- Verify path-scoped status checks. + +Expected: + +- First-run indexing/backfill behavior is visible. +- Status messaging changes appropriately for ready/indexing/needs_rebuild/ + unavailable/error. + +### QRY-IDX-02: Query Index Rebuild And Clean + +- Run `query index rebuild` without `--wait`. +- Run `query index rebuild --wait`. +- Run path-scoped rebuild. +- Run `query index rebuild --force`. +- Run `query index clean` in plain and JSON modes. + +Expected: + +- Rebuild returns enqueue/process details consistent with status. +- `--wait` visibly drains pending work. +- `clean` behavior is documented as current no-op behavior if no deletion occurs. +- Any mismatch between help text and actual destructive behavior is recorded. + +### QRY-CONFIG-01: Workspace Query Config + +- Read existing config with `afs ws config list`. +- Toggle `query.embeddings.enabled`. +- Set and unset `query.embeddings.model`. +- Set `query.embeddings.chunkStrategy` to `auto` and `regex`. +- Verify invalid config values fail cleanly. +- Re-run relevant query and index commands after config changes. + +Expected: + +- Config round-trips through CLI and reflected API/UI surfaces. +- Config changes affect warning and unavailable messaging consistently. + +### QRY-API-01: Control-Plane HTTP Contract + +- Exercise: + - `GET /.../query/index/status` + - `POST /.../query/index/rebuild` + - `POST /.../query` +- Cover both top-level workspace routes and database-scoped equivalents if both + are intended to work. +- Verify request/response JSON matches the CLI/MCP contract: + - defaults + - warnings + - explain entries + - unavailable semantic status +- Verify bad methods and malformed JSON return useful errors. + +Expected: + +- HTTP behavior is a faithful transport of the shared control-plane logic. +- Query routes are reachable wherever the UI hooks expect them. + +### QRY-MCP-01: Local MCP `file_query` + +- Call `file_query` with: + - plain query + - typed query document in `query` + - explicit `searches` + - `mode=keyword` + - `mode=semantic` +- Verify parser errors and typed-mode restrictions match CLI behavior. +- Compare results and warnings against equivalent CLI commands. + +Expected: + +- Local MCP and CLI share the same request validation rules. +- Semantic-unavailable behavior is consistent. + +### QRY-MCP-02: Hosted MCP `file_query` + +- If a hosted token is available, repeat the local MCP checks through hosted + MCP. +- Verify workspace scoping from the token. +- Verify no control-plane-only data leaks in workspace-scoped responses. + +Expected: + +- Hosted MCP matches local MCP for the same workspace state. + +### QRY-UI-01: Workspace Studio Search Tab + +- Load the Search tab for a seeded workspace. +- Verify index status card states: + - loading + - ready + - indexing + - needs rebuild + - unavailable + - error +- Run hybrid, keyword, and semantic-only searches from the UI. +- Verify warning pills, explain badges, empty state, score display, and line + range labels. +- Verify index rebuild dialog: + - default path + - path normalization + - force toggle +- Verify semantic settings: + - top-level toggle + - model field + - chunk strategy + - saved notice and error states + +Expected: + +- UI uses the same query contract as CLI/MCP. +- Semantic-only failures are clearly surfaced. +- UI state refreshes after rebuild/config changes. + +### QRY-DOC-01: Contract And Documentation Alignment + +- Compare the shipped behavior with: + - `docs/reference/cli.md` + - `docs/reference/mcp.md` + - `docs/reference/control-plane-api.md` + - `docs/internals/decisions/0001-qmd-inspired-workspace-query.md` +- Record any mismatch between documentation and observed behavior. + +Expected: + +- Docs either match the product or generate concrete follow-up items. + +## Evidence Capture + +For every scenario, record: + +- environment used +- command/request/tool invocation +- response or screenshot +- whether behavior matches current expected contract +- follow-up issue if not + +Store command transcripts and JSON payloads under `.ai/query-qa/` during the +run. Do not commit raw evidence. + +## Results + +- Executed the CLI, index/config, HTTP, MCP, and UI scenarios against a + disposable Redis 8 + self-managed control-plane environment. +- Verified that the shipped keyword and hybrid-fallback paths work across every + exposed surface. +- Verified that semantic-only retrieval remains intentionally unavailable and is + surfaced in each transport. +- Captured evidence and a summarized run log in `.ai/query-qa/run-log.md`. + +Key follow-ups discovered: + +- `docs/reference/control-plane-api.md` does not yet document the shipped query + HTTP routes. +- `query index clean` is described like a cleanup operation but is currently a + no-op implementation. +- Semantic-unavailable warnings differ across CLI, HTTP, MCP, and UI. +- `docs/reference/cli.md` says existing workspaces backfill on first query, but + `query index status` can also warm the index before any search command runs. + +## Decisions / Blockers + +- Treated semantic-only retrieval as intentionally unavailable unless a newer + build changed the contract. +- Treated `query index clean` as behavior-under-test because the current code + advertises cleanup language while returning a no-op status. +- Treated UI query coverage as required because the feature is already exposed + in `workspace-studio/-search-tab.tsx`. + +## Verification + +Research and grounding completed with: + +- `git switch main && git pull --ff-only origin main` +- `go test ./cmd/afs -run Query` +- `go test ./internal/controlplane -run Query` +- `go test ./internal/mcptools -run FileQuery` +- headless Chrome interaction against + `http://127.0.0.1:8091/workspaces/ws_2de43f4165de4ad0?databaseId=local-development&tab=search` +- local stdio MCP against `query-qa` and `query-fresh` +- hosted `/mcp` token-backed tool calls against `query-qa` and `query-fresh` +- design/docs/code review across: + - `plans/file-query.md` + - `docs/internals/decisions/0001-qmd-inspired-workspace-query.md` + - `docs/reference/cli.md` + - `docs/reference/mcp.md` + - `docs/reference/control-plane-api.md` + - `cmd/afs/afs_query_commands*.go` + - `cmd/afs/afs_query_index_commands.go` + - `internal/controlplane/workspace_query.go` + - `internal/mcptools/query.go` + - `ui/src/routes/workspace-studio/-search-tab.tsx` From ab46af877b47260301e0e717fbacea9eccfe12c6 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 7 May 2026 23:18:04 -0700 Subject: [PATCH 2/6] Keep query QA plan local --- plans/query-manual-qa.md | 424 --------------------------------------- 1 file changed, 424 deletions(-) delete mode 100644 plans/query-manual-qa.md diff --git a/plans/query-manual-qa.md b/plans/query-manual-qa.md deleted file mode 100644 index e300d9a..0000000 --- a/plans/query-manual-qa.md +++ /dev/null @@ -1,424 +0,0 @@ -# AFS Query Manual QA - -Status: complete -Owner: Codex -Created: 2026-05-07 -Updated: 2026-05-08 - -## Goal - -Manually QA the new AFS `query` feature across every exposed user surface so we -can answer three questions with evidence: - -1. Does the shipped keyword and hybrid-fallback behavior work end to end? -2. Are intentionally unavailable semantic paths reported clearly and - consistently? -3. Do CLI, control-plane HTTP, MCP, and UI surfaces agree on the same contract, - defaults, warnings, and recovery guidance? - -## Scope - -In scope: - -- Top-level and workspace-scoped CLI query commands: - - `afs query` - - `afs fs query` - - `afs query --keyword` - - `afs query --semantic` - - `afs query index ` -- Query argument parsing and typed query document handling: - - plain text - - `expand:` - - `lex:` - - `vec:` - - `hyde:` - - `intent:` -- Query output formats: - - plain - - `--json` - - `--files` - - `--paths` - - `--md` - - `--csv` - - `--xml` -- Workspace query config under `afs ws config`, especially - `query.embeddings.enabled`, `query.embeddings.model`, and - `query.embeddings.chunkStrategy` -- Query index status and rebuild behavior -- Control-plane HTTP workspace query routes -- Local and hosted MCP `file_query` -- Workspace Studio Search tab behavior in the UI -- Contract/documentation alignment for query-specific docs - -Out of scope: - -- Tuning ranking relevance beyond obvious correctness failures -- Performance benchmarking beyond basic responsiveness notes -- Implementing fixes -- Broader grep/manual-QA coverage unrelated to query behavior - -## Current Observations - -Grounded in `main` at `834f311` plus targeted passing tests: - -- `go test ./cmd/afs -run Query` -- `go test ./internal/controlplane -run Query` -- `go test ./internal/mcptools -run FileQuery` - -Observed implementation shape: - -- Hybrid and keyword requests are live and route through control-plane query - handlers. -- Semantic-only mode is intentionally unavailable today. -- When embeddings are disabled, hybrid plain queries fall back to keyword - retrieval. -- When embeddings are disabled, `vec:` and `hyde:` typed clauses on hybrid - queries are expected to warn and degrade to keyword text. -- Query index status and rebuild are implemented; `query index clean` currently - reports a no-op status rather than removing data. -- The UI now exposes query in Workspace Studio even though the early feature - plan described UI as out of scope. - -These are the expected baselines the QA run must validate. - -## Environments - -Use the lightest environment that can prove each surface: - -- E1 local standalone CLI - - Redis 8 - - local config - - at least one seeded workspace -- E2 self-managed control plane - - control plane running against a disposable database - - browser-accessible UI - - ability to hit `/v1/...` routes directly -- E3 MCP clients - - local stdio MCP against a seeded workspace - - hosted/control-plane MCP token if hosted MCP parity is in scope for the run - -## Fixtures - -Seed one workspace with content crafted to expose ranking, scoping, and -typed-clause behavior: - -- `/docs/checkpoints.md` - - checkpoint, savepoint, restore, snapshot language -- `/docs/index.md` - - text beginning with `Index` to test `query index ...` disambiguation -- `/notes/auth.md` - - auth, token, tenant scope language -- `/mount/setup.md` - - NFS, FUSE, mount backend language -- `/archive/checkpoints.md` - - checkpoint language outside `/docs` to verify path scoping -- one binary or non-text file - - validate it does not break indexing or query responses - -## Scenario Matrix - -### QRY-ENV: Environment And Fixture Readiness - -- Confirm Redis Search is available in the chosen Redis 8 environment. -- Confirm the seeded workspace is reachable from CLI, HTTP, MCP, and UI. -- Record workspace id, database id, and seed file inventory in the run log. - -### QRY-CLI-01: Help And Command Discovery - -- Verify `afs query --help` and `afs fs query --help`. -- Verify usage mentions: - - hybrid default - - `--keyword` - - `--semantic` - - typed query clauses - - supported output formats - - index subcommands -- Verify natural queries beginning with `index` are not treated as index - subcommands. - -Expected: - -- Help matches the documented command family. -- No stale `search` or `vsearch` terminology appears. -- `query index` is only entered when `index` is the actual subcommand. - -### QRY-CLI-02: Plain And Typed Query Parsing - -- Run a plain natural-language query. -- Run an explicit `expand:` query. -- Run a typed document with `lex:` + `vec:`. -- Run a typed document with `intent:` + `lex:` + `hyde:`. -- Verify trailing flags after query text still parse correctly. -- Verify `--intent` conflicts with `intent:` typed clauses. -- Verify `--keyword` and `--semantic` reject typed documents. -- Verify `--keyword` and `--semantic` together fail cleanly. -- Verify invalid multi-line plain text and malformed typed docs return useful - parse errors. - -Expected: - -- Parsed behavior matches the typed-document rules from the tests and MCP parser. -- Failures are actionable, not generic flag parser noise. - -### QRY-CLI-03: Result Quality, Path Scoping, And Warnings - -- Run hybrid plain-text queries expected to hit `/docs/checkpoints.md`. -- Run scoped queries with `--path /docs`. -- Verify results do not leak from `/archive` when scope is `/docs`. -- Run typed hybrid queries with `vec:` or `hyde:` while embeddings are off. -- Capture stderr warnings for: - - plain hybrid fallback with embeddings disabled - - typed semantic-clause degradation to keyword text -- Verify `--min-score`, `--limit`, `--all`, `--candidate-limit`, `--full`, and - `--explain`. - -Expected: - -- Relevant files rank first or near first for the seeded dataset. -- Path scoping is enforced. -- Warning semantics differ correctly between plain fallback and typed semantic - clause fallback. -- `--explain` includes backend/fallback details when requested. - -### QRY-CLI-04: Output Formats - -- Verify plain output block formatting and line-number rendering. -- Verify empty-result behavior for: - - plain - - `--md` - - `--files` - - `--paths` - - `--csv` - - `--xml` -- Verify `--files` emits QMD-style `#id,score,afs://...`. -- Verify `--paths` deduplicates file paths and omits snippets. -- Verify markdown, CSV, and XML are structurally valid and consistent with the - same result set. -- Verify overlapping chunks coalesce instead of duplicating adjacent results. - -Expected: - -- Every format matches the CLI contract encoded by the tests. -- Empty outputs are format-appropriate and stable. - -### QRY-CLI-05: Semantic-Only Unavailable Paths - -- With embeddings disabled, run `afs query --semantic ...`. -- Enable embeddings in workspace config, then run `afs query --semantic ...` - again. -- Repeat with `afs fs query --semantic ...`. -- Verify both human-readable and JSON behaviors. - -Expected: - -- With embeddings disabled: the user gets explicit enablement guidance. -- With embeddings enabled: the user gets a clear “not ready yet” or - unavailable response instead of a silent fallback to keyword. -- JSON mode returns `status: "unavailable"` with empty results and warnings. - -### QRY-IDX-01: Query Index Status - -- Run `query index status` before any query. -- Run a first query and re-check status. -- Verify status fields: - - state - - files - - ready - - pending - - stale - - unindexed - - skipped - - errors - - chunks - - embeddings/model/strategy -- Verify path-scoped status checks. - -Expected: - -- First-run indexing/backfill behavior is visible. -- Status messaging changes appropriately for ready/indexing/needs_rebuild/ - unavailable/error. - -### QRY-IDX-02: Query Index Rebuild And Clean - -- Run `query index rebuild` without `--wait`. -- Run `query index rebuild --wait`. -- Run path-scoped rebuild. -- Run `query index rebuild --force`. -- Run `query index clean` in plain and JSON modes. - -Expected: - -- Rebuild returns enqueue/process details consistent with status. -- `--wait` visibly drains pending work. -- `clean` behavior is documented as current no-op behavior if no deletion occurs. -- Any mismatch between help text and actual destructive behavior is recorded. - -### QRY-CONFIG-01: Workspace Query Config - -- Read existing config with `afs ws config list`. -- Toggle `query.embeddings.enabled`. -- Set and unset `query.embeddings.model`. -- Set `query.embeddings.chunkStrategy` to `auto` and `regex`. -- Verify invalid config values fail cleanly. -- Re-run relevant query and index commands after config changes. - -Expected: - -- Config round-trips through CLI and reflected API/UI surfaces. -- Config changes affect warning and unavailable messaging consistently. - -### QRY-API-01: Control-Plane HTTP Contract - -- Exercise: - - `GET /.../query/index/status` - - `POST /.../query/index/rebuild` - - `POST /.../query` -- Cover both top-level workspace routes and database-scoped equivalents if both - are intended to work. -- Verify request/response JSON matches the CLI/MCP contract: - - defaults - - warnings - - explain entries - - unavailable semantic status -- Verify bad methods and malformed JSON return useful errors. - -Expected: - -- HTTP behavior is a faithful transport of the shared control-plane logic. -- Query routes are reachable wherever the UI hooks expect them. - -### QRY-MCP-01: Local MCP `file_query` - -- Call `file_query` with: - - plain query - - typed query document in `query` - - explicit `searches` - - `mode=keyword` - - `mode=semantic` -- Verify parser errors and typed-mode restrictions match CLI behavior. -- Compare results and warnings against equivalent CLI commands. - -Expected: - -- Local MCP and CLI share the same request validation rules. -- Semantic-unavailable behavior is consistent. - -### QRY-MCP-02: Hosted MCP `file_query` - -- If a hosted token is available, repeat the local MCP checks through hosted - MCP. -- Verify workspace scoping from the token. -- Verify no control-plane-only data leaks in workspace-scoped responses. - -Expected: - -- Hosted MCP matches local MCP for the same workspace state. - -### QRY-UI-01: Workspace Studio Search Tab - -- Load the Search tab for a seeded workspace. -- Verify index status card states: - - loading - - ready - - indexing - - needs rebuild - - unavailable - - error -- Run hybrid, keyword, and semantic-only searches from the UI. -- Verify warning pills, explain badges, empty state, score display, and line - range labels. -- Verify index rebuild dialog: - - default path - - path normalization - - force toggle -- Verify semantic settings: - - top-level toggle - - model field - - chunk strategy - - saved notice and error states - -Expected: - -- UI uses the same query contract as CLI/MCP. -- Semantic-only failures are clearly surfaced. -- UI state refreshes after rebuild/config changes. - -### QRY-DOC-01: Contract And Documentation Alignment - -- Compare the shipped behavior with: - - `docs/reference/cli.md` - - `docs/reference/mcp.md` - - `docs/reference/control-plane-api.md` - - `docs/internals/decisions/0001-qmd-inspired-workspace-query.md` -- Record any mismatch between documentation and observed behavior. - -Expected: - -- Docs either match the product or generate concrete follow-up items. - -## Evidence Capture - -For every scenario, record: - -- environment used -- command/request/tool invocation -- response or screenshot -- whether behavior matches current expected contract -- follow-up issue if not - -Store command transcripts and JSON payloads under `.ai/query-qa/` during the -run. Do not commit raw evidence. - -## Results - -- Executed the CLI, index/config, HTTP, MCP, and UI scenarios against a - disposable Redis 8 + self-managed control-plane environment. -- Verified that the shipped keyword and hybrid-fallback paths work across every - exposed surface. -- Verified that semantic-only retrieval remains intentionally unavailable and is - surfaced in each transport. -- Captured evidence and a summarized run log in `.ai/query-qa/run-log.md`. - -Key follow-ups discovered: - -- `docs/reference/control-plane-api.md` does not yet document the shipped query - HTTP routes. -- `query index clean` is described like a cleanup operation but is currently a - no-op implementation. -- Semantic-unavailable warnings differ across CLI, HTTP, MCP, and UI. -- `docs/reference/cli.md` says existing workspaces backfill on first query, but - `query index status` can also warm the index before any search command runs. - -## Decisions / Blockers - -- Treated semantic-only retrieval as intentionally unavailable unless a newer - build changed the contract. -- Treated `query index clean` as behavior-under-test because the current code - advertises cleanup language while returning a no-op status. -- Treated UI query coverage as required because the feature is already exposed - in `workspace-studio/-search-tab.tsx`. - -## Verification - -Research and grounding completed with: - -- `git switch main && git pull --ff-only origin main` -- `go test ./cmd/afs -run Query` -- `go test ./internal/controlplane -run Query` -- `go test ./internal/mcptools -run FileQuery` -- headless Chrome interaction against - `http://127.0.0.1:8091/workspaces/ws_2de43f4165de4ad0?databaseId=local-development&tab=search` -- local stdio MCP against `query-qa` and `query-fresh` -- hosted `/mcp` token-backed tool calls against `query-qa` and `query-fresh` -- design/docs/code review across: - - `plans/file-query.md` - - `docs/internals/decisions/0001-qmd-inspired-workspace-query.md` - - `docs/reference/cli.md` - - `docs/reference/mcp.md` - - `docs/reference/control-plane-api.md` - - `cmd/afs/afs_query_commands*.go` - - `cmd/afs/afs_query_index_commands.go` - - `internal/controlplane/workspace_query.go` - - `internal/mcptools/query.go` - - `ui/src/routes/workspace-studio/-search-tab.tsx` From e7885dd04046351cb98e64fcebbe50b67d72ffc5 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 8 May 2026 01:06:00 -0700 Subject: [PATCH 3/6] Fix query QA regressions --- cmd/afs/afs_mcp_test.go | 28 ++++++ cmd/afs/config_commands_test.go | 104 ++++++++++++++++++++++ cmd/afs/controlplane_http_client.go | 3 +- cmd/afs/mount_commands.go | 7 +- cmd/afs/reset_command.go | 130 +++++++++++++++++++++++++--- cmd/afs/sync_daemon.go | 2 +- cmd/afs/sync_integration_test.go | 45 ++++++++++ cmd/afs/sync_lifecycle.go | 40 +-------- cmd/afs/ui_test.go | 12 +-- docs/guides/agent-filesystem.md | 5 ++ docs/reference/cli.md | 9 +- internal/controlplane/diff.go | 27 ++++++ internal/controlplane/diff_test.go | 24 +++++ internal/controlplane/http.go | 19 ++-- internal/controlplane/http_test.go | 73 ++++++++++++++++ internal/controlplane/mcp_hosted.go | 2 +- internal/controlplane/service.go | 4 + sdk/python/tests/test_client.py | 10 ++- sdk/typescript/test/sdk.test.mjs | 34 ++++++++ 19 files changed, 504 insertions(+), 74 deletions(-) diff --git a/cmd/afs/afs_mcp_test.go b/cmd/afs/afs_mcp_test.go index 77abbeb..ff9fdf1 100644 --- a/cmd/afs/afs_mcp_test.go +++ b/cmd/afs/afs_mcp_test.go @@ -257,6 +257,34 @@ func TestAFSMCPCheckpointCreateAllowsUnchangedWorkspace(t *testing.T) { } } +func TestAFSMCPCheckpointCreateGeneratesCheckpointNameWhenOmitted(t *testing.T) { + t.Helper() + + server, closeFn := setupAFSMCPTestServer(t) + defer closeFn() + server.profile = controlplane.MCPProfileWorkspaceRWCheckpoint + + checkpointResult := server.callTool(context.Background(), "checkpoint_create", map[string]any{}) + if checkpointResult.IsError { + t.Fatalf("checkpoint_create without name returned error result: %+v", checkpointResult) + } + + var checkpointPayload map[string]any + if err := decodeStructuredContent(checkpointResult.StructuredContent, &checkpointPayload); err != nil { + t.Fatalf("decodeStructuredContent(checkpoint generated) returned error: %v", err) + } + if created, _ := checkpointPayload["created"].(bool); !created { + t.Fatalf("checkpoint_create created = %#v, want true", checkpointPayload["created"]) + } + checkpoint, _ := checkpointPayload["checkpoint"].(string) + if !strings.HasPrefix(checkpoint, "save-") { + t.Fatalf("checkpoint_create checkpoint = %#v, want save-*", checkpointPayload["checkpoint"]) + } + if _, err := server.store.getSavepointMeta(context.Background(), "repo", checkpoint); err != nil { + t.Fatalf("getSavepointMeta(%s) returned error: %v", checkpoint, err) + } +} + func TestAFSMCPFileWriteDoesNotRematerializeLocalWorkspaceCache(t *testing.T) { t.Helper() diff --git a/cmd/afs/config_commands_test.go b/cmd/afs/config_commands_test.go index 83f4bcb..cf85461 100644 --- a/cmd/afs/config_commands_test.go +++ b/cmd/afs/config_commands_test.go @@ -304,6 +304,110 @@ func TestCmdConfigResetRemovesConfigAndState(t *testing.T) { } } +func TestCmdConfigResetWithAlternateConfigPreservesDefaultState(t *testing.T) { + t.Helper() + + withTempHome(t) + configFile := filepath.Join(t.TempDir(), "afs.config.json") + origConfigPath := cfgPathOverride + cfgPathOverride = configFile + t.Cleanup(func() { + cfgPathOverride = origConfigPath + }) + + if err := os.WriteFile(configFile, []byte(`{"mode":"sync"}`), 0o600); err != nil { + t.Fatalf("WriteFile(config) returned error: %v", err) + } + + rawDefaultState, err := json.MarshalIndent(state{ + Mode: modeSync, + CurrentWorkspace: "default-workspace", + LocalPath: t.TempDir(), + }, "", " ") + if err != nil { + t.Fatalf("json.MarshalIndent(default state) returned error: %v", err) + } + if err := os.MkdirAll(filepath.Dir(defaultStatePath()), 0o700); err != nil { + t.Fatalf("MkdirAll(default state dir) returned error: %v", err) + } + if err := os.WriteFile(defaultStatePath(), rawDefaultState, 0o600); err != nil { + t.Fatalf("WriteFile(defaultStatePath) returned error: %v", err) + } + + out, err := captureStdout(t, func() error { + return cmdConfig([]string{"config", "reset"}) + }) + if err != nil { + t.Fatalf("cmdConfig(reset) returned error: %v", err) + } + if strings.Contains(out, "Unmounted workspace default-workspace") { + t.Fatalf("cmdConfig(reset) output = %q, should not unmount default runtime for an alternate config", out) + } + if _, err := os.Stat(defaultStatePath()); err != nil { + t.Fatalf("defaultStatePath removed by alternate-config reset: %v", err) + } +} + +func TestCmdConfigResetWithAlternateConfigRemovesOnlyScopedState(t *testing.T) { + t.Helper() + + withTempHome(t) + configFile := filepath.Join(t.TempDir(), "afs.config.json") + origConfigPath := cfgPathOverride + cfgPathOverride = configFile + t.Cleanup(func() { + cfgPathOverride = origConfigPath + }) + + if err := os.WriteFile(configFile, []byte(`{"mode":"sync"}`), 0o600); err != nil { + t.Fatalf("WriteFile(config) returned error: %v", err) + } + + scopedStatePath := statePath() + rawScopedState, err := json.MarshalIndent(state{ + Mode: modeSync, + CurrentWorkspace: "scoped-workspace", + LocalPath: t.TempDir(), + }, "", " ") + if err != nil { + t.Fatalf("json.MarshalIndent(scoped state) returned error: %v", err) + } + if err := os.MkdirAll(filepath.Dir(scopedStatePath), 0o700); err != nil { + t.Fatalf("MkdirAll(scoped state dir) returned error: %v", err) + } + if err := os.WriteFile(scopedStatePath, rawScopedState, 0o600); err != nil { + t.Fatalf("WriteFile(scopedStatePath) returned error: %v", err) + } + + rawDefaultState, err := json.MarshalIndent(state{ + Mode: modeSync, + CurrentWorkspace: "default-workspace", + LocalPath: t.TempDir(), + }, "", " ") + if err != nil { + t.Fatalf("json.MarshalIndent(default state) returned error: %v", err) + } + if err := os.MkdirAll(filepath.Dir(defaultStatePath()), 0o700); err != nil { + t.Fatalf("MkdirAll(default state dir) returned error: %v", err) + } + if err := os.WriteFile(defaultStatePath(), rawDefaultState, 0o600); err != nil { + t.Fatalf("WriteFile(defaultStatePath) returned error: %v", err) + } + + if _, err := captureStdout(t, func() error { + return cmdConfig([]string{"config", "reset"}) + }); err != nil { + t.Fatalf("cmdConfig(reset) returned error: %v", err) + } + + if _, err := os.Stat(scopedStatePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("scoped state still exists after reset: %v", err) + } + if _, err := os.Stat(defaultStatePath()); err != nil { + t.Fatalf("default state removed by scoped reset: %v", err) + } +} + func TestCmdConfigSetAgentNamePersistsFriendlyAgentName(t *testing.T) { t.Helper() diff --git a/cmd/afs/controlplane_http_client.go b/cmd/afs/controlplane_http_client.go index a697e63..4931260 100644 --- a/cmd/afs/controlplane_http_client.go +++ b/cmd/afs/controlplane_http_client.go @@ -106,7 +106,8 @@ type httpCLIAccessTokenResponse struct { } type httpSaveCheckpointResponse struct { - Saved bool `json:"saved"` + Saved bool `json:"saved"` + CheckpointID string `json:"checkpoint_id,omitempty"` } func newHTTPControlPlaneClient(ctx context.Context, cfg config) (*httpControlPlaneClient, string, error) { diff --git a/cmd/afs/mount_commands.go b/cmd/afs/mount_commands.go index 30a4acb..aca05e4 100644 --- a/cmd/afs/mount_commands.go +++ b/cmd/afs/mount_commands.go @@ -1082,9 +1082,10 @@ With no volume, lists volumes and prompts for a selection. With no directory, prompts for a local folder. Use --readonly to make this mount refuse local writes. Use --session to name this mount session separately from agent.name. -When mounting to a populated local folder, AFS shows the safe reconciliation -plan and asks before uploading or downloading files. Use --yes to accept a -safe plan non-interactively; conflicts still block mount. +When mounting to a populated local folder, AFS shows a reconciliation plan. +Disjoint local-only and remote-only files are presented as a safe import plan +that you can confirm or accept with --yes. Same-path conflicts still block +mount until you move one side aside. Live Mount mode requires an empty local folder unless --yes is passed, because the NFS/FUSE mount hides any local files that already exist there. The directory is preserved on unmount unless --delete is used. diff --git a/cmd/afs/reset_command.go b/cmd/afs/reset_command.go index 013eede..f341b1b 100644 --- a/cmd/afs/reset_command.go +++ b/cmd/afs/reset_command.go @@ -2,16 +2,19 @@ package main import ( "errors" + "fmt" "os" "path/filepath" + "strings" + "syscall" + "time" ) func cmdReset() error { - if st, err := loadState(); err == nil { - if st.MountPID > 0 || st.SyncPID > 0 { - if err := unmountAllActive(false); err != nil { - return err - } + targetStatePath := statePath() + if st, err := loadStateFromPath(targetStatePath); err == nil { + if err := stopRuntimeForReset(st, targetStatePath); err != nil { + return err } } else if !errors.Is(err, os.ErrNotExist) { return err @@ -24,13 +27,10 @@ func cmdReset() error { return err } - removedState := false - if err := os.RemoveAll(stateDir()); err != nil { + removedState, err := removeResetScopedState(targetStatePath) + if err != nil { return err } - if _, err := os.Stat(stateDir()); errors.Is(err, os.ErrNotExist) { - removedState = true - } rows := []outputRow{ {Label: "config", Value: ternaryString(removedConfig, compactDisplayPath(configPath()), "already clear")}, @@ -40,3 +40,113 @@ func cmdReset() error { printSection(markerSuccess+" "+clr(ansiBold, "local state reset"), rows) return nil } + +func stopRuntimeForReset(st state, targetStatePath string) error { + reg, err := loadMountRegistry() + if err != nil { + return err + } + if localPath := st.LocalPath; localPath != "" { + if rec, ok := removeMountByPath(®, localPath); ok { + return unmountMountRecord(reg, rec, false) + } + } + if handled, err := stopSyncServicesIfActiveAtPath(st, targetStatePath, false); handled || err != nil { + return err + } + return nil +} + +func stopSyncServicesIfActiveAtPath(st state, targetStatePath string, deleteLocal bool) (bool, error) { + if strings.TrimSpace(st.Mode) != modeSync { + return false, nil + } + + fmt.Println() + + if st.SyncPID > 0 && processAlive(st.SyncPID) { + s := startStep("Stopping sync daemon") + if err := terminatePID(st.SyncPID, 5*time.Second); err != nil { + s.fail(err.Error()) + } else { + s.succeed(fmt.Sprintf("pid %d", st.SyncPID)) + } + } + if localPath := strings.TrimSpace(st.LocalPath); localPath != "" && deleteLocal { + if err := os.RemoveAll(localPath); err != nil { + fmt.Printf(" %s local sync folder preserved at %s (%v)\n", clr(ansiYellow, "!"), localPath, err) + } + } + + if deleteLocal { + workspace := strings.TrimSpace(st.CurrentWorkspace) + _ = removeSyncState(workspace) + } + closeManagedWorkspaceSession(configFromState(st), strings.TrimSpace(st.CurrentWorkspace), strings.TrimSpace(st.SessionID)) + + if err := os.Remove(targetStatePath); err != nil && !errors.Is(err, os.ErrNotExist) { + return true, err + } + local := "preserved" + if deleteLocal { + local = "deleted" + } + fmt.Printf("Unmounted workspace %s\n", currentWorkspaceLabel(st.CurrentWorkspace)) + fmt.Printf("path %s\n", homeRelativeDisplayPath(st.LocalPath)) + fmt.Printf("local %s\n", local) + return true, nil +} + +func removeResetScopedState(targetStatePath string) (bool, error) { + removed := false + if err := os.Remove(targetStatePath); err == nil { + removed = true + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + + reg, err := loadMountRegistry() + if err != nil { + return removed, err + } + if len(reg.Mounts) == 0 { + if err := os.Remove(mountRegistryPath()); err == nil { + removed = true + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + if err := os.Remove(legacyMountRegistryPath()); err == nil { + removed = true + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + } + + for _, dir := range []string{filepath.Dir(targetStatePath), syncStateDir(), stateDir()} { + if err := removeDirIfEmpty(dir); err != nil { + return false, err + } + } + if _, err := os.Stat(stateDir()); errors.Is(err, os.ErrNotExist) { + removed = true + } + return removed, nil +} + +func removeDirIfEmpty(path string) error { + if strings.TrimSpace(path) == "" { + return nil + } + err := os.Remove(path) + if err == nil || errors.Is(err, os.ErrNotExist) { + return nil + } + if errors.Is(err, os.ErrExist) || errors.Is(err, syscall.ENOTEMPTY) { + return nil + } + var pathErr *os.PathError + if errors.As(err, &pathErr) && pathErr.Err == syscall.ENOTEMPTY { + return nil + } + return err +} diff --git a/cmd/afs/sync_daemon.go b/cmd/afs/sync_daemon.go index 63d69eb..bef4767 100644 --- a/cmd/afs/sync_daemon.go +++ b/cmd/afs/sync_daemon.go @@ -375,7 +375,7 @@ func (d *syncDaemon) validateInitialSyncSafety(ctx context.Context) error { } return fmt.Errorf( - "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.", + "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.", d.cfg.Workspace, d.cfg.LocalRoot, ) diff --git a/cmd/afs/sync_integration_test.go b/cmd/afs/sync_integration_test.go index 87bbaf9..1aa3727 100644 --- a/cmd/afs/sync_integration_test.go +++ b/cmd/afs/sync_integration_test.go @@ -171,6 +171,51 @@ func TestMountReconcileAllowsApprovedSafeUnion(t *testing.T) { } } +func TestMountReconcileReportsEmptyRemoteImport(t *testing.T) { + t.Helper() + env := newSyncTestEnv(t) + env.writeLocalFile(t, "local-only.txt", "hello") + + daemonClient := client.New(env.rdb, env.mountKey) + cfg := syncDaemonConfig{ + Workspace: env.workspace, + LocalRoot: env.localRoot, + FS: daemonClient, + Store: env.store, + MaxFileBytes: 16 * 1024 * 1024, + WatcherDebounce: 20 * time.Millisecond, + } + d, err := newSyncDaemon(cfg) + if err != nil { + t.Fatalf("newSyncDaemon: %v", err) + } + plan, err := buildMountReconcilePlan(context.Background(), d) + if err != nil { + t.Fatalf("buildMountReconcilePlan: %v", err) + } + if plan.ImportCount != 1 || plan.DownloadCount != 0 || plan.ConflictCount != 0 { + t.Fatalf("plan counts = import:%d download:%d conflict:%d, want 1/0/0", plan.ImportCount, plan.DownloadCount, plan.ConflictCount) + } + if !plan.requiresConfirmation() { + t.Fatal("requiresConfirmation() = false, want true for empty-remote local import") + } + requireMountOp(t, plan, "I", "local-only.txt") + + approveMountReconcilePlan(d, plan) + if err := d.Start(context.Background()); err != nil { + t.Fatalf("Start() after approved empty-remote import: %v", err) + } + env.daemon = d + defer env.stopDaemon() + + assertEventually(t, 3*time.Second, "local-only.txt to upload", func() bool { + return env.remoteExists(t, "local-only.txt") + }) + if got := env.readRemoteFile(t, "local-only.txt"); got != "hello" { + t.Fatalf("remote local-only.txt = %q, want hello", got) + } +} + func TestMountReconcileReportsOfflineLocalCreate(t *testing.T) { t.Helper() env := newSyncTestEnv(t) diff --git a/cmd/afs/sync_lifecycle.go b/cmd/afs/sync_lifecycle.go index d22e954..6ea61ac 100644 --- a/cmd/afs/sync_lifecycle.go +++ b/cmd/afs/sync_lifecycle.go @@ -658,43 +658,5 @@ func printSyncReadyBox(cfg config, workspace, localRoot string) { // stopSyncServicesIfActive performs unmount cleanup when the running daemon was // started in sync mode. func stopSyncServicesIfActive(st state, deleteLocal bool) (bool, error) { - if strings.TrimSpace(st.Mode) != modeSync { - return false, nil - } - - fmt.Println() - - if st.SyncPID > 0 && processAlive(st.SyncPID) { - s := startStep("Stopping sync daemon") - if err := terminatePID(st.SyncPID, 5*time.Second); err != nil { - s.fail(err.Error()) - } else { - s.succeed(fmt.Sprintf("pid %d", st.SyncPID)) - } - } - if localPath := strings.TrimSpace(st.LocalPath); localPath != "" && deleteLocal { - if err := os.RemoveAll(localPath); err != nil { - fmt.Printf(" %s local sync folder preserved at %s (%v)\n", clr(ansiYellow, "!"), localPath, err) - } - } - - if deleteLocal { - // Clean up sync state only when the user explicitly deletes the local - // copy; otherwise it remains as the baseline for a later re-mount. - workspace := strings.TrimSpace(st.CurrentWorkspace) - _ = removeSyncState(workspace) - } - closeManagedWorkspaceSession(configFromState(st), strings.TrimSpace(st.CurrentWorkspace), strings.TrimSpace(st.SessionID)) - - if err := os.Remove(statePath()); err != nil && !errors.Is(err, os.ErrNotExist) { - return true, err - } - local := "preserved" - if deleteLocal { - local = "deleted" - } - fmt.Printf("Unmounted workspace %s\n", currentWorkspaceLabel(st.CurrentWorkspace)) - fmt.Printf("path %s\n", homeRelativeDisplayPath(st.LocalPath)) - fmt.Printf("local %s\n", local) - return true, nil + return stopSyncServicesIfActiveAtPath(st, statePath(), deleteLocal) } diff --git a/cmd/afs/ui_test.go b/cmd/afs/ui_test.go index 61b41f6..2f668a2 100644 --- a/cmd/afs/ui_test.go +++ b/cmd/afs/ui_test.go @@ -57,17 +57,9 @@ func TestMarkerSuccessConstant(t *testing.T) { func TestFormatCLIErrorUsesPlainSectionFormat(t *testing.T) { t.Helper() - got := formatCLIError(errors.New(`mount blocked for workspace "smoke": local path "/Users/rowantrollope/afs" is already populated and the remote workspace is not empty -Use an empty directory, import the local directory into a new workspace, or move conflicting files aside first`)) + got := formatCLIError(errors.New("mount blocked for workspace \"smoke\": local path \"/Users/rowantrollope/afs\" 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.")) - want := ` -Error - -Mount blocked for workspace "smoke": local path "/Users/rowantrollope/afs" is already populated and the remote workspace is not empty. - -Use an empty directory, import the local directory into a new workspace, or move conflicting files aside first. - -` + want := "\nError\n\nMount blocked for workspace \"smoke\": local path \"/Users/rowantrollope/afs\" is already populated and the remote workspace is not empty.\n\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.\n\n" if got != want { t.Fatalf("formatCLIError() = %q, want %q", got, want) } diff --git a/docs/guides/agent-filesystem.md b/docs/guides/agent-filesystem.md index 9a3927c..788ec31 100644 --- a/docs/guides/agent-filesystem.md +++ b/docs/guides/agent-filesystem.md @@ -273,6 +273,11 @@ afs ws unmount my-project If you run `afs ws mount` without a workspace, AFS lists available workspaces and prompts you to choose one. +When mounting into a populated local folder, AFS shows a reconciliation plan +before it changes anything. Local-only files can be imported into an empty or +otherwise disjoint workspace after confirmation, while same-path mismatches are +treated as real conflicts and block the mount until you move one side aside. + ## Deployment Modes | Mode | Use it when | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1557e71..98725f4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -170,7 +170,7 @@ where the workspace argument is optional. ### `afs ws mount` ```bash -afs ws mount [--dry-run] [--verbose] [--readonly] [--session ] [ ] +afs ws mount [--dry-run] [--yes] [--verbose] [--readonly] [--session ] [ ] ``` Mounts a durable workspace to a local directory using sync mode. The @@ -183,9 +183,12 @@ unambiguous. Mount safety rules: - Empty local directory + populated workspace: downloads workspace files. -- Populated local directory + empty workspace: uploads local files. +- Populated local directory + empty workspace: shows a safe import plan and, + after confirmation or `--yes`, uploads local files. - Populated local directory + populated workspace with no prior sync baseline: - mount is blocked so files are not overwritten silently. + disjoint local-only and remote-only files are shown as a reconciliation plan + that you can confirm; overlapping paths with mismatched content are treated + as conflicts and block the mount. - Existing sync baseline: AFS reconciles from that baseline. Examples: diff --git a/internal/controlplane/diff.go b/internal/controlplane/diff.go index fc52021..b7e9b26 100644 --- a/internal/controlplane/diff.go +++ b/internal/controlplane/diff.go @@ -3,6 +3,8 @@ package controlplane import ( "bytes" "context" + "mime" + "path" "sort" "strconv" "strings" @@ -411,6 +413,9 @@ func (s *Service) textDiffForEntries(ctx context.Context, storageID, p string, o if hasNew && newEntry.Type != "file" && newEntry.Type != "symlink" { return nil } + if looksBinaryPath(p) && ((hadOld && oldEntry.Type == "file") || (hasNew && newEntry.Type == "file")) { + return skippedTextDiff("binary") + } if hadOld && oldEntry.Type == "file" && oldEntry.Size > diffTextMaxBytes { return skippedTextDiff("too_large") } @@ -479,6 +484,28 @@ func skippedTextDiff(reason string) *TextDiff { } } +func looksBinaryPath(p string) bool { + ext := strings.ToLower(path.Ext(strings.TrimSpace(p))) + if ext == "" { + return false + } + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(mime.TypeByExtension(ext), ";")[0])) + if mediaType == "" { + return false + } + if strings.HasPrefix(mediaType, "text/") { + return false + } + switch mediaType { + case "application/json", "application/xml", "application/yaml", "application/x-yaml", "application/javascript", "application/x-javascript", "image/svg+xml": + return false + } + if strings.HasSuffix(mediaType, "+json") || strings.HasSuffix(mediaType, "+xml") { + return false + } + return true +} + func splitDiffLines(text string) []string { if text == "" { return nil diff --git a/internal/controlplane/diff_test.go b/internal/controlplane/diff_test.go index d0835cd..b5d383e 100644 --- a/internal/controlplane/diff_test.go +++ b/internal/controlplane/diff_test.go @@ -1,6 +1,7 @@ package controlplane import ( + "context" "encoding/base64" "testing" ) @@ -61,3 +62,26 @@ func TestSummarizeDiffEntriesCountsBytes(t *testing.T) { t.Fatalf("summary bytes = +%d/-%d, want +10/-10", summary.BytesAdded, summary.BytesRemoved) } } + +func TestTextDiffForEntriesSkipsLikelyBinaryPaths(t *testing.T) { + t.Helper() + + service := &Service{} + entry := ManifestEntry{ + Type: "file", + Mode: 0o644, + Size: int64(len("not-a-real-png")), + Inline: base64.StdEncoding.EncodeToString([]byte("not-a-real-png")), + } + + diff := service.textDiffForEntries(context.Background(), "repo", "/assets/logo.png", entry, true, nil, entry, true, nil) + if diff == nil { + t.Fatal("textDiffForEntries() = nil, want skipped binary diff") + } + if diff.Available { + t.Fatalf("textDiffForEntries().Available = true, want false: %#v", diff) + } + if diff.SkippedReason != "binary" { + t.Fatalf("textDiffForEntries().SkippedReason = %q, want %q", diff.SkippedReason, "binary") + } +} diff --git a/internal/controlplane/http.go b/internal/controlplane/http.go index 61da84d..9b45e11 100644 --- a/internal/controlplane/http.go +++ b/internal/controlplane/http.go @@ -21,7 +21,8 @@ import ( ) type saveCheckpointHTTPResponse struct { - Saved bool `json:"saved"` + Saved bool `json:"saved"` + CheckpointID string `json:"checkpoint_id,omitempty"` } type forkWorkspaceRequest struct { @@ -1474,7 +1475,11 @@ func handleWorkspaceRoute( writeError(w, fmt.Errorf("invalid request body: %w", err)) return } - saved, err := manager.SaveCheckpointFromLiveWithOptions(r.Context(), databaseID, workspace, input.CheckpointID, SaveCheckpointFromLiveOptions{ + checkpointID := strings.TrimSpace(input.CheckpointID) + if checkpointID == "" { + checkpointID = generatedSavepointName() + } + saved, err := manager.SaveCheckpointFromLiveWithOptions(r.Context(), databaseID, workspace, checkpointID, SaveCheckpointFromLiveOptions{ Description: input.Description, Kind: input.Kind, Source: input.Source, @@ -1485,7 +1490,7 @@ func handleWorkspaceRoute( writeError(w, err) return } - writeJSON(w, http.StatusCreated, saveCheckpointHTTPResponse{Saved: saved}) + writeJSON(w, http.StatusCreated, saveCheckpointHTTPResponse{Saved: saved, CheckpointID: checkpointID}) case strings.HasSuffix(workspacePath, ":fork"): workspace := strings.TrimSuffix(workspacePath, ":fork") if r.Method != http.MethodPost { @@ -2085,7 +2090,11 @@ func handleResolvedWorkspaceRoute( writeError(w, fmt.Errorf("invalid request body: %w", err)) return } - saved, err := manager.SaveResolvedCheckpointFromLiveWithOptions(r.Context(), workspace, input.CheckpointID, SaveCheckpointFromLiveOptions{ + checkpointID := strings.TrimSpace(input.CheckpointID) + if checkpointID == "" { + checkpointID = generatedSavepointName() + } + saved, err := manager.SaveResolvedCheckpointFromLiveWithOptions(r.Context(), workspace, checkpointID, SaveCheckpointFromLiveOptions{ Description: input.Description, Kind: input.Kind, Source: input.Source, @@ -2096,7 +2105,7 @@ func handleResolvedWorkspaceRoute( writeError(w, err) return } - writeJSON(w, http.StatusCreated, saveCheckpointHTTPResponse{Saved: saved}) + writeJSON(w, http.StatusCreated, saveCheckpointHTTPResponse{Saved: saved, CheckpointID: checkpointID}) case strings.HasSuffix(workspacePath, ":fork"): workspace := strings.TrimSuffix(workspacePath, ":fork") if r.Method != http.MethodPost { diff --git a/internal/controlplane/http_test.go b/internal/controlplane/http_test.go index 67d9d2d..8f79a07 100644 --- a/internal/controlplane/http_test.go +++ b/internal/controlplane/http_test.go @@ -1954,6 +1954,79 @@ func TestHTTPCheckpointListSaveAndFork(t *testing.T) { } } +func TestHTTPSaveFromLiveGeneratesCheckpointIDWhenOmitted(t *testing.T) { + t.Helper() + + manager, databaseID := newTestManager(t) + ctx := context.Background() + service, _, err := manager.serviceFor(ctx, databaseID) + if err != nil { + t.Fatalf("manager.serviceFor() returned error: %v", err) + } + redisKey, _, _, err := EnsureWorkspaceRoot(ctx, service.store, "repo") + if err != nil { + t.Fatalf("EnsureWorkspaceRoot() returned error: %v", err) + } + if err := mountclient.New(service.store.rdb, redisKey).Echo(ctx, "/drafts/notes.txt", []byte("working copy\n")); err != nil { + t.Fatalf("Echo() returned error: %v", err) + } + if err := MarkWorkspaceRootDirty(ctx, service.store, "repo"); err != nil { + t.Fatalf("MarkWorkspaceRootDirty() returned error: %v", err) + } + detail, err := manager.GetWorkspace(ctx, databaseID, "repo") + if err != nil { + t.Fatalf("manager.GetWorkspace() returned error: %v", err) + } + + server := httptest.NewServer(NewHandler(manager, "*")) + defer server.Close() + + resp, err := http.Post(server.URL+"/v1/workspaces/"+detail.ID+":save-from-live", "application/json", strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("POST save-from-live returned error: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("POST save-from-live status = %d, want %d, body=%s", resp.StatusCode, http.StatusCreated, body) + } + + var saveResponse saveCheckpointHTTPResponse + if err := json.NewDecoder(resp.Body).Decode(&saveResponse); err != nil { + t.Fatalf("Decode(save-from-live response) returned error: %v", err) + } + if !saveResponse.Saved { + t.Fatal("expected save-from-live response to report saved=true") + } + if !strings.HasPrefix(saveResponse.CheckpointID, "save-") { + t.Fatalf("generated checkpoint_id = %q, want save-*", saveResponse.CheckpointID) + } + + resp, err = http.Get(server.URL + "/v1/workspaces/" + detail.ID + "/checkpoints?limit=10") + if err != nil { + t.Fatalf("GET checkpoints after save-from-live returned error: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("GET checkpoints after save-from-live status = %d, want %d, body=%s", resp.StatusCode, http.StatusOK, body) + } + var checkpoints []checkpointSummary + if err := json.NewDecoder(resp.Body).Decode(&checkpoints); err != nil { + t.Fatalf("Decode(checkpoints after save-from-live) returned error: %v", err) + } + found := false + for _, checkpoint := range checkpoints { + if checkpoint.ID == saveResponse.CheckpointID { + found = true + break + } + } + if !found { + t.Fatalf("generated checkpoint %q not found in %#v", saveResponse.CheckpointID, checkpoints) + } +} + func TestHTTPClientWorkspaceSession(t *testing.T) { t.Helper() diff --git a/internal/controlplane/mcp_hosted.go b/internal/controlplane/mcp_hosted.go index 0ae9399..fbba2b7 100644 --- a/internal/controlplane/mcp_hosted.go +++ b/internal/controlplane/mcp_hosted.go @@ -1989,7 +1989,7 @@ func ternaryString(condition bool, whenTrue, whenFalse string) string { } func generatedSavepointName() string { - return "cp-" + time.Now().UTC().Format("20060102-150405") + return "save-" + time.Now().UTC().Format("20060102-150405.000") } func validateHostedMCPName(kind, value string) error { diff --git a/internal/controlplane/service.go b/internal/controlplane/service.go index e226a8e..2db1fc9 100644 --- a/internal/controlplane/service.go +++ b/internal/controlplane/service.go @@ -567,6 +567,10 @@ func (s *Service) saveCheckpointFromLive(ctx context.Context, workspace, checkpo if err := ValidateName("workspace", workspace); err != nil { return false, fmt.Errorf("save-from-live validate: %w", err) } + checkpointID = strings.TrimSpace(checkpointID) + if checkpointID == "" { + checkpointID = generatedSavepointName() + } if err := ValidateName("checkpoint", checkpointID); err != nil { return false, fmt.Errorf("save-from-live validate checkpoint: %w", err) } diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 3772767..dc58c04 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -31,7 +31,7 @@ def call_tool(self, name, arguments=None): entries.append({"path": file_path, "name": remainder, "kind": "file"}) return {"entries": entries} if name == "checkpoint_create": - return {"workspace": "workspace", "checkpoint": arguments.get("checkpoint") or "auto", "created": True} + return {"workspace": "workspace", "checkpoint": arguments.get("checkpoint") or "save-20260508-000000.000", "created": True} if name == "checkpoint_restore": return {"workspace": "workspace", "checkpoint": arguments["checkpoint"], "restored": True} raise AssertionError(f"unexpected tool {name}") @@ -159,6 +159,14 @@ def test_checkpoint_create_and_restore_round_trip(self): self.assertTrue(restored["restored"]) self.assertEqual(restored["checkpoint"], "unchanged-head") + def test_checkpoint_create_allows_omitted_name(self): + checkpoint = CheckpointClient(FakeMCP()) + + created = checkpoint.create(workspace="repo") + + self.assertTrue(created["created"]) + self.assertEqual(created["checkpoint"], "save-20260508-000000.000") + def test_normalizes_mcp_endpoint(self): self.assertEqual(_normalize_mcp_endpoint("https://afs.cloud"), "https://afs.cloud/mcp") self.assertEqual(_normalize_mcp_endpoint("https://afs.cloud/mcp"), "https://afs.cloud/mcp") diff --git a/sdk/typescript/test/sdk.test.mjs b/sdk/typescript/test/sdk.test.mjs index f7e4b72..12a92a2 100644 --- a/sdk/typescript/test/sdk.test.mjs +++ b/sdk/typescript/test/sdk.test.mjs @@ -104,3 +104,37 @@ test("checkpoint.create and checkpoint.restore round-trip through MCP", async () ["checkpoint_create", "checkpoint_restore"], ); }); + +test("checkpoint.create allows omitted checkpoint names", async () => { + const calls = []; + const afs = new AFS({ + apiKey: "test", + baseUrl: "https://afs.cloud", + fetch: async (_url, init) => { + const body = JSON.parse(String(init.body)); + calls.push(body); + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: body.id, + result: { + structuredContent: { + workspace: body.params.arguments.workspace, + checkpoint: "save-20260508-000000.000", + created: true, + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + }); + + const created = await afs.checkpoint.create({ workspace: "repo" }); + + assert.equal(created.created, true); + assert.equal(created.checkpoint, "save-20260508-000000.000"); + assert.equal(calls.length, 1); + assert.equal(calls[0].params.name, "checkpoint_create"); + assert.equal(calls[0].params.arguments.workspace, "repo"); +}); From 9eb7b8f2ce0ff7602a92272090ff8b6648d99425 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 8 May 2026 01:15:51 -0700 Subject: [PATCH 4/6] Handle post-QA edge cases --- internal/queryindex/queryindex.go | 1 + .../queryindex/search_unavailable_test.go | 15 +++++++++ .../searchindex/search_unavailable_test.go | 15 +++++++++ internal/searchindex/searchindex.go | 1 + sdk/python/src/redis_afs/client.py | 2 ++ sdk/python/tests/test_client.py | 32 +++++++++++++++++++ 6 files changed, 66 insertions(+) create mode 100644 internal/queryindex/search_unavailable_test.go create mode 100644 internal/searchindex/search_unavailable_test.go diff --git a/internal/queryindex/queryindex.go b/internal/queryindex/queryindex.go index 976827c..f71622e 100644 --- a/internal/queryindex/queryindex.go +++ b/internal/queryindex/queryindex.go @@ -1276,6 +1276,7 @@ func isSearchUnavailable(err error) bool { return strings.Contains(msg, "unknown command") || strings.Contains(msg, "module") || strings.Contains(msg, "not supported") || + strings.Contains(msg, "db != 0") || strings.Contains(msg, "resp3 responses for this command are disabled") } diff --git a/internal/queryindex/search_unavailable_test.go b/internal/queryindex/search_unavailable_test.go new file mode 100644 index 0000000..c976942 --- /dev/null +++ b/internal/queryindex/search_unavailable_test.go @@ -0,0 +1,15 @@ +package queryindex + +import ( + "errors" + "testing" +) + +func TestIsSearchUnavailableTreatsNonZeroDBAsUnavailable(t *testing.T) { + t.Helper() + + err := errors.New("ERR Cannot create index on db != 0") + if !isSearchUnavailable(err) { + t.Fatalf("isSearchUnavailable(%q) = false, want true", err) + } +} diff --git a/internal/searchindex/search_unavailable_test.go b/internal/searchindex/search_unavailable_test.go new file mode 100644 index 0000000..b85ea3a --- /dev/null +++ b/internal/searchindex/search_unavailable_test.go @@ -0,0 +1,15 @@ +package searchindex + +import ( + "errors" + "testing" +) + +func TestIsSearchUnavailableTreatsNonZeroDBAsUnavailable(t *testing.T) { + t.Helper() + + err := errors.New("ERR Cannot create index on db != 0") + if !isSearchUnavailable(err) { + t.Fatalf("isSearchUnavailable(%q) = false, want true", err) + } +} diff --git a/internal/searchindex/searchindex.go b/internal/searchindex/searchindex.go index 8d2b799..8b51628 100644 --- a/internal/searchindex/searchindex.go +++ b/internal/searchindex/searchindex.go @@ -149,6 +149,7 @@ func isSearchUnavailable(err error) bool { return strings.Contains(msg, "unknown command") || strings.Contains(msg, "module") || strings.Contains(msg, "not supported") || + strings.Contains(msg, "db != 0") || strings.Contains(msg, "resp3 responses for this command are disabled") } diff --git a/sdk/python/src/redis_afs/client.py b/sdk/python/src/redis_afs/client.py index 78ee6c2..10449ae 100644 --- a/sdk/python/src/redis_afs/client.py +++ b/sdk/python/src/redis_afs/client.py @@ -351,6 +351,8 @@ def _copy_local_directory(self, workspace: _MountedWorkspace, local_directory: P remote_path = _normalize_remote_path(posixpath.join(remote_directory, child.name)) if child.is_dir(): self._copy_local_directory(workspace, child, remote_path) + elif child.is_symlink(): + continue elif child.is_file(): workspace.client.call_tool( "file_write", diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index dc58c04..bf2dd61 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -1,4 +1,5 @@ import unittest +from pathlib import Path from unittest.mock import patch from redis_afs.client import AFSError, CheckpointClient, FSClient, MCPHttpClient, MountedFS, _MountedWorkspace, _normalize_mcp_endpoint @@ -7,13 +8,22 @@ class FakeMCP: def __init__(self): self.files = {} + self.symlinks = {} + self.calls = [] def call_tool(self, name, arguments=None): arguments = arguments or {} + self.calls.append((name, dict(arguments))) if name == "file_write": self.files[arguments["path"]] = arguments["content"] return {"path": arguments["path"], "operation": "write"} if name == "file_read": + if arguments["path"] in self.symlinks: + return { + "path": arguments["path"], + "kind": "symlink", + "target": self.symlinks[arguments["path"]], + } return { "path": arguments["path"], "kind": "file", @@ -29,6 +39,13 @@ def call_tool(self, name, arguments=None): remainder = file_path[len(path.rstrip("/")) + 1 :] if "/" not in remainder: entries.append({"path": file_path, "name": remainder, "kind": "file"}) + for link_path, target in sorted(self.symlinks.items()): + if path == "/" and "/" not in link_path.strip("/"): + entries.append({"path": link_path, "name": link_path.strip("/"), "kind": "symlink", "target": target}) + elif link_path.startswith(path.rstrip("/") + "/"): + remainder = link_path[len(path.rstrip("/")) + 1 :] + if "/" not in remainder: + entries.append({"path": link_path, "name": remainder, "kind": "symlink", "target": target}) return {"entries": entries} if name == "checkpoint_create": return {"workspace": "workspace", "checkpoint": arguments.get("checkpoint") or "save-20260508-000000.000", "created": True} @@ -146,6 +163,21 @@ def test_fs_mount_issues_workspace_token_and_reads_and_writes_files(self): self.assertEqual(control_plane.issued[0]["arguments"]["profile"], "workspace-rw") self.assertEqual(control_plane.issued[0]["arguments"]["name"], "Mounted FS") + def test_sync_to_remote_skips_symlink_entries(self): + fake = FakeMCP() + fake.files["/README.md"] = "hello" + fake.symlinks["/readme-link.md"] = "README.md" + fs = MountedFS([_MountedWorkspace(name="repo", token="token", client=fake)]) + self.addCleanup(fs.close) + + root = fs.sync_from_remote() + self.assertTrue(Path(root, "repo", "readme-link.md").is_symlink()) + + fs.sync_to_remote() + + write_paths = [args["path"] for name, args in fake.calls if name == "file_write"] + self.assertNotIn("/readme-link.md", write_paths) + class EndpointTest(unittest.TestCase): def test_checkpoint_create_and_restore_round_trip(self): From a1e57a70ce8a201c66549f5db651ec50fc3c030a Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 8 May 2026 12:03:42 -0700 Subject: [PATCH 5/6] Fix UI lint after rebase --- ui/src/foundation/api/afs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/foundation/api/afs.ts b/ui/src/foundation/api/afs.ts index 24194b3..b97191e 100644 --- a/ui/src/foundation/api/afs.ts +++ b/ui/src/foundation/api/afs.ts @@ -2171,8 +2171,8 @@ This workspace was created from the AFS Web UI. const configs = loadDemoWorkspaceConfigs(); const config = configs[input.workspaceId] ?? defaultWorkspaceConfig(); const policies = loadDemoVersioningPolicies(); - if (policies[input.workspaceId]) { - config.versioning = policies[input.workspaceId]; + if (Object.hasOwn(policies, input.workspaceId)) { + config.versioning = policies[input.workspaceId]!; } return normalizeWorkspaceConfig(config); }, From 24274f95221fb70595e639dda701344e55989927 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 11 May 2026 10:54:47 -0700 Subject: [PATCH 6/6] Align UI doc test with volume routes --- .../agent-experience/public-agent-documents.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/src/features/agent-experience/public-agent-documents.test.ts b/ui/src/features/agent-experience/public-agent-documents.test.ts index 6f4a84e..a6f6b59 100644 --- a/ui/src/features/agent-experience/public-agent-documents.test.ts +++ b/ui/src/features/agent-experience/public-agent-documents.test.ts @@ -56,16 +56,18 @@ describe("getSiteAgentDocument", () => { expect(doc.markdown).toContain("[MCP](https://ui.example.com/mcp)"); }); - test("uses workspace tab state for workspace studio routes", () => { - const doc = getSiteAgentDocument("/workspaces/payments-portal", { + test("uses volume tab state for volume studio routes", () => { + const doc = getSiteAgentDocument("/volumes/payments-portal", { controlPlaneUrl: "https://afs.example.com", siteOrigin: "https://ui.example.com", search: "?tab=checkpoints&databaseId=db-1", }); - expect(doc.title).toBe("Workspace Studio: Checkpoints"); + expect(doc.title).toBe("Volume Details: Checkpoints"); expect(doc.markdown).toContain("Active tab: Checkpoints"); - expect(doc.markdown).toContain("afs cp create payments-portal before-risky-change"); + expect(doc.markdown).toContain( + "afs cp create --volume payments-portal before-risky-change", + ); }); test("describes installed template pages directly", () => {