mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-02 21:01:51 +00:00
diffs: honor ttl for standalone file artifacts
This commit is contained in:
@@ -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>",
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user