diffs: honor ttl for standalone file artifacts

This commit is contained in:
Gustavo Madeira Santana
2026-03-02 02:05:59 -05:00
parent 44bc5964a4
commit 30e831cb24
4 changed files with 154 additions and 2 deletions

View File

@@ -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: "<html>demo</html>",

View File

@@ -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<void> {
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<void> {
await fs.writeFile(this.standaloneMetaPath(meta.id), JSON.stringify(meta, null, 2), "utf8");
}
private async readStandaloneMeta(id: string): Promise<StandaloneFileMeta | null> {
try {
const raw = await fs.readFile(this.standaloneMetaPath(id), "utf8");
const parsed = JSON.parse(raw) as Partial<StandaloneFileMeta>;
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<void> {
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
}

View File

@@ -175,6 +175,45 @@ describe("diffs tool", () => {
expect((result?.details as Record<string, unknown>).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<string, unknown>).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(

View File

@@ -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,