Skip to content

Commit d2c05e3

Browse files
committed
refactor(code-index): extract scan execution without behavior changes
1 parent d351a15 commit d2c05e3

4 files changed

Lines changed: 325 additions & 132 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { CodeIndexScanExecutor } from "../code-index-scan-executor"
2+
import type { IDirectoryScanner } from "../interfaces"
3+
4+
vi.mock("../../../i18n", () => ({ t: (key: string) => key }))
5+
6+
function setup() {
7+
const scanner = { scanDirectory: vi.fn<IDirectoryScanner["scanDirectory"]>() }
8+
const vectorStore = { markIndexingIncomplete: vi.fn().mockResolvedValue(undefined) }
9+
const stateManager = { setSystemState: vi.fn(), reportBlockIndexingProgress: vi.fn() }
10+
return {
11+
scanner,
12+
vectorStore,
13+
stateManager,
14+
executor: new CodeIndexScanExecutor("/workspace", scanner, vectorStore, stateManager),
15+
}
16+
}
17+
18+
describe("CodeIndexScanExecutor", () => {
19+
it.each(["runFullScan", "runIncrementalScan"] as const)(
20+
"%s skips failure validation when cancellation occurs during the scan",
21+
async (method) => {
22+
const { executor, scanner } = setup()
23+
const controller = new AbortController()
24+
scanner.scanDirectory.mockImplementation(async (_path, onError, _onIndexed, onParsed) => {
25+
onParsed?.(3)
26+
onError?.(new Error("batch failure"))
27+
controller.abort()
28+
return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 3 }
29+
})
30+
await expect(executor[method](controller.signal)).resolves.toBe(false)
31+
},
32+
)
33+
34+
it("completes an incremental scan with no changed files", async () => {
35+
const { executor, scanner } = setup()
36+
scanner.scanDirectory.mockResolvedValue({ stats: { processed: 0, skipped: 2 }, totalBlockCount: 0 })
37+
await expect(executor.runIncrementalScan(new AbortController().signal)).resolves.toBe(true)
38+
})
39+
40+
it.each(["runFullScan", "runIncrementalScan"] as const)(
41+
"%s preserves progress and signal wiring",
42+
async (method) => {
43+
const { executor, scanner, vectorStore, stateManager } = setup()
44+
const signal = new AbortController().signal
45+
scanner.scanDirectory.mockImplementation(async (path, _onError, onIndexed, onParsed, receivedSignal) => {
46+
expect(path).toBe("/workspace")
47+
expect(receivedSignal).toBe(signal)
48+
expect(vectorStore.markIndexingIncomplete).toHaveBeenCalledOnce()
49+
onParsed?.(2)
50+
onIndexed?.(1)
51+
onParsed?.(1)
52+
onIndexed?.(2)
53+
return { stats: { processed: 2, skipped: 0 }, totalBlockCount: 3 }
54+
})
55+
await expect(executor[method](signal)).resolves.toBe(true)
56+
expect(stateManager.reportBlockIndexingProgress.mock.calls).toEqual([
57+
[0, 2],
58+
[1, 2],
59+
[1, 3],
60+
[3, 3],
61+
])
62+
expect(stateManager.setSystemState).toHaveBeenCalledExactlyOnceWith(
63+
"Indexing",
64+
method === "runFullScan"
65+
? "Services ready. Starting workspace scan..."
66+
: "Checking for new or modified files...",
67+
)
68+
},
69+
)
70+
71+
it.each(["runFullScan", "runIncrementalScan"] as const)("%s rejects missing results", async (method) => {
72+
const { executor } = setup()
73+
// An unconfigured mock simulates a scanner violating its return contract.
74+
await expect(executor[method](new AbortController().signal)).rejects.toThrow(
75+
method === "runFullScan"
76+
? "Scan failed, is scanner initialized?"
77+
: "Incremental scan failed, is scanner initialized?",
78+
)
79+
})
80+
81+
it.each(["runFullScan", "runIncrementalScan"] as const)(
82+
"%s returns cancellation before validating results",
83+
async (method) => {
84+
const { executor, scanner } = setup()
85+
const controller = new AbortController()
86+
// Abort before the unconfigured scanner returns a missing result.
87+
controller.abort()
88+
await expect(executor[method](controller.signal)).resolves.toBe(false)
89+
expect(scanner.scanDirectory).toHaveBeenCalledOnce()
90+
},
91+
)
92+
93+
it.each(["runFullScan", "runIncrementalScan"] as const)(
94+
"%s propagates scanner and marker failures unchanged",
95+
async (method) => {
96+
const { executor, scanner, vectorStore } = setup()
97+
const signal = new AbortController().signal
98+
const failure = new Error("scan failed")
99+
scanner.scanDirectory.mockRejectedValueOnce(failure)
100+
await expect(executor[method](signal)).rejects.toBe(failure)
101+
const abort = new DOMException("stopped", "AbortError")
102+
scanner.scanDirectory.mockRejectedValueOnce(abort)
103+
await expect(executor[method](signal)).rejects.toBe(abort)
104+
vectorStore.markIndexingIncomplete.mockRejectedValueOnce(failure)
105+
await expect(executor[method](signal)).rejects.toBe(failure)
106+
expect(scanner.scanDirectory).toHaveBeenCalledTimes(2)
107+
},
108+
)
109+
110+
it.each([
111+
{ found: 0, indexed: 0, error: false, message: undefined },
112+
{ found: 3, indexed: 0, error: false, message: "embeddings:orchestrator.indexingFailedNoBlocks" },
113+
{ found: 3, indexed: 0, error: true, message: "Indexing failed: batch failure" },
114+
{ found: 0, indexed: 0, error: true, message: "Indexing failed completely: batch failure" },
115+
{ found: 10, indexed: 9, error: true, message: undefined },
116+
{
117+
found: 10,
118+
indexed: 8,
119+
error: true,
120+
message: "Indexing partially failed: Only 8 of 10 blocks were indexed. batch failure",
121+
},
122+
{ found: 10, indexed: 8, error: false, message: undefined },
123+
])(
124+
"preserves full-scan policy for $indexed/$found blocks, error=$error",
125+
async ({ found, indexed, error, message }) => {
126+
const { executor, scanner } = setup()
127+
scanner.scanDirectory.mockImplementation(async (_path, onError, onIndexed, onParsed) => {
128+
onParsed?.(found)
129+
onIndexed?.(indexed)
130+
if (error) onError?.(new Error("batch failure"))
131+
return { stats: { processed: 1, skipped: 0 }, totalBlockCount: found }
132+
})
133+
const result = executor.runFullScan(new AbortController().signal)
134+
if (message) await expect(result).rejects.toThrow(message)
135+
else await expect(result).resolves.toBe(true)
136+
},
137+
)
138+
139+
it.each([0, 2])("preserves incremental batch-error tolerance with %s indexed blocks", async (indexed) => {
140+
const { executor, scanner } = setup()
141+
scanner.scanDirectory.mockImplementation(async (_path, onError, onIndexed, onParsed) => {
142+
onParsed?.(3)
143+
onIndexed?.(indexed)
144+
onError?.(new Error("batch failure"))
145+
return { stats: { processed: 1, skipped: 0 }, totalBlockCount: 3 }
146+
})
147+
await expect(executor.runIncrementalScan(new AbortController().signal)).resolves.toBe(true)
148+
})
149+
})

‎src/services/code-index/__tests__/orchestrator.spec.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,72 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => {
107107
}
108108
})
109109

110+
it.each([false, true])(
111+
"keeps watcher startup and completion after scanning (incremental: %s)",
112+
async (incremental) => {
113+
const events: string[] = []
114+
vectorStore.initialize.mockResolvedValue(false)
115+
vectorStore.hasIndexedData.mockResolvedValue(incremental)
116+
vectorStore.markIndexingIncomplete.mockImplementation(async () => {
117+
events.push("incomplete")
118+
})
119+
scanner.scanDirectory.mockImplementation(async () => {
120+
events.push("scan")
121+
return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 }
122+
})
123+
fileWatcher.initialize.mockImplementation(async () => {
124+
events.push("watcher")
125+
})
126+
vectorStore.markIndexingComplete.mockImplementation(async () => {
127+
events.push("complete")
128+
})
129+
const orchestrator = new CodeIndexOrchestrator(
130+
configManager,
131+
stateManager,
132+
workspacePath,
133+
cacheManager,
134+
vectorStore,
135+
scanner,
136+
fileWatcher,
137+
)
138+
await orchestrator.startIndexing()
139+
expect(events).toEqual(["incomplete", "scan", "watcher", "complete"])
140+
expect(orchestrator.state).toBe("Indexed")
141+
expect(cacheManager.clearCacheFile).not.toHaveBeenCalled()
142+
expect(vectorStore.clearCollection).not.toHaveBeenCalled()
143+
},
144+
)
145+
146+
it.each([false, true])(
147+
"keeps returned cancellation cleanup in the owner (incremental: %s)",
148+
async (incremental) => {
149+
vectorStore.initialize.mockResolvedValue(false)
150+
vectorStore.hasIndexedData.mockResolvedValue(incremental)
151+
const orchestrator = new CodeIndexOrchestrator(
152+
configManager,
153+
stateManager,
154+
workspacePath,
155+
cacheManager,
156+
vectorStore,
157+
scanner,
158+
fileWatcher,
159+
)
160+
scanner.scanDirectory.mockImplementation(async () => {
161+
orchestrator.stopIndexing()
162+
return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 }
163+
})
164+
// Preserve the original normal-return path: a failed flush is retried by the catch handler.
165+
cacheManager.flush.mockRejectedValueOnce(new Error("flush failed"))
166+
await orchestrator.startIndexing()
167+
expect(cacheManager.flush).toHaveBeenCalledTimes(2)
168+
expect(orchestrator.state).toBe("Standby")
169+
expect(fileWatcher.initialize).not.toHaveBeenCalled()
170+
expect(vectorStore.markIndexingComplete).not.toHaveBeenCalled()
171+
expect(vectorStore.clearCollection).not.toHaveBeenCalled()
172+
expect(cacheManager.clearCacheFile).not.toHaveBeenCalled()
173+
},
174+
)
175+
110176
it("should not call clearCollection() or clear cache when initialize() fails (indexing not started)", async () => {
111177
// Arrange: fail at initialize()
112178
vectorStore.initialize.mockRejectedValue(new Error("Qdrant unreachable"))
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { IDirectoryScanner, IVectorStore } from "./interfaces"
2+
import type { CodeIndexStateManager } from "./state-manager"
3+
import { t } from "../../i18n"
4+
5+
/** Executes scans; cancellation cleanup, watcher startup and completion remain with the orchestrator. */
6+
export class CodeIndexScanExecutor {
7+
constructor(
8+
private readonly workspacePath: string,
9+
private readonly scanner: IDirectoryScanner,
10+
private readonly vectorStore: Pick<IVectorStore, "markIndexingIncomplete">,
11+
private readonly stateManager: Pick<CodeIndexStateManager, "setSystemState" | "reportBlockIndexingProgress">,
12+
) {}
13+
14+
/** Returns false when the scanner returns after cancellation, preserving the owner's normal-return path. */
15+
async runIncrementalScan(signal: AbortSignal): Promise<boolean> {
16+
console.log(
17+
"[CodeIndexOrchestrator] Collection already has indexed data. Running incremental scan for new/changed files...",
18+
)
19+
this.stateManager.setSystemState("Indexing", "Checking for new or modified files...")
20+
const summary = await this.scanWorkspace(signal, "incremental")
21+
if (!summary) return false
22+
23+
// Preserve the existing incremental policy: reported batch errors do not prevent completion.
24+
if (summary.found > 0) {
25+
console.log(
26+
`[CodeIndexOrchestrator] Incremental scan completed: ${summary.indexed} blocks indexed from new/changed files`,
27+
)
28+
} else {
29+
console.log("[CodeIndexOrchestrator] No new or changed files found")
30+
}
31+
return true
32+
}
33+
34+
/** Returns false on returned cancellation; scanner rejections propagate unchanged. */
35+
async runFullScan(signal: AbortSignal): Promise<boolean> {
36+
this.stateManager.setSystemState("Indexing", "Services ready. Starting workspace scan...")
37+
const summary = await this.scanWorkspace(signal, "full")
38+
if (!summary) return false
39+
this.validateFullScan(summary.indexed, summary.found, summary.batchErrors)
40+
return true
41+
}
42+
43+
private async scanWorkspace(
44+
signal: AbortSignal,
45+
kind: "full" | "incremental",
46+
): Promise<{ indexed: number; found: number; batchErrors: Error[] } | undefined> {
47+
await this.vectorStore.markIndexingIncomplete()
48+
let indexed = 0
49+
let found = 0
50+
const batchErrors: Error[] = []
51+
const result = await this.scanner.scanDirectory(
52+
this.workspacePath,
53+
(batchError: Error) => {
54+
console.error(
55+
`[CodeIndexOrchestrator] Error during ${kind === "full" ? "initial" : "incremental"} scan batch: ${batchError.message}`,
56+
batchError,
57+
)
58+
batchErrors.push(batchError)
59+
},
60+
(count: number) => {
61+
indexed += count
62+
this.stateManager.reportBlockIndexingProgress(indexed, found)
63+
},
64+
(count: number) => {
65+
found += count
66+
this.stateManager.reportBlockIndexingProgress(indexed, found)
67+
},
68+
signal,
69+
)
70+
71+
// Cancellation precedes missing-result checks and validation, just as in the orchestrator.
72+
if (signal.aborted) return undefined
73+
if (!result) {
74+
throw new Error(
75+
kind === "full"
76+
? "Scan failed, is scanner initialized?"
77+
: "Incremental scan failed, is scanner initialized?",
78+
)
79+
}
80+
return { indexed, found, batchErrors }
81+
}
82+
83+
private validateFullScan(indexed: number, found: number, batchErrors: Error[]): void {
84+
const firstError = batchErrors[0]
85+
if (indexed === 0 && found > 0) {
86+
throw new Error(
87+
firstError
88+
? `Indexing failed: ${firstError.message}`
89+
: t("embeddings:orchestrator.indexingFailedNoBlocks"),
90+
)
91+
}
92+
if (firstError && (found - indexed) / found > 0.1) {
93+
throw new Error(
94+
`Indexing partially failed: Only ${indexed} of ${found} blocks were indexed. ${firstError.message}`,
95+
)
96+
}
97+
if (firstError && indexed === 0) {
98+
throw new Error(`Indexing failed completely: ${firstError.message}`)
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)