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:
parent
23407236b4
commit
b4c3bf6961
|
|
@ -27,6 +27,7 @@ export interface WorkFolderListing {
|
|||
owner: WorkFolderOwner;
|
||||
files: WorkFile[];
|
||||
nextCursor: string | null;
|
||||
lastSavedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WorkFolderSyncStatus {
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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() }) },
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -51,9 +51,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
applyObservabilityHeaders(headers);
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => null);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <div className="flex min-h-0 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
|
|||
Loading…
Reference in New Issue