diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index d670c2411c5..09850855ea7 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -98,6 +98,25 @@ describe("DiffArtifactStore", () => { expect(filePath).toContain(rootDir); }); + it("expires standalone file artifacts using ttl metadata", async () => { + vi.useFakeTimers(); + const now = new Date("2026-02-27T16:00:00Z"); + vi.setSystemTime(now); + + const standalone = await store.createStandaloneFileArtifact({ + format: "png", + ttlMs: 1_000, + }); + await fs.writeFile(standalone.filePath, Buffer.from("png")); + + vi.setSystemTime(new Date(now.getTime() + 2_000)); + await store.cleanupExpired(); + + await expect(fs.stat(path.dirname(standalone.filePath))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it("supports image path aliases for backward compatibility", async () => { const artifact = await store.createArtifact({ html: "demo", diff --git a/extensions/diffs/src/store.ts b/extensions/diffs/src/store.ts index 2132555a902..f529096d9dc 100644 --- a/extensions/diffs/src/store.ts +++ b/extensions/diffs/src/store.ts @@ -18,6 +18,19 @@ type CreateArtifactParams = { ttlMs?: number; }; +type CreateStandaloneFileArtifactParams = { + format?: DiffOutputFormat; + ttlMs?: number; +}; + +type StandaloneFileMeta = { + kind: "standalone_file"; + id: string; + createdAt: string; + expiresAt: string; + filePath: string; +}; + export class DiffArtifactStore { private readonly rootDir: string; private readonly logger?: PluginLogger; @@ -115,6 +128,36 @@ export class DiffArtifactStore { return path.join(this.artifactDir(id), `preview.${format}`); } + async createStandaloneFileArtifact( + params: CreateStandaloneFileArtifactParams = {}, + ): Promise<{ id: string; filePath: string; expiresAt: string }> { + await this.ensureRoot(); + + const id = crypto.randomBytes(10).toString("hex"); + const artifactDir = this.artifactDir(id); + const format = params.format ?? "png"; + const filePath = path.join(artifactDir, `preview.${format}`); + const ttlMs = normalizeTtlMs(params.ttlMs); + const createdAt = new Date(); + const expiresAt = new Date(createdAt.getTime() + ttlMs).toISOString(); + const meta: StandaloneFileMeta = { + kind: "standalone_file", + id, + createdAt: createdAt.toISOString(), + expiresAt, + filePath: this.normalizeStoredPath(filePath, "filePath"), + }; + + await fs.mkdir(artifactDir, { recursive: true }); + await this.writeStandaloneMeta(meta); + this.scheduleCleanup(); + return { + id, + filePath: meta.filePath, + expiresAt: meta.expiresAt, + }; + } + allocateImagePath(id: string, format: DiffOutputFormat = "png"): string { return this.allocateFilePath(id, format); } @@ -145,6 +188,14 @@ export class DiffArtifactStore { return; } + const standaloneMeta = await this.readStandaloneMeta(id); + if (standaloneMeta) { + if (isExpired(standaloneMeta)) { + await this.deleteArtifact(id); + } + return; + } + const artifactPath = this.artifactDir(id); const stat = await fs.stat(artifactPath).catch(() => null); if (!stat) { @@ -190,6 +241,10 @@ export class DiffArtifactStore { return path.join(this.artifactDir(id), "meta.json"); } + private standaloneMetaPath(id: string): string { + return path.join(this.artifactDir(id), "file-meta.json"); + } + private async writeMeta(meta: DiffArtifactMeta): Promise { await fs.writeFile(this.metaPath(meta.id), JSON.stringify(meta, null, 2), "utf8"); } @@ -207,6 +262,39 @@ export class DiffArtifactStore { } } + private async writeStandaloneMeta(meta: StandaloneFileMeta): Promise { + await fs.writeFile(this.standaloneMetaPath(meta.id), JSON.stringify(meta, null, 2), "utf8"); + } + + private async readStandaloneMeta(id: string): Promise { + try { + const raw = await fs.readFile(this.standaloneMetaPath(id), "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.kind !== "standalone_file" || + typeof parsed.id !== "string" || + typeof parsed.createdAt !== "string" || + typeof parsed.expiresAt !== "string" || + typeof parsed.filePath !== "string" + ) { + return null; + } + return { + kind: parsed.kind, + id: parsed.id, + createdAt: parsed.createdAt, + expiresAt: parsed.expiresAt, + filePath: this.normalizeStoredPath(parsed.filePath, "filePath"), + }; + } catch (error) { + if (isFileNotFound(error)) { + return null; + } + this.logger?.warn(`Failed to read standalone diff metadata for ${id}: ${String(error)}`); + return null; + } + } + private async deleteArtifact(id: string): Promise { await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {}); } diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 8e2e5b70a68..23b71b1e6eb 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -175,6 +175,45 @@ describe("diffs tool", () => { expect((result?.details as Record).viewerUrl).toBeUndefined(); }); + it("honors ttlSeconds for artifact-only file output", async () => { + vi.useFakeTimers(); + const now = new Date("2026-02-27T16:00:00Z"); + vi.setSystemTime(now); + try { + const screenshotter = { + screenshotHtml: vi.fn(async ({ outputPath }: { outputPath: string }) => { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, Buffer.from("png")); + return outputPath; + }), + }; + + const tool = createDiffsTool({ + api: createApi(), + store, + defaults: DEFAULT_DIFFS_TOOL_DEFAULTS, + screenshotter, + }); + + const result = await tool.execute?.("tool-2c-ttl", { + before: "one\n", + after: "two\n", + mode: "file", + ttlSeconds: 1, + }); + const filePath = (result?.details as Record).filePath as string; + await expect(fs.stat(filePath)).resolves.toBeDefined(); + + vi.setSystemTime(new Date(now.getTime() + 2_000)); + await store.cleanupExpired(); + await expect(fs.stat(filePath)).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + vi.useRealTimers(); + } + }); + it("accepts image* tool options for backward compatibility", async () => { const screenshotter = { screenshotHtml: vi.fn( diff --git a/extensions/diffs/src/tool.ts b/extensions/diffs/src/tool.ts index a76efb3e4f1..92f9f5c778d 100644 --- a/extensions/diffs/src/tool.ts +++ b/extensions/diffs/src/tool.ts @@ -180,8 +180,8 @@ export function createDiffsTool(params: { html: rendered.imageHtml, theme, image, + ttlMs, }); - params.store.scheduleCleanup(); return { content: [ @@ -334,10 +334,16 @@ async function renderDiffArtifactFile(params: { html: string; theme: DiffTheme; image: DiffRenderOptions["image"]; + ttlMs?: number; }): Promise<{ path: string; bytes: number }> { const outputPath = params.artifactId ? params.store.allocateFilePath(params.artifactId, params.image.format) - : params.store.allocateStandaloneFilePath(params.image.format); + : ( + await params.store.createStandaloneFileArtifact({ + format: params.image.format, + ttlMs: params.ttlMs, + }) + ).filePath; await params.screenshotter.screenshotHtml({ html: params.html,