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...@@ -23,10 +23,11 @@ export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<Wo
23 {23 {
24 cwd: Schema.String,24 cwd: Schema.String,
25 reason: Schema.String,25 reason: Schema.String,
26 cause: Schema.optional(Schema.Defect()),
26 },27 },
27) {28) {
28 override get message(): string {29 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}'.`;
30 }31 }
31}32}
3233
...@@ -46,11 +47,14 @@ export class WorkspaceSearchIndexSearchFailed extends Schema.TaggedErrorClass<Wo...@@ -46,11 +47,14 @@ export class WorkspaceSearchIndexSearchFailed extends Schema.TaggedErrorClass<Wo
46 "WorkspaceSearchIndexSearchFailed",47 "WorkspaceSearchIndexSearchFailed",
47 {48 {
48 cwd: Schema.String,49 cwd: Schema.String,
50 queryLength: Schema.Number,
51 pageSize: Schema.Number,
49 reason: Schema.String,52 reason: Schema.String,
53 cause: Schema.optional(Schema.Defect()),
50 },54 },
51) {55) {
52 override get message(): string {56 override get message(): string {
53 return `Workspace search failed for '${this.cwd}': ${this.reason}`;57 return `Workspace search failed for '${this.cwd}'.`;
54 }58 }
55}59}
5660
...@@ -59,10 +63,11 @@ export class WorkspaceSearchIndexRefreshFailed extends Schema.TaggedErrorClass<W...@@ -59,10 +63,11 @@ export class WorkspaceSearchIndexRefreshFailed extends Schema.TaggedErrorClass<W
59 {63 {
60 cwd: Schema.String,64 cwd: Schema.String,
61 reason: Schema.String,65 reason: Schema.String,
66 cause: Schema.optional(Schema.Defect()),
62 },67 },
63) {68) {
64 override get message(): string {69 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}'.`;
66 }71 }
67}72}
6873
...@@ -153,23 +158,35 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn...@@ -153,23 +158,35 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn
153}158}
154159
155const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) {160const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) {
156 const result = FileFinder.create({161 const result = yield* Effect.try({
157 basePath: cwd,162 try: () =>
158 disableMmapCache: true,163 FileFinder.create({
159 disableContentIndexing: true,164 basePath: cwd,
160 aiMode: false,165 disableMmapCache: true,
161 enableFsRootScanning: true,166 disableContentIndexing: true,
162 enableHomeDirScanning: 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 }),
163 });177 });
164 if (result.ok) return result.value;178 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 });
166});183});
167184
168const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* (185const waitForScan = <E>(cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) =>
169 cwd: string,186 Effect.try({
170 finder: FileFinder,187 try: () => finder.isScanning(),
171) {188 catch: onFailure,
172 yield* Effect.sync(() => finder.isScanning()).pipe(189 }).pipe(
173 Effect.repeat({190 Effect.repeat({
174 while: (scanning) => scanning,191 while: (scanning) => scanning,
175 schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL),192 schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL),
...@@ -179,22 +196,46 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* (...@@ -179,22 +196,46 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* (
179 orElse: () =>196 orElse: () =>
180 new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }),197 new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }),
181 }),198 }),
199 Effect.withSpan("WorkspaceSearchIndex.waitForScan"),
182 );200 );
183});
184201
185export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) {202export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) {
186 const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) =>203 const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) =>
187 Effect.sync(() => finder.destroy()),204 Effect.sync(() => finder.destroy()),
188 );205 );
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
191 const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (217 const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (
192 query: string,218 query: string,
193 pageSize: number,219 pageSize: number,
194 ) {220 ) {
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 });
196 if (!result.ok) {232 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 });
198 }239 }
199 return result.value;240 return result.value;
200 });241 });
...@@ -202,11 +243,31 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin...@@ -202,11 +243,31 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin
202 const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn(243 const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn(
203 "WorkspaceSearchIndex.refresh",244 "WorkspaceSearchIndex.refresh",
204 )(function* () {245 )(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 });
206 if (!result.ok) {255 if (!result.ok) {
207 return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error });256 return yield* new WorkspaceSearchIndexRefreshFailed({
257 cwd,
258 reason: result.error,
259 });
208 }260 }
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 );
210 });271 });
211272
212 const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(273 const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(