Fix browser file operations and persist visible save times

Preserve JSON Content-Type when custom idempotency headers are supplied. Expose the latest accepted folder operation timestamp so browser saves remain visible before an agent runs and after all files are deleted.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-07 13:31:59 -05:00
1 parent 23407236b4
commit b4c3bf6961
8 files changed
+46 -6

No files matched your search

+12
View File
@@ -57,6 +57,18 @@ describe("durable work folders", () => {
expect(await textContent(f, "memory.md")).toBe("second");
await expect(svc.write(f, { ...first, body: Buffer.from("different") })).rejects.toMatchObject({ status: 409 });
});
it("retains the last accepted save time after all files are removed", async () => {
const f = await folder();
expect((await svc.list(f)).lastSavedAt).toBeNull();
await svc.write(f, { path: "note", body: Buffer.from("saved"), operationId: "write" });
const saved = (await svc.list(f)).lastSavedAt;
expect(saved).toEqual(expect.any(String));
await svc.remove(f, "note", "delete");
const listing = await svc.list(f);
expect(listing.files).toEqual([]);
expect(Date.parse(listing.lastSavedAt!)).toBeGreaterThanOrEqual(Date.parse(saved!));
expect((await svc.list(f, { trash: true })).lastSavedAt).toBe(listing.lastSavedAt);
});
it("retains a deleted copy after the same path is recreated", async () => {
const f = await folder();
await svc.write(f, { path: "note", body: Buffer.from("deleted"), operationId: "one" });
+1 -1
View File
@@ -5276,7 +5276,7 @@ const workFolderErrors = { 400: r.badRequest, 401: r.unauthorized, 404: r.notFou
registry.registerPath({ method: "get", path: workFolderPath, tags: ["work-folders"], summary: "List scoped sandbox files or recoverable trash",
description: "User files require the owning user or an authorized run acting for that user. Company access alone does not grant access.",
request: { params: workFolderParams, query: z.object({ trash: z.enum(["true", "false"]).optional(), cursor: z.uuid().optional(), limit: z.coerce.number().int().min(1).max(1000).optional() }) },
responses: { ...workFolderErrors, 200: r.ok(z.object({ id: z.uuid(), owner: workFolderParams, files: z.array(workFileResponse), nextCursor: z.string().nullable() })) },
responses: { ...workFolderErrors, 200: r.ok(z.object({ id: z.uuid(), owner: workFolderParams, files: z.array(workFileResponse), nextCursor: z.string().nullable(), lastSavedAt: z.string().nullable() })) },
});
registry.registerPath({ method: "get", path: `${workFolderPath}/content`, tags: ["work-folders"], summary: "Download a scoped file",
request: { params: workFolderParams, query: z.object({ path: z.string() }) },
+4 -1
View File
@@ -5,7 +5,7 @@ import os from "node:os";
import path from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { and, asc, eq, gt, isNull, isNotNull, sql } from "drizzle-orm";
import { and, asc, eq, gt, isNull, isNotNull, max, sql } from "drizzle-orm";
import { workFolders, workFiles, workFileOperations, workFolderObjects, type Db } from "@paperclipai/db";
import { validateWorkFilePath, type WorkFile, type WorkFolderOwner } from "@paperclipai/shared";
import { registerWorkFolderObject } from "./work-folder-garbage.js";
@@ -41,7 +41,10 @@ export function workFolderService(db: Db, storage: StorageProvider) {
const rows = await db.select().from(workFiles).where(and(eq(workFiles.folderId, folder.id),
eq(workFiles.companyId, folder.companyId), options.trash ? isNotNull(workFiles.deletedAt) : isNull(workFiles.deletedAt),
options.cursor ? gt(workFiles.id, options.cursor) : undefined)).orderBy(asc(workFiles.id)).limit(limit + 1);
const [saved] = await db.select({ at: max(workFileOperations.createdAt) }).from(workFileOperations)
.where(and(eq(workFileOperations.companyId, folder.companyId), eq(workFileOperations.folderId, folder.id)));
return { id: folder.id, owner: { companyId: folder.companyId, scope: folder.scope, ownerId: folder.ownerId },
lastSavedAt: saved?.at?.toISOString() ?? null,
files: rows.slice(0, limit).map(workFileDto), nextCursor: rows.length > limit ? rows[limit - 1]!.id : null };
}