From b4c3bf69617092c0c8346f044e2bbb3d4ce07d96 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 7 Sep 2026 13:31:59 -0500 Subject: [PATCH] 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 --- packages/shared/src/work-folders.ts | 1 + server/src/__tests__/work-folders.test.ts | 12 ++++++++++++ server/src/routes/openapi.ts | 2 +- server/src/services/work-folders.ts | 5 ++++- ui/src/api/client.test.ts | 21 +++++++++++++++++++++ ui/src/api/client.ts | 2 +- ui/src/api/work-folders.ts | 4 +++- ui/src/components/WorkFolderBrowser.tsx | 5 +++-- 8 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/work-folders.ts b/packages/shared/src/work-folders.ts index 023a09d217..297f8c5e9e 100644 --- a/packages/shared/src/work-folders.ts +++ b/packages/shared/src/work-folders.ts @@ -27,6 +27,7 @@ export interface WorkFolderListing { owner: WorkFolderOwner; files: WorkFile[]; nextCursor: string | null; + lastSavedAt: string | null; } export interface WorkFolderSyncStatus { diff --git a/server/src/__tests__/work-folders.test.ts b/server/src/__tests__/work-folders.test.ts index 790e5917e2..a51ac82230 100644 --- a/server/src/__tests__/work-folders.test.ts +++ b/server/src/__tests__/work-folders.test.ts @@ -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" }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 955d1cef41..6cfb0fb557 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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() }) }, diff --git a/server/src/services/work-folders.ts b/server/src/services/work-folders.ts index 25d530111a..050565385e 100644 --- a/server/src/services/work-folders.ts +++ b/server/src/services/work-folders.ts @@ -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 }; } diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index 0ed8fc231f..2f4f936b61 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -41,6 +41,27 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("request headers", () => { + it("keeps JSON content type when a mutation supplies an idempotency header", async () => { + fetchMock.mockResolvedValue(jsonResponse({ applied: true })); + await api.post("/work-folder/operations", { action: "delete", path: "note.md" }, + { headers: { "Idempotency-Key": "delete-note" } }); + const request = fetchMock.mock.calls[0]![1] as RequestInit; + const headers = new Headers(request.headers); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("Idempotency-Key")).toBe("delete-note"); + expect(JSON.parse(String(request.body))).toEqual({ action: "delete", path: "note.md" }); + }); + it("preserves a raw upload's explicit content type", async () => { + fetchMock.mockResolvedValue(jsonResponse({ applied: true })); + const body = new Blob(["file bytes"]); + await api.putRaw("/work-folder/content", body, { headers: { "Idempotency-Key": "upload" } }); + const request = fetchMock.mock.calls[0]![1] as RequestInit; + expect(new Headers(request.headers).get("Content-Type")).toBe("application/octet-stream"); + expect(request.body).toBe(body); + }); +}); + describe("tenant-session recovery", () => { it("keeps concurrent failures pending and schedules one top-level reload", async () => { const reload = vi.fn(); diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 88bb9a9cd1..f9413b3669 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -51,9 +51,9 @@ async function request(path: string, init?: RequestInit): Promise { applyObservabilityHeaders(headers); const res = await fetch(`${BASE}${path}`, { - headers, credentials: "include", ...init, + headers, }); if (!res.ok) { const errorBody = await res.json().catch(() => null); diff --git a/ui/src/api/work-folders.ts b/ui/src/api/work-folders.ts index 14613fff31..51d87f3c3a 100644 --- a/ui/src/api/work-folders.ts +++ b/ui/src/api/work-folders.ts @@ -8,14 +8,16 @@ export const workFoldersApi = { async list(owner: WorkFolderOwner, trash = false) { const files: WorkFile[] = []; let cursor: string | null = null; + let lastSavedAt: string | null = null; do { const query = new URLSearchParams({ trash: String(trash), limit: "1000", ...(cursor ? { cursor } : {}) }); const page: WorkFolderListing = await api.get(`${base(owner)}?${query}`); files.push(...page.files); + if (page.lastSavedAt && (!lastSavedAt || page.lastSavedAt > lastSavedAt)) lastSavedAt = page.lastSavedAt; cursor = page.nextCursor; } while (cursor && files.length < 100_000); if (cursor) throw new Error("This folder is too large to display in one view"); - return files; + return { files, lastSavedAt }; }, upload: (owner: WorkFolderOwner, file: File, filePath: string, operationId: string) => api.putRaw(`${base(owner)}/content?${new URLSearchParams({ path: filePath })}`, file, diff --git a/ui/src/components/WorkFolderBrowser.tsx b/ui/src/components/WorkFolderBrowser.tsx index 3081c47b8a..b001ce6fef 100644 --- a/ui/src/components/WorkFolderBrowser.tsx +++ b/ui/src/components/WorkFolderBrowser.tsx @@ -53,7 +53,7 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw const filesQuery = useQuery({ queryKey: [...key, "files", trash], queryFn: () => workFoldersApi.list(owner, trash), enabled: !exampleFiles, refetchInterval: 15_000, retry: false }); const syncQuery = useQuery({ queryKey: [...key, "sync"], queryFn: () => workFoldersApi.sync(owner), enabled: !exampleFiles, refetchInterval: 5000, retry: false }); - const files = exampleFiles ?? filesQuery.data ?? []; + const files = exampleFiles ?? filesQuery.data?.files ?? []; const selected = files.find((file) => file.path === selectedPath); const nodes = useMemo(() => tree(files), [files]); const preview = useQuery({ queryKey: [...key, "preview", selected?.path, selected?.sha256], @@ -71,7 +71,8 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw const statuses = syncQuery.data ?? []; const failed = statuses.find((status) => status.state === "failed"); const saving = mutation.isPending || statuses.some((status) => status.state === "saving"); - const lastSaved = statuses.map((status) => status.lastSavedAt).filter((value): value is string => Boolean(value)).sort().at(-1); + const lastSaved = [...statuses.map((status) => status.lastSavedAt), filesQuery.data?.lastSavedAt] + .filter((value): value is string => Boolean(value)).sort().at(-1); const disabled = mutation.isPending || Boolean(exampleFiles); return