diff --git a/.changeset/strict-translation-discovery.md b/.changeset/strict-translation-discovery.md new file mode 100644 index 00000000..2f426c80 --- /dev/null +++ b/.changeset/strict-translation-discovery.md @@ -0,0 +1,5 @@ +--- +"openwiki": patch +--- + +fix: surface translation filesystem discovery failures diff --git a/src/agent/translation-middleware.ts b/src/agent/translation-middleware.ts index 574c2801..5d1a3499 100644 --- a/src/agent/translation-middleware.ts +++ b/src/agent/translation-middleware.ts @@ -405,9 +405,22 @@ function extractText(content: MessageContent): string { async function collectMarkdownFiles( backend: BackendProtocolV2, directoryPath: string, + optionalRoot = directoryPath, ): Promise { const result = await backend.ls(directoryPath); - if (result.error) return []; + if (result.error) { + // A repository-mode wiki root may not exist yet, which is a valid no-op. + // Every other listing failure means the file tree is incomplete and must + // abort the pass rather than silently claiming a language switch succeeded. + if ( + directoryPath === optionalRoot && + isMissingDirectoryError(result.error) + ) { + return []; + } + + throw new Error(`Unable to list ${directoryPath}: ${result.error}`); + } const files: string[] = []; for (const entry of result.files ?? []) { @@ -416,7 +429,9 @@ async function collectMarkdownFiles( const entryPath = path.posix.join(directoryPath, name); if (entry.is_dir) { - files.push(...(await collectMarkdownFiles(backend, entryPath))); + files.push( + ...(await collectMarkdownFiles(backend, entryPath, optionalRoot)), + ); continue; } if ( @@ -429,6 +444,10 @@ async function collectMarkdownFiles( return files; } +function isMissingDirectoryError(error: string): boolean { + return /(?:ENOENT|not found)/iu.test(error); +} + /** * Reads a text file from the backend or throws an actionable error. */ diff --git a/test/translation-middleware.test.ts b/test/translation-middleware.test.ts index 533c9da0..8219007a 100644 --- a/test/translation-middleware.test.ts +++ b/test/translation-middleware.test.ts @@ -444,4 +444,52 @@ describe("createWikiTranslationMiddleware beforeAgent", () => { ).resolves.toBeUndefined(); expect(calls).toHaveLength(0); }); + + test("fails a language switch when the wiki root cannot be listed", async () => { + const { backend } = await setup("repository"); + const realLs = backend.ls.bind(backend); + vi.spyOn(backend, "ls").mockImplementation((directoryPath) => + directoryPath === "/openwiki" + ? Promise.resolve({ error: "EACCES: permission denied" }) + : realLs(directoryPath), + ); + const { model, calls } = fakeModel((content) => `T\n${content}`); + + await expect( + runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + ), + ), + ).rejects.toThrow("Unable to list /openwiki: EACCES"); + expect(calls).toHaveLength(0); + }); + + test("fails a language switch when a nested directory cannot be listed", async () => { + const { backend } = await setup("repository"); + await backend.write("/openwiki/accessible.md", "# Accessible\n"); + await backend.write("/openwiki/unreadable/page.md", "# Unreadable\n"); + const realLs = backend.ls.bind(backend); + vi.spyOn(backend, "ls").mockImplementation((directoryPath) => + directoryPath === "/openwiki/unreadable" + ? Promise.resolve({ error: "EIO: input/output error" }) + : realLs(directoryPath), + ); + const { model, calls } = fakeModel((content) => `T\n${content}`); + + await expect( + runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + ), + ), + ).rejects.toThrow("Unable to list /openwiki/unreadable: EIO"); + expect(calls).toHaveLength(0); + }); });