fix(ui): identify failed sandbox saves in the cache inspector

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 12:53:29 -05:00
parent 0c4810ea7b
commit cbdf95d053
8 changed files with 38 additions and 9 deletions

View File

@ -1,5 +1,7 @@
# Sandbox work folders
Shared folders can contain saved files from several sandboxes. The cached-file inspector keeps earlier run failures visible with a **View failed run** link, separately from the last successful save time and direct file-operation errors.
The deployed acceptance entry point is `pnpm test:e2e:work-folders:deployed`.
Set `PAPERCLIP_DEPLOYED_STACK_MANIFEST` to a JSON manifest matching
`tests/runner-e2e/deployed-stack.ts`, `PAPERCLIP_DEPLOYED_STACK_AUTH` to a private

View File

@ -33,6 +33,8 @@ export interface WorkFolderListing {
export interface WorkFolderSyncStatus {
runId: string;
/** Identifies the run to inspect when a shared folder has an unsaved copy. */
agentId?: string;
state: "starting" | "saved" | "saving" | "failed";
lastSavedAt: string | null;
error: string | null;

View File

@ -72,6 +72,8 @@ describe("work folder HTTP ownership and streaming", () => {
folders: { task: null, agent: null, user: folder.id, project: null }, repositories: [] } });
const runApp = app({ type: "agent", source: "agent_jwt", companyId, agentId, runId, onBehalfOfUserId: ownerId });
await request(runApp).get(base).expect(200);
const sync = await request(runApp).get(`${base}/sync`).expect(200);
expect(sync.body).toEqual([expect.objectContaining({ runId, agentId, active: true })]);
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, ownerId)));
await request(runApp).get(base).expect(404);
await db.update(companyMemberships).set({ status: "active" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, ownerId)));

View File

@ -94,7 +94,7 @@ export function workFolderRoutes(db: Db, provider?: StorageProvider) {
if (includedCompletedSave) return [];
includedCompletedSave = true;
}
return [{ runId: row.runId, state: row.state, lastSavedAt: row.lastSavedAt, error: row.error,
return [{ runId: row.runId, agentId: row.manifest.agentId, state: row.state, lastSavedAt: row.lastSavedAt, error: row.error,
refreshRequested: row.refreshRequested, active }];
}));
});

View File

@ -1,17 +1,20 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import type { WorkFolderSyncStatus } from "@paperclipai/shared";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WorkFolderBrowser } from "./WorkFolderBrowser";
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: { issuePrefix: "STG" } }) }));
const owner = { companyId: "company", scope: "task" as const, ownerId: "task" };
const key = ["work-folders", owner.companyId, owner.scope, owner.ownerId];
function render(statuses: WorkFolderSyncStatus[], lastOperationAt: string | null, readOnly = false) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData([...key, "files", false], { files: [], lastOperationAt });
client.setQueryData([...key, "sync"], statuses);
return renderToStaticMarkup(<QueryClientProvider client={client}><TooltipProvider><WorkFolderBrowser owner={owner} readOnly={readOnly} /></TooltipProvider></QueryClientProvider>);
return renderToStaticMarkup(<MemoryRouter initialEntries={["/STG/issues/task"]}><QueryClientProvider client={client}><TooltipProvider><WorkFolderBrowser owner={owner} readOnly={readOnly} /></TooltipProvider></QueryClientProvider></MemoryRouter>);
}
const checkpoint: WorkFolderSyncStatus = { runId: "run", state: "saved", active: false,
lastSavedAt: "2026-09-07T12:00:00.000Z", error: null, refreshRequested: false };
@ -34,11 +37,20 @@ describe("work folder save feedback", () => {
});
it("keeps the last successful checkpoint visible during a failed or pending save", () => {
const failed = render([{ ...checkpoint, state: "failed", error: "Working copy retained" }], null);
expect(failed).toContain('role="status">Save failed');
expect(failed).toContain('role="status">Run save failed');
expect(failed).toContain("Last agent save");
expect(failed).toContain("Working copy retained");
const saving = render([{ ...checkpoint, state: "saving", active: true }], null);
expect(saving).toContain('role="status">Saving…');
expect(saving).toContain("Last agent save");
});
it("identifies the failed run when a shared folder also has a newer successful save", () => {
const html = render([checkpoint, { ...checkpoint, runId: "older-run", agentId: "other-agent", state: "failed",
lastSavedAt: "2026-09-06T12:00:00.000Z", error: "Working copy retained" }], null, true);
expect(html).toContain('role="status">Run save failed');
expect(html).toContain("The files below are saved copies.");
expect(html).toContain('href="/STG/agents/other-agent/runs/older-run"');
expect(html).toContain("View failed run");
expect(html).toContain("Last agent save");
});
});

View File

@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Link } from "@/lib/router";
function tree(files: WorkFile[]) {
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
@ -65,7 +66,7 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
}
else if (action.type === "restore" || action.type === "purge") await workFoldersApi.operation(owner, { action: action.type, fileId: action.fileId }, crypto.randomUUID());
else for (const run of (syncQuery.data ?? []).filter((run) => run.active)) await workFoldersApi.refresh(owner, run.runId);
}, onSuccess: async (_data, action) => {
}, onMutate: () => setAnnouncement(""), onSuccess: async (_data, action) => {
setAnnouncement(action.type === "refresh" ? "Refresh requested for the next safe run boundary." : "Files saved.");
}, onSettled: () => queryClient.invalidateQueries({ queryKey: key }) });
const statuses = syncQuery.data ?? [];
@ -74,7 +75,6 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
const lastSaved = statuses.map((status) => status.lastSavedAt)
.filter((value): value is string => Boolean(value)).sort().at(-1);
const lastOperation = filesQuery.data?.lastOperationAt;
const saveFailed = Boolean(failed) || mutation.isError;
const disabled = mutation.isPending || Boolean(exampleFiles);
return <Tabs value={trash ? "trash" : "files"} onValueChange={(value) => { setTrash(value === "trash"); setSelectedPath(null); setCheckedFiles(new Set()); }} className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
<div className="flex shrink-0 flex-wrap items-center gap-2">
@ -89,7 +89,7 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
</TabsList>
{!trash && canManageTrash && checkedPaths.length > 0 && <Button variant="outline" size="sm" disabled={disabled} onClick={() => mutation.mutate({ type: "deleteSelected", paths: checkedPaths })}><Trash2 aria-hidden />Move {checkedPaths.length} {checkedPaths.length === 1 ? "file" : "files"} to trash</Button>}
{!readOnly && <Button variant="outline" size="sm" disabled={disabled || !statuses.some((status) => status.active)} onClick={() => mutation.mutate({ type: "refresh" })}><RefreshCw aria-hidden />Refresh sandbox</Button>}
<span className="text-xs text-muted-foreground" role="status">{saving ? "Saving…" : saveFailed ? "Save failed" : "Saved"}</span>
<span className="text-xs text-muted-foreground" role="status">{saving ? "Saving…" : mutation.isError ? "Save failed" : failed ? "Run save failed" : "Saved"}</span>
{lastSaved && <span className="text-xs text-muted-foreground">Last agent save {new Date(lastSaved).toLocaleTimeString()}</span>}
{lastOperation && (!lastSaved || lastOperation > lastSaved) && <span className="text-xs text-muted-foreground">Files updated {new Date(lastOperation).toLocaleTimeString()}</span>}
</div>
@ -98,7 +98,10 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
<Button variant="outline" size="sm" disabled={disabled || !directory} onClick={() => mutation.mutate({ type: "mkdir" })}><FolderPlus aria-hidden />Create folder</Button>
</div>}
{[filesQuery.error, syncQuery.error, mutation.error].filter(Boolean).map((error, index) => <p key={index} role="alert" className="text-sm text-destructive">{(error as Error).message}</p>)}
{failed && <p role="alert" className="text-sm text-destructive">{failed.error}</p>}
{failed && <p role="alert" className="text-sm text-destructive">
A sandbox run could not save its files. The files below are saved copies. {failed.error}{" "}
{failed.agentId && <Link className="underline" to={`/agents/${encodeURIComponent(failed.agentId)}/runs/${encodeURIComponent(failed.runId)}`}>View failed run</Link>}
</p>}
<p className="sr-only" aria-live="polite">{announcement}</p>
{trash ? <TabsContent value="trash" className={cn("overflow-auto", fillHeight ? "min-h-0 flex-1" : "max-h-96")}><p className="mb-3 text-sm text-muted-foreground">Deleted cached files are retained here. Restore them to return them to Files.</p>{files.length === 0 ? <p className="text-sm text-muted-foreground">Trash is empty.</p> : files.map((file) => <div key={file.id} className="flex items-center gap-2 border-b py-2">
<span className="min-w-0 flex-1 truncate text-sm">{file.path}</span>{canManageTrash && <Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "restore", fileId: file.id })}><RotateCcw aria-hidden />Restore</Button>}

View File

@ -9,6 +9,7 @@ export type WorkFolderScenario =
| "saved"
| "saving"
| "failed"
| "olderFailure"
| "empty"
| "loading"
| "unavailable"
@ -147,6 +148,7 @@ export function createWorkFolderFixture(
if (action === "sync") {
const status: WorkFolderSyncStatus = {
runId: "run-storybook",
agentId: workFolderOwners.agent.ownerId,
state:
scenario === "saving"
? "saving"
@ -161,7 +163,11 @@ export function createWorkFolderFixture(
? "Storage is unavailable. Your working files are retained in the sandbox; retry when storage recovers."
: null,
};
return Response.json([status]);
return Response.json(scenario === "olderFailure" ? [status, {
...status, runId: "earlier-run-storybook", state: "failed", active: false,
lastSavedAt: "2026-09-07T14:00:00.000Z",
error: "An earlier sandbox could not reach storage. Its working copy was retained.",
}] : [status]);
}
if (action === "refresh") return Response.json({ ok: true });
if (!action) {

View File

@ -32,6 +32,7 @@ const meta = {
"saved",
"saving",
"failed",
"olderFailure",
"empty",
"loading",
"unavailable",
@ -100,6 +101,7 @@ export const EmptyFolder: Story = { args: { scenario: "empty" } };
export const Loading: Story = { args: { scenario: "loading" } };
export const Saving: Story = { args: { scenario: "saving" } };
export const SaveFailed: Story = { args: { scenario: "failed" } };
export const SharedFolderWithEarlierFailure: Story = { args: { scope: "agent", scenario: "olderFailure" } };
export const StorageUnavailable: Story = { args: { scenario: "unavailable" } };
export const UploadFailed: Story = {
args: { scenario: "uploadFailed" },