From 54b99d50964710196e87a0bb2b5a098c991f13b1 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 20 Mar 2026 16:12:10 -0500 Subject: [PATCH] Search sibling storage roots for attachments Co-Authored-By: Paperclip --- cli/src/__tests__/worktree.test.ts | 32 ++++++++++++++++--- cli/src/commands/worktree.ts | 51 +++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 7a5f49d7..ca48b001 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -196,13 +196,37 @@ describe("worktree helpers", () => { expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); }); - it("treats missing source attachment objects as a non-fatal skip", async () => { + it("falls back across storage roots before skipping a missing attachment object", async () => { + const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" }); + const expected = Buffer.from("image-bytes"); + await expect( + readSourceAttachmentBody( + [ + { + getObject: vi.fn().mockRejectedValue(missingErr), + }, + { + getObject: vi.fn().mockResolvedValue(expected), + }, + ], + "company-1", + "company-1/issues/issue-1/missing.png", + ), + ).resolves.toEqual(expected); + }); + + it("returns null when an attachment object is missing from every lookup storage", async () => { const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" }); await expect( readSourceAttachmentBody( - { - getObject: vi.fn().mockRejectedValue(missingErr), - }, + [ + { + getObject: vi.fn().mockRejectedValue(missingErr), + }, + { + getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })), + }, + ], "company-1", "company-1/issues/issue-1/missing.png", ), diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 28cc85c6..285d57c0 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -360,18 +360,21 @@ export function isMissingStorageObjectError(error: unknown): boolean { } export async function readSourceAttachmentBody( - sourceStorage: Pick, + sourceStorages: Array>, companyId: string, objectKey: string, ): Promise { - try { - return await sourceStorage.getObject(companyId, objectKey); - } catch (error) { - if (isMissingStorageObjectError(error)) { - return null; + for (const sourceStorage of sourceStorages) { + try { + return await sourceStorage.getObject(companyId, objectKey); + } catch (error) { + if (isMissingStorageObjectError(error)) { + continue; + } + throw error; } - throw error; } + return null; } export function resolveWorktreeMakeTargetPath(name: string): string { @@ -1350,6 +1353,29 @@ function resolveCurrentEndpoint(): ResolvedWorktreeEndpoint { }; } +function resolveAttachmentLookupStorages(input: { + sourceEndpoint: ResolvedWorktreeEndpoint; + targetEndpoint: ResolvedWorktreeEndpoint; +}): ConfiguredStorage[] { + const orderedConfigPaths = [ + input.sourceEndpoint.configPath, + resolveCurrentEndpoint().configPath, + input.targetEndpoint.configPath, + ...toMergeSourceChoices(process.cwd()) + .filter((choice) => choice.hasPaperclipConfig) + .map((choice) => path.resolve(choice.worktree, ".paperclip", "config.json")), + ]; + const seen = new Set(); + const storages: ConfiguredStorage[] = []; + for (const configPath of orderedConfigPaths) { + const resolved = path.resolve(configPath); + if (seen.has(resolved) || !existsSync(resolved)) continue; + seen.add(resolved); + storages.push(openConfiguredStorage(resolved)); + } + return storages; +} + async function openConfiguredDb(configPath: string): Promise { const config = readConfig(configPath); if (!config) { @@ -1930,7 +1956,7 @@ async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise