Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andChristoph Leiter 89d1e0cd2e fix(server): reuse provider image files across daemon restarts
Integrate persistent provider-image storage with current main while retaining image paragraph boundaries. Replaying identical image bytes must reuse the existing content-addressed file instead of leaving another temporary copy after each restart.

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-10-26 04:18:01 +00:00
Christoph Leiter a6f975f49a fix(server): stop sweeping materialized images by age
The 30-day sweep reintroduced the bug this PR fixes, on a longer fuse.
mtime cannot distinguish an image a transcript still references from an
abandoned one, and reading a transcript never touches the file, so an
image you still look at ages out and disappears.

It was also a regression on hosts whose temp dir is not reaped on a
schedule. There these images survived indefinitely; adding our own
deleter made them mortal.

Nothing deletes them now. Content addressing bounds growth to distinct
images rather than renders. Bounding the directory properly needs to be
reference-aware, against the agent timelines that point into it.
2026-07-30 06:05:50 +02:00
Christoph Leiter ba42900d1a test(server): keep POSIX mode assertions out of the cross-platform suite
Windows ignores mkdir/writeFile modes and reports 0o666, so the directory
and file mode assertions failed on the windows-latest runner. The .posix
suite already covers both and skips on win32.
2026-07-30 06:05:50 +02:00
Christoph LeiterandClaude Opus 4.8 14ec5a8ae9 fix(server): materialize provider images under the paseo home
Provider images were materialized into a per-process directory created
with mkdtemp(os.tmpdir(), "paseo-attachments-"). The OS reaps that
location -- macOS clears /var/folders after roughly three days -- so
every image in a transcript older than the reap window was silently
orphaned: the stored markdown still pointed at a path whose bytes no
longer existed, and nothing could recreate them.

Materialize into resolvePaseoHome()/paseo-attachments/ instead. The
directory keeps its 0700 mode and the files their 0600 mode, and the
basename is unchanged so PROVIDER_IMAGE_MARKDOWN still matches history
written by older builds (the optional "-<suffix>" group covers the old
mkdtemp shape).

A stable directory is no longer self-limiting, so add a best-effort
sweep of entries older than 30 days, run once per process on the first
image materialized. Re-materializing an image rewrites it and refreshes
its mtime, so images still being rendered keep themselves alive and only
genuinely cold ones age out.

This also makes the existing content-hash filename deduplication work
across daemon restarts; previously it only held within a single process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 06:05:50 +02:00
3 changed files with 138 additions and 33 deletions
@@ -1,24 +1,46 @@
import { existsSync, rmSync, statSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { materializeProviderImage } from "./provider-image-output.js";
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
test("writes image attachments under a private temp directory", () => {
const originalPaseoHome = process.env.PASEO_HOME;
let testHome: string | null = null;
beforeEach(async () => {
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-posix-"));
process.env.PASEO_HOME = testHome;
vi.resetModules();
({ materializeProviderImage } = await import("./provider-image-output.js"));
});
afterEach(() => {
if (originalPaseoHome === undefined) {
delete process.env.PASEO_HOME;
} else {
process.env.PASEO_HOME = originalPaseoHome;
}
if (testHome) {
rmSync(testHome, { recursive: true, force: true });
testHome = null;
}
});
test("writes image attachments under a private directory in the paseo home", () => {
const materialized = materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
const attachmentDir = path.dirname(materialized.path);
try {
expect(path.basename(attachmentDir)).toMatch(/^paseo-attachments-/);
expect(existsSync(materialized.path)).toBe(true);
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
} finally {
rmSync(attachmentDir, { recursive: true, force: true });
}
// Exactly "paseo-attachments" — no mkdtemp suffix. The old per-process temp
// directory is what let the OS reap these images out from under the app.
expect(path.basename(attachmentDir)).toBe("paseo-attachments");
expect(attachmentDir).toBe(path.join(testHome as string, "paseo-attachments"));
expect(existsSync(materialized.path)).toBe(true);
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
});
});
@@ -1,17 +1,36 @@
import { existsSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, readdirSync, rmSync, utimesSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import MarkdownIt from "markdown-it";
import { projectTimelineRows } from "../timeline-projection.js";
import { describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
isProviderImageMarkdown,
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "./provider-image-output.js";
const HASH = "a".repeat(64);
// Materialized images live under the paseo home now, so without this every test
// run would read and delete the developer's real provider images.
const originalPaseoHome = process.env.PASEO_HOME;
let testHome: string | null = null;
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
function useTempPaseoHome(): string {
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-"));
process.env.PASEO_HOME = testHome;
return testHome;
}
function attachmentsDir(): string {
if (!testHome) {
throw new Error("useTempPaseoHome() must run before reading the attachments dir.");
}
return path.join(testHome, "paseo-attachments");
}
function renderImageMarkdown(imagePath: string): string {
const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
if (!item || item.type !== "assistant_message") {
@@ -92,27 +111,91 @@ describe("isProviderImageMarkdown", () => {
});
describe("materializeProviderImage", () => {
test("recreates the private temp directory if the cached directory is removed", () => {
beforeEach(async () => {
useTempPaseoHome();
vi.resetModules();
({ materializeProviderImage } = await import("./provider-image-output.js"));
});
afterEach(() => {
if (originalPaseoHome === undefined) {
delete process.env.PASEO_HOME;
} else {
process.env.PASEO_HOME = originalPaseoHome;
}
if (testHome) {
rmSync(testHome, { recursive: true, force: true });
testHome = null;
}
});
test("recreates the private directory if the cached directory is removed", () => {
const first = materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
const firstDir = path.dirname(first.path);
expect(existsSync(first.path)).toBe(true);
rmSync(firstDir, { recursive: true, force: true });
rmSync(path.dirname(first.path), { recursive: true, force: true });
const second = materializeProviderImage({
data: "ZGVmNDU2",
mimeType: "image/png",
});
const secondDir = path.dirname(second.path);
try {
expect(existsSync(second.path)).toBe(true);
} finally {
rmSync(secondDir, { recursive: true, force: true });
expect(existsSync(second.path)).toBe(true);
});
test("stores images in a stable directory under the paseo home", () => {
const result = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
// A fixed directory, not a per-process mkdtemp one — the old shape is what
// let the OS reap these images out from under the app after ~3 days.
expect(path.dirname(result.path)).toBe(attachmentsDir());
expect(path.basename(path.dirname(result.path))).toBe("paseo-attachments");
// Mode assertions live in the .posix suite: Windows ignores mkdir/writeFile
// modes and reports 0o666, so asserting them here fails on that runner.
});
test("is idempotent for the same bytes across daemon restarts", async () => {
const first = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
// Reload the module while retaining the files from the previous instance.
vi.resetModules();
const restarted = await import("./provider-image-output.js");
const second = restarted.materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
expect(second.path).toBe(first.path);
expect(readdirSync(attachmentsDir())).toHaveLength(1);
});
test("emits markdown that isProviderImageMarkdown still recognizes", () => {
const item = renderProviderImageOutputAsAssistantMarkdown(
{ data: "YWJjMTIz", mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
if (!item || item.type !== "assistant_message") {
throw new Error("Expected provider image output to render as assistant markdown.");
}
expect(isProviderImageMarkdown(item.text)).toBe(true);
});
// The whole point of moving out of the temp dir is that nothing deletes these
// behind the user's back. An age-based sweep would reintroduce that on a
// longer fuse, because mtime cannot tell a referenced image from an abandoned
// one and reading a transcript never touches the file.
test("keeps images no matter how old they are", async () => {
const image = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
const longAgoSeconds = Date.now() / 1000 - 365 * 24 * 60 * 60;
utimesSync(image.path, longAgoSeconds, longAgoSeconds);
// Reloading the module must not sweep images referenced by older transcripts.
vi.resetModules();
const restarted = await import("./provider-image-output.js");
restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" });
expect(existsSync(image.path)).toBe(true);
});
});
@@ -1,9 +1,9 @@
import { createHash } from "node:crypto";
import * as fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AgentTimelineItem } from "../agent-sdk-types.js";
import { resolvePaseoHome } from "../../paseo-home.js";
export interface ProviderImageOutput {
path?: string | null;
@@ -17,11 +17,13 @@ export interface MaterializedProviderImage {
path: string;
}
// Keep this basename compatible with provider-image Markdown in existing timelines.
const PROVIDER_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
const PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX = `${PROVIDER_IMAGE_ATTACHMENT_DIR}-`;
const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
// Persist images across process lifetimes. Age-based cleanup would delete files
// still referenced by transcripts; any future cleanup must account for references.
let materializedImageAttachmentDir: string | null = null;
function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
@@ -45,11 +47,11 @@ function getMaterializedImageAttachmentDir(): string {
return materializedImageAttachmentDir;
}
materializedImageAttachmentDir = fsSync.mkdtempSync(
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
);
fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
const dir = path.join(resolvePaseoHome(), PROVIDER_IMAGE_ATTACHMENT_DIR);
fsSync.mkdirSync(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE });
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
materializedImageAttachmentDir = dir;
return dir;
}
function getImageExtension(mimeType: string): string {
@@ -81,9 +83,7 @@ function normalizeImageData(mimeType: string, data: string): { mimeType: string;
return { mimeType, data };
}
// Filenames are a content hash of the bytes so re-materializing the same image
// within a process reuses the existing temp file instead of leaking a fresh one
// for repeated image blocks or history replay.
// Content-addressed filenames deduplicate repeated image bytes across restarts.
export function materializeProviderImage(image: {
data: string;
mimeType: string | null;