fix(ui): identify failed sandbox saves in the cache inspector
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
0c4810ea7b
commit
cbdf95d053
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue