authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:01:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:01:31-07:00
logc8454ad88a8774034544f02d177fbebd0520f80a
tree95b68ccfb2f84e24bb48bc112a6ac5ec222e517b
parent336d176027d1079fb98554a9439b6a2412c7e2fe
signature Signed by PGP key B5690EEEBB952194

[codex] structure workspace search failures (#3352)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 212 insertions(+), 23 deletions(-)

apps/server/src/workspace/WorkspaceSearchIndex.test.ts created+128
......@@ -0,0 +1,128 @@
1import { FileFinder } from "@ff-labs/fff-node";
2import { afterEach, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import { vi } from "vite-plus/test";
5
6import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
7
8afterEach(() => {
9 vi.restoreAllMocks();
10});
11
12it.effect("preserves unexpected FileFinder creation failures", () =>
13 Effect.gen(function* () {
14 const cause = new Error("native initialization failed");
15 vi.spyOn(FileFinder, "create").mockImplementationOnce(() => {
16 throw cause;
17 });
18
19 const error = yield* Effect.flip(
20 Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")),
21 );
22
23 expect(error).toMatchObject({
24 _tag: "WorkspaceSearchIndexCreateFailed",
25 cwd: "/workspace/project",
26 reason: "FileFinder.create threw unexpectedly.",
27 cause,
28 });
29 }),
30);
31
32it.effect("keeps returned FileFinder creation diagnostics out of the cause chain", () =>
33 Effect.gen(function* () {
34 vi.spyOn(FileFinder, "create").mockReturnValueOnce({
35 ok: false,
36 error: "native index rejected the directory",
37 });
38
39 const error = yield* Effect.flip(
40 Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")),
41 );
42
43 expect(error).toMatchObject({
44 _tag: "WorkspaceSearchIndexCreateFailed",
45 cwd: "/workspace/project",
46 reason: "native index rejected the directory",
47 });
48 expect(error.cause).toBeUndefined();
49 }),
50);
51
52it.effect("preserves search and refresh failures with operation context", () =>
53 Effect.scoped(
54 Effect.gen(function* () {
55 const searchCause = new Error("native search failed");
56 const refreshCause = new Error("native scan failed");
57 const finder = {
58 destroy: vi.fn(),
59 isScanning: vi.fn(() => false),
60 mixedSearch: vi.fn(() => {
61 throw searchCause;
62 }),
63 scanFiles: vi.fn(() => {
64 throw refreshCause;
65 }),
66 } as unknown as FileFinder;
67 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
68
69 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
70 const query = "authorization: Bearer secret-token";
71 const searchError = yield* Effect.flip(searchIndex.search(query, 3));
72 const refreshError = yield* Effect.flip(searchIndex.refresh());
73
74 expect(searchError).toMatchObject({
75 _tag: "WorkspaceSearchIndexSearchFailed",
76 cwd: "/workspace/project",
77 queryLength: query.length,
78 pageSize: 4,
79 reason: "FileFinder.mixedSearch threw unexpectedly.",
80 cause: searchCause,
81 });
82 expect(searchError).not.toHaveProperty("query");
83 expect(searchError.message).not.toMatch(/Bearer|secret-token/);
84 expect(refreshError).toMatchObject({
85 _tag: "WorkspaceSearchIndexRefreshFailed",
86 cwd: "/workspace/project",
87 reason: "FileFinder.scanFiles threw unexpectedly.",
88 cause: refreshCause,
89 });
90 }),
91 ),
92);
93
94it.effect("keeps returned search diagnostics out of the cause chain", () =>
95 Effect.scoped(
96 Effect.gen(function* () {
97 const finder = {
98 destroy: vi.fn(),
99 isScanning: vi.fn(() => false),
100 mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })),
101 scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })),
102 } as unknown as FileFinder;
103 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
104
105 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
106 const query = "authorization: Bearer secret-token";
107 const searchError = yield* Effect.flip(searchIndex.search(query, 3));
108 const refreshError = yield* Effect.flip(searchIndex.refresh());
109
110 expect(searchError).toMatchObject({
111 _tag: "WorkspaceSearchIndexSearchFailed",
112 cwd: "/workspace/project",
113 queryLength: query.length,
114 pageSize: 4,
115 reason: "native query rejected",
116 });
117 expect(searchError).not.toHaveProperty("query");
118 expect(searchError.message).not.toMatch(/Bearer|secret-token/);
119 expect(searchError.cause).toBeUndefined();
120 expect(refreshError).toMatchObject({
121 _tag: "WorkspaceSearchIndexRefreshFailed",
122 cwd: "/workspace/project",
123 reason: "native refresh rejected",
124 });
125 expect(refreshError.cause).toBeUndefined();
126 }),
127 ),
128);
apps/server/src/workspace/WorkspaceSearchIndex.ts+84-23
......@@ -23,10 +23,11 @@ export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<Wo
2323 {
2424 cwd: Schema.String,
2525 reason: Schema.String,
26 cause: Schema.optional(Schema.Defect()),
2627 },
2728) {
2829 override get message(): string {
29 return `Failed to create the workspace search index for '${this.cwd}': ${this.reason}`;
30 return `Failed to create the workspace search index for '${this.cwd}'.`;
3031 }
3132}
3233
......@@ -46,11 +47,14 @@ export class WorkspaceSearchIndexSearchFailed extends Schema.TaggedErrorClass<Wo
4647 "WorkspaceSearchIndexSearchFailed",
4748 {
4849 cwd: Schema.String,
50 queryLength: Schema.Number,
51 pageSize: Schema.Number,
4952 reason: Schema.String,
53 cause: Schema.optional(Schema.Defect()),
5054 },
5155) {
5256 override get message(): string {
53 return `Workspace search failed for '${this.cwd}': ${this.reason}`;
57 return `Workspace search failed for '${this.cwd}'.`;
5458 }
5559}
5660
......@@ -59,10 +63,11 @@ export class WorkspaceSearchIndexRefreshFailed extends Schema.TaggedErrorClass<W
5963 {
6064 cwd: Schema.String,
6165 reason: Schema.String,
66 cause: Schema.optional(Schema.Defect()),
6267 },
6368) {
6469 override get message(): string {
65 return `Failed to refresh the workspace search index for '${this.cwd}': ${this.reason}`;
70 return `Failed to refresh the workspace search index for '${this.cwd}'.`;
6671 }
6772}
6873
......@@ -153,23 +158,35 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn
153158}
154159
155160const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) {
156 const result = FileFinder.create({
157 basePath: cwd,
158 disableMmapCache: true,
159 disableContentIndexing: true,
160 aiMode: false,
161 enableFsRootScanning: true,
162 enableHomeDirScanning: true,
161 const result = yield* Effect.try({
162 try: () =>
163 FileFinder.create({
164 basePath: cwd,
165 disableMmapCache: true,
166 disableContentIndexing: true,
167 aiMode: false,
168 enableFsRootScanning: true,
169 enableHomeDirScanning: true,
170 }),
171 catch: (cause) =>
172 new WorkspaceSearchIndexCreateFailed({
173 cwd,
174 reason: "FileFinder.create threw unexpectedly.",
175 cause,
176 }),
163177 });
164178 if (result.ok) return result.value;
165 return yield* new WorkspaceSearchIndexCreateFailed({ cwd, reason: result.error });
179 return yield* new WorkspaceSearchIndexCreateFailed({
180 cwd,
181 reason: result.error,
182 });
166183});
167184
168const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* (
169 cwd: string,
170 finder: FileFinder,
171) {
172 yield* Effect.sync(() => finder.isScanning()).pipe(
185const waitForScan = <E>(cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) =>
186 Effect.try({
187 try: () => finder.isScanning(),
188 catch: onFailure,
189 }).pipe(
173190 Effect.repeat({
174191 while: (scanning) => scanning,
175192 schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL),
......@@ -179,22 +196,46 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* (
179196 orElse: () =>
180197 new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }),
181198 }),
199 Effect.withSpan("WorkspaceSearchIndex.waitForScan"),
182200 );
183});
184201
185202export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) {
186203 const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) =>
187204 Effect.sync(() => finder.destroy()),
188205 );
189 yield* waitForScan(cwd, finder);
206 yield* waitForScan(
207 cwd,
208 finder,
209 (cause) =>
210 new WorkspaceSearchIndexCreateFailed({
211 cwd,
212 reason: "FileFinder.isScanning threw while creating the index.",
213 cause,
214 }),
215 );
190216
191217 const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (
192218 query: string,
193219 pageSize: number,
194220 ) {
195 const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize }));
221 const result = yield* Effect.try({
222 try: () => finder.mixedSearch(query, { pageSize }),
223 catch: (cause) =>
224 new WorkspaceSearchIndexSearchFailed({
225 cwd,
226 queryLength: query.length,
227 pageSize,
228 reason: "FileFinder.mixedSearch threw unexpectedly.",
229 cause,
230 }),
231 });
196232 if (!result.ok) {
197 return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error });
233 return yield* new WorkspaceSearchIndexSearchFailed({
234 cwd,
235 queryLength: query.length,
236 pageSize,
237 reason: result.error,
238 });
198239 }
199240 return result.value;
200241 });
......@@ -202,11 +243,31 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin
202243 const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn(
203244 "WorkspaceSearchIndex.refresh",
204245 )(function* () {
205 const result = yield* Effect.sync(() => finder.scanFiles());
246 const result = yield* Effect.try({
247 try: () => finder.scanFiles(),
248 catch: (cause) =>
249 new WorkspaceSearchIndexRefreshFailed({
250 cwd,
251 reason: "FileFinder.scanFiles threw unexpectedly.",
252 cause,
253 }),
254 });
206255 if (!result.ok) {
207 return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error });
256 return yield* new WorkspaceSearchIndexRefreshFailed({
257 cwd,
258 reason: result.error,
259 });
208260 }
209 yield* waitForScan(cwd, finder);
261 yield* waitForScan(
262 cwd,
263 finder,
264 (cause) =>
265 new WorkspaceSearchIndexRefreshFailed({
266 cwd,
267 reason: "FileFinder.isScanning threw while refreshing the index.",
268 cause,
269 }),
270 );
210271 });
211272
212273 const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(