Skip to content

Commit c8454ad

Browse files
[codex] structure workspace search failures (#3352)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 336d176 commit c8454ad

2 files changed

Lines changed: 212 additions & 23 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { FileFinder } from "@ff-labs/fff-node";
2+
import { afterEach, expect, it } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import { vi } from "vite-plus/test";
5+
6+
import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
7+
8+
afterEach(() => {
9+
vi.restoreAllMocks();
10+
});
11+
12+
it.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+
32+
it.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+
52+
it.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+
94+
it.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

Lines changed: 84 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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

155160
const 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

168-
const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* (
169-
cwd: string,
170-
finder: FileFinder,
171-
) {
172-
yield* Effect.sync(() => finder.isScanning()).pipe(
185+
const 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,34 +196,78 @@ 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

185202
export 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
});
201242

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")(

0 commit comments

Comments
 (0)