diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts new file mode 100644 index 00000000000..41ea90b9735 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -0,0 +1,128 @@ +import { FileFinder } from "@ff-labs/fff-node"; +import { afterEach, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it.effect("preserves unexpected FileFinder creation failures", () => + Effect.gen(function* () { + const cause = new Error("native initialization failed"); + vi.spyOn(FileFinder, "create").mockImplementationOnce(() => { + throw cause; + }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexCreateFailed", + cwd: "/workspace/project", + reason: "FileFinder.create threw unexpectedly.", + cause, + }); + }), +); + +it.effect("keeps returned FileFinder creation diagnostics out of the cause chain", () => + Effect.gen(function* () { + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ + ok: false, + error: "native index rejected the directory", + }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexCreateFailed", + cwd: "/workspace/project", + reason: "native index rejected the directory", + }); + expect(error.cause).toBeUndefined(); + }), +); + +it.effect("preserves search and refresh failures with operation context", () => + Effect.scoped( + Effect.gen(function* () { + const searchCause = new Error("native search failed"); + const refreshCause = new Error("native scan failed"); + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => false), + mixedSearch: vi.fn(() => { + throw searchCause; + }), + scanFiles: vi.fn(() => { + throw refreshCause; + }), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const query = "authorization: Bearer secret-token"; + const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const refreshError = yield* Effect.flip(searchIndex.refresh()); + + expect(searchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 4, + reason: "FileFinder.mixedSearch threw unexpectedly.", + cause: searchCause, + }); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(refreshError).toMatchObject({ + _tag: "WorkspaceSearchIndexRefreshFailed", + cwd: "/workspace/project", + reason: "FileFinder.scanFiles threw unexpectedly.", + cause: refreshCause, + }); + }), + ), +); + +it.effect("keeps returned search diagnostics out of the cause chain", () => + Effect.scoped( + Effect.gen(function* () { + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => false), + mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), + scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const query = "authorization: Bearer secret-token"; + const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const refreshError = yield* Effect.flip(searchIndex.refresh()); + + expect(searchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 4, + reason: "native query rejected", + }); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(searchError.cause).toBeUndefined(); + expect(refreshError).toMatchObject({ + _tag: "WorkspaceSearchIndexRefreshFailed", + cwd: "/workspace/project", + reason: "native refresh rejected", + }); + expect(refreshError.cause).toBeUndefined(); + }), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index fcacf3caf13..2b043e05c0e 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -23,10 +23,11 @@ export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass): ProjectEn } const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { - const result = FileFinder.create({ - basePath: cwd, - disableMmapCache: true, - disableContentIndexing: true, - aiMode: false, - enableFsRootScanning: true, - enableHomeDirScanning: true, + const result = yield* Effect.try({ + try: () => + FileFinder.create({ + basePath: cwd, + disableMmapCache: true, + disableContentIndexing: true, + aiMode: false, + enableFsRootScanning: true, + enableHomeDirScanning: true, + }), + catch: (cause) => + new WorkspaceSearchIndexCreateFailed({ + cwd, + reason: "FileFinder.create threw unexpectedly.", + cause, + }), }); if (result.ok) return result.value; - return yield* new WorkspaceSearchIndexCreateFailed({ cwd, reason: result.error }); + return yield* new WorkspaceSearchIndexCreateFailed({ + cwd, + reason: result.error, + }); }); -const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( - cwd: string, - finder: FileFinder, -) { - yield* Effect.sync(() => finder.isScanning()).pipe( +const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => + Effect.try({ + try: () => finder.isScanning(), + catch: onFailure, + }).pipe( Effect.repeat({ while: (scanning) => scanning, schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), @@ -179,22 +196,46 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( orElse: () => new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), }), + Effect.withSpan("WorkspaceSearchIndex.waitForScan"), ); -}); export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy()), ); - yield* waitForScan(cwd, finder); + yield* waitForScan( + cwd, + finder, + (cause) => + new WorkspaceSearchIndexCreateFailed({ + cwd, + reason: "FileFinder.isScanning threw while creating the index.", + cause, + }), + ); const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( query: string, pageSize: number, ) { - const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); + const result = yield* Effect.try({ + try: () => finder.mixedSearch(query, { pageSize }), + catch: (cause) => + new WorkspaceSearchIndexSearchFailed({ + cwd, + queryLength: query.length, + pageSize, + reason: "FileFinder.mixedSearch threw unexpectedly.", + cause, + }), + }); if (!result.ok) { - return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); + return yield* new WorkspaceSearchIndexSearchFailed({ + cwd, + queryLength: query.length, + pageSize, + reason: result.error, + }); } return result.value; }); @@ -202,11 +243,31 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( "WorkspaceSearchIndex.refresh", )(function* () { - const result = yield* Effect.sync(() => finder.scanFiles()); + const result = yield* Effect.try({ + try: () => finder.scanFiles(), + catch: (cause) => + new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: "FileFinder.scanFiles threw unexpectedly.", + cause, + }), + }); if (!result.ok) { - return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); + return yield* new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: result.error, + }); } - yield* waitForScan(cwd, finder); + yield* waitForScan( + cwd, + finder, + (cause) => + new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: "FileFinder.isScanning threw while refreshing the index.", + cause, + }), + ); }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(