mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
feat(workspaces): keep remote files off the webserver disk (#18403)
Keep native sandbox workspace files independent of the webserver disk. Use encrypted saved versions and Postgres ownership so a replacement server can recover files and continue work. Enable the path by default for new eligible workspaces when durable storage is configured. Preserve explicit opt-outs and existing host workspaces. Include safe snapshot repair, Files access, import, migration and retention. Bound saved-Files resources, preserve imported Git recovery, reject unfinished Git operations, publish immutable concurrent captures, and verify permanent object-version deletion. Validated 471 targeted local controls, build/typechecks, all 40 CI partitions, fresh Greptile 5/5 and security review. The PR records original failed attempts and scoped retries. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
75fa37f30f
commit
5e4cc01073
99 files changed
+9874
-238
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
@@ -371,6 +371,18 @@ describe("WorkspaceFileBrowser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["live", "saved"] as const)("identifies %s workspace files without presenting saved bytes as live", kind => {
|
||||
const response = availableResponse([createItem()]);
|
||||
response.workspace!.source = { kind, savedAt: kind === "saved" ? "2026-10-30T12:00:00.000Z" : null,
|
||||
snapshotId: kind === "saved" ? "f6d9bb44-e06d-45bb-a7c3-72a5ac6f6ef5" : null };
|
||||
useQueryMock.mockReturnValue(ok(response));
|
||||
renderBrowser();
|
||||
const source = container.querySelector('[aria-label="Workspace file source"]');
|
||||
expect(source?.textContent).toContain(kind === "live" ? "Live workspace" : "Saved copy from");
|
||||
expect(source?.querySelector("time")?.getAttribute("datetime") ?? null).toBe(response.workspace!.source.savedAt);
|
||||
if (kind === "saved") expect(source?.textContent).not.toContain("Live workspace");
|
||||
});
|
||||
|
||||
it("groups nested paths into collapsible folder rows instead of repeating full paths", () => {
|
||||
useQueryMock.mockReturnValue(
|
||||
ok(availableResponse([
|
||||
@@ -630,6 +642,66 @@ describe("WorkspaceFileBrowser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("settles a controlled parent with an inline browse-state handler and reports child search changes", async () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
||||
const notifications = vi.fn();
|
||||
type BrowseState = Parameters<NonNullable<ComponentProps<typeof WorkspaceFileBrowser>["onBrowseStateChange"]>>[0];
|
||||
function ControlledBrowser() {
|
||||
const [browse, setBrowse] = useState<BrowseState>({ q: null, folderPath: null, projectId: null, workspaceId: null });
|
||||
return <>
|
||||
<output data-testid="parent-query">{browse.q}</output>
|
||||
<WorkspaceFileBrowser issueId="issue-1" onOpen={vi.fn()} initialQuery={browse.q}
|
||||
onBrowseStateChange={(next) => {
|
||||
notifications(next);
|
||||
// Bound the broken feedback loop so the regression fails without
|
||||
// exhausting React's nested-update limit or hanging the worker.
|
||||
if (notifications.mock.calls.length < 10) setBrowse(next);
|
||||
}} />
|
||||
</>;
|
||||
}
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => root.render(<ControlledBrowser />));
|
||||
await waitForExpectation(() => expect(notifications).toHaveBeenCalledTimes(1));
|
||||
|
||||
const input = container.querySelector("input")!;
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
act(() => {
|
||||
nativeSetter?.call(input, "workspace-proof");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await waitForExpectation(() => {
|
||||
expect(container.querySelector('[data-testid="parent-query"]')?.textContent).toBe("workspace-proof");
|
||||
expect(notifications).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the replaced browse callback for the next actual state change without reporting callback-only changes", async () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
||||
const first = vi.fn(), latest = vi.fn(), onOpen = vi.fn();
|
||||
const { root } = renderBrowser(onOpen, { onBrowseStateChange: first });
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
act(() => root.render(<WorkspaceFileBrowser issueId="issue-1" onOpen={onOpen} onBrowseStateChange={latest} />));
|
||||
expect(latest).not.toHaveBeenCalled();
|
||||
|
||||
const input = container.querySelector("input")!;
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
act(() => {
|
||||
nativeSetter?.call(input, " next-file ");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await waitForExpectation(() => expect(latest).toHaveBeenCalledWith({
|
||||
q: "next-file", folderPath: null, projectId: null, workspaceId: null,
|
||||
}));
|
||||
act(() => {
|
||||
nativeSetter?.call(input, "next-file");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0));
|
||||
expect(latest).toHaveBeenCalledTimes(1);
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the remote-workspace state without file rows", () => {
|
||||
useQueryMock.mockReturnValue(ok(unavailableResponse("remote_workspace")));
|
||||
renderBrowser();
|
||||
|
||||
Reference in new issue
Block a user