diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index beedf9b42d..16ace68e16 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -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 diff --git a/packages/shared/src/work-folders.ts b/packages/shared/src/work-folders.ts index 3e1ba33c27..17cfd0e4f4 100644 --- a/packages/shared/src/work-folders.ts +++ b/packages/shared/src/work-folders.ts @@ -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; diff --git a/server/src/__tests__/work-folder-routes.test.ts b/server/src/__tests__/work-folder-routes.test.ts index bd93a75a15..01d1846c50 100644 --- a/server/src/__tests__/work-folder-routes.test.ts +++ b/server/src/__tests__/work-folder-routes.test.ts @@ -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))); diff --git a/server/src/routes/work-folders.ts b/server/src/routes/work-folders.ts index ac119ef99b..c824cf9b67 100644 --- a/server/src/routes/work-folders.ts +++ b/server/src/routes/work-folders.ts @@ -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 }]; })); }); diff --git a/ui/src/components/WorkFolderBrowser.test.tsx b/ui/src/components/WorkFolderBrowser.test.tsx index f3fafa71cf..b3cd2f264c 100644 --- a/ui/src/components/WorkFolderBrowser.test.tsx +++ b/ui/src/components/WorkFolderBrowser.test.tsx @@ -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(); + return renderToStaticMarkup(); } 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"); + }); }); diff --git a/ui/src/components/WorkFolderBrowser.tsx b/ui/src/components/WorkFolderBrowser.tsx index aca1db77e4..9ba569258a 100644 --- a/ui/src/components/WorkFolderBrowser.tsx +++ b/ui/src/components/WorkFolderBrowser.tsx @@ -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 { setTrash(value === "trash"); setSelectedPath(null); setCheckedFiles(new Set()); }} className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
@@ -89,7 +89,7 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH {!trash && canManageTrash && checkedPaths.length > 0 && } {!readOnly && } - {saving ? "Saving…" : saveFailed ? "Save failed" : "Saved"} + {saving ? "Saving…" : mutation.isError ? "Save failed" : failed ? "Run save failed" : "Saved"} {lastSaved && Last agent save {new Date(lastSaved).toLocaleTimeString()}} {lastOperation && (!lastSaved || lastOperation > lastSaved) && Files updated {new Date(lastOperation).toLocaleTimeString()}}
@@ -98,7 +98,10 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH } {[filesQuery.error, syncQuery.error, mutation.error].filter(Boolean).map((error, index) =>

{(error as Error).message}

)} - {failed &&

{failed.error}

} + {failed &&

+ A sandbox run could not save its files. The files below are saved copies. {failed.error}{" "} + {failed.agentId && View failed run} +

}

{announcement}

{trash ?

Deleted cached files are retained here. Restore them to return them to Files.

{files.length === 0 ?

Trash is empty.

: files.map((file) =>
{file.path}{canManageTrash && } diff --git a/ui/storybook/fixtures/workFolders.ts b/ui/storybook/fixtures/workFolders.ts index 5f8d5e183a..4cd3be96e2 100644 --- a/ui/storybook/fixtures/workFolders.ts +++ b/ui/storybook/fixtures/workFolders.ts @@ -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) { diff --git a/ui/storybook/stories/work-folders.stories.tsx b/ui/storybook/stories/work-folders.stories.tsx index 202879be44..128bb9efa3 100644 --- a/ui/storybook/stories/work-folders.stories.tsx +++ b/ui/storybook/stories/work-folders.stories.tsx @@ -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" },