From fec71c5ab4fd860331fb15ce37c15edf6db1c236 Mon Sep 17 00:00:00 2001 From: Michel Liao <107891771+Michel-Liao@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:08:28 -0400 Subject: [PATCH] fix(server): return partial workspace index on timeout (#11500) --- .../workspace/WorkspaceSearchIndex.test.ts | 68 +++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 25 +++++-- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 1fdf956447d45c8e79097eaea8a3ecca7c61037d..4a3e80e92ecc6c79bb7663d96a323ae2f9032e80 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -140,6 +140,74 @@ it.effect("preserves a full-index warmup timeout as a structured error", () => }), ); +it.effect("returns partial path results when the initial scan times out", () => + Effect.scoped( + Effect.gen(function* () { + const mixedSearch = vi.fn(() => ({ + ok: true as const, + value: { + items: [ + { + type: "directory" as const, + item: { relativePath: "src", fileName: "src", fileCount: 1 }, + }, + { type: "file" as const, item: fileItem("src/index.ts") }, + ], + scores: [], + totalMatched: 2, + totalFiles: 1, + }, + })); + let scanning = true; + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => scanning), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), + mixedSearch, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "paths"); + const list = yield* searchIndex.list(); + const search = yield* searchIndex.search("src", 10); + scanning = true; + mixedSearch.mockImplementationOnce(() => { + scanning = false; + return { + ok: true as const, + value: { + items: [ + { + type: "directory" as const, + item: { relativePath: "src", fileName: "src", fileCount: 1 }, + }, + { type: "file" as const, item: fileItem("src/index.ts") }, + ], + scores: [], + totalMatched: 2, + totalFiles: 1, + }, + }; + }); + const searchCompletingDuringQuery = yield* searchIndex.search("src", 10); + scanning = false; + const completedSearch = yield* searchIndex.search("src", 10); + + expect(list).toEqual({ + entries: [ + { kind: "directory", path: "src" }, + { kind: "file", path: "src/index.ts" }, + ], + truncated: true, + }); + expect(search.truncated).toBe(true); + expect(searchCompletingDuringQuery.truncated).toBe(true); + expect(completedSearch.truncated).toBe(false); + expect(mixedSearch).toHaveBeenCalledTimes(4); + }), + ), +); + it.effect("preserves FileFinder destroy failures as structured defects", () => Effect.gen(function* () { const cause = new Error("native destroy failed"); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index a387dd189b3b95ef651b40327f373587d5d5fda9..2d3402dd76a36f7483e56b03d58bb74e5005db3b 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -365,6 +365,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), }).pipe(Effect.orDie), ); + let initialScanTimedOut = false; yield* waitForIndexReady( cwd, finder, @@ -374,8 +375,18 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason, cause, }), + ).pipe( + Effect.catchTag("WorkspaceSearchIndexScanTimedOut", (error) => + variant === "paths" + ? Effect.sync(() => { + initialScanTimedOut = true; + }) + : Effect.fail(error), + ), ); + const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning(); + const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, pageSize: number, @@ -436,6 +447,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { + const incompleteBeforeQuery = hasIncompleteInitialScan(); const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), ); @@ -446,7 +458,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); return { entries, - truncated: mapped.truncated || entries.length < sortedEntries.length, + truncated: + incompleteBeforeQuery || mapped.truncated || entries.length < sortedEntries.length, }; }, ); @@ -455,22 +468,26 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( "WorkspaceSearchIndex.search", )(function* (query, limit, kind, imageOnly) { const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); + const incompleteBeforeQuery = hasIncompleteInitialScan(); if (kind === "file" || imageOnly) { const result = yield* runSearch(query, pageSize, "fileSearch", () => finder.fileSearch(query, { pageSize }), ); - return mapFileSearchResult(result, limit, imageOnly); + const mapped = mapFileSearchResult(result, limit, imageOnly); + return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; } if (kind === "directory") { const result = yield* runSearch(query, pageSize, "directorySearch", () => finder.directorySearch(query, { pageSize }), ); - return mapDirectorySearchResult(result, limit); + const mapped = mapDirectorySearchResult(result, limit); + return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; } const result = yield* runSearch(query, pageSize, "mixedSearch", () => finder.mixedSearch(query, { pageSize }), ); - return mapMixedSearchResult(result, limit); + const mapped = mapMixedSearchResult(result, limit); + return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; }); const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( -- 2.54.0