diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 759af6a5c4..9f216853a0 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -7,7 +7,10 @@ Set `PAPERCLIP_DEPLOYED_STACK_MANIFEST` to a JSON manifest matching and `PAPERCLIP_DEPLOYED_STACK_EVIDENCE` to an absolute output directory. The harness never launches a local server. It checks the deployed commit and adapter inventory, exercises scoped file APIs, and starts real sandbox tasks -for every configured profile. Missing profiles fail the inventory gate. +for every configured profile, including cold/warm Git state and real timed saves. +The maintainer-approved matrix is Codex, Claude, OpenCode, and Pi in both runner +generations (11 CLI/ACP profiles). Only Cursor, Gemini, Grok, and Kimi are deferred +for this campaign. Missing required profiles fail the inventory gate. Credentials are not recorded in Playwright reports. These API checks supplement the required browser walkthrough, two real 180-second intervals, and recovery scenarios; passing them alone is not staging acceptance. @@ -94,7 +97,9 @@ URL. Names derive from repository names, with stable workspace-ID suffixes on collisions. Initial clones use existing Git credentials and starting-ref policy; the primary clone also honors the task's configured branch. Warm starts never reset branches, clean edits, or rerun completed setup. Added repositories are -prepared at the next startup; removed bindings retain saved work. +prepared at the next startup; removed bindings retain saved work. Replacement +sandboxes rerun project setup to restore excluded dependencies; an existing +warm checkout keeps both its completed setup and reusable caches. A complete repository checkpoint includes Git objects, refs, HEAD and index, tracked working files, and nonignored untracked files. It excludes dependencies @@ -110,7 +115,9 @@ directories are not supported by this checkpoint format. The task, agent, project, and current-user pages expose a Files dialog using the shared file tree and viewer. It supports uploads, folder creation, previews, -downloads, deletion, trash restore/purge, and sync state with the last save time. +downloads, deletion, trash restore/purge, and sync state with the last agent save +time. Direct file-operation timestamps are labeled separately as “Files updated”; +a delete, restore, or idempotent receipt is not presented as an agent checkpoint. All routes start at `/api/companies/:companyId/work-folders/:scope/:ownerId`: diff --git a/packages/shared/src/work-folders.ts b/packages/shared/src/work-folders.ts index 297f8c5e9e..3e1ba33c27 100644 --- a/packages/shared/src/work-folders.ts +++ b/packages/shared/src/work-folders.ts @@ -27,7 +27,8 @@ export interface WorkFolderListing { owner: WorkFolderOwner; files: WorkFile[]; nextCursor: string | null; - lastSavedAt: string | null; + /** Last accepted file operation, including deletion, restore, and retries. */ + lastOperationAt: string | null; } export interface WorkFolderSyncStatus { diff --git a/server/src/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index 29a660e0a1..3d7313f7a9 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -47,6 +47,7 @@ async function buildSandboxTarget(input: { snapshot: EffectiveExecutionCapabilities | null; supportsSync: boolean; config?: Record; + leaseMetadata?: Record; // Reject the capability resolution to exercise the fail-closed error path. rejectResolution?: boolean; }) { @@ -82,7 +83,7 @@ async function buildSandboxTarget(input: { adapterType: "codex_local", environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } }, leaseId: "lease-1", - leaseMetadata: { remoteCwd: "/work" }, + leaseMetadata: { remoteCwd: "/work", ...input.leaseMetadata }, lease: { id: "lease-1", leasePolicy: "reuse_by_environment", metadata: { remoteCwd: "/work", marker: "preserved" } } as never, environmentRuntime, }); @@ -285,6 +286,16 @@ describe("effective snapshot gates the sync decision", () => { expect(target.remoteCwd).toBe("/work"); }); + it("restores the host-bound sync home when reconstructing a target from its saved lease", async () => { + const { target, environmentRuntime } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: true, + leaseMetadata: { workFolderHome: "/home/daytona" } }); + expect(target.workFolderHome).toBe("/home/daytona"); + await target.runner!.syncOut!([]); + expect(environmentRuntime.syncOut).toHaveBeenLastCalledWith(expect.objectContaining({ + lease: expect.objectContaining({ metadata: expect.objectContaining({ remoteCwd: "/home/daytona" }) }), + })); + }); + it("omits the native sync hooks when the snapshot removes a sync verb", async () => { // The snapshot verified inbound sync but not outbound sync. The runner // exposes the sync hooks both-or-neither, so it keeps the base64 fallback. diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index 01bb0442a9..f49d2d69e7 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -287,11 +287,14 @@ describe("environmentRunOrchestrator — realizeForRun", () => { }); it("uses an in-place authoritative root on the adapter execution target", async () => { - mockResolveEnvironmentExecutionTarget.mockResolvedValue({ + const executionTarget = { kind: "remote", transport: "sandbox", remoteCwd: "/copied/workspace", - }); + workFolderHome: undefined as string | undefined, + runner: { syncOut: async () => executionTarget.workFolderHome }, + }; + mockResolveEnvironmentExecutionTarget.mockResolvedValue(executionTarget); const runtime = makeMockRuntime({ realizeWorkspace: vi.fn().mockResolvedValue({ cwd: "/app", @@ -323,6 +326,10 @@ describe("environmentRunOrchestrator — realizeForRun", () => { outboundRestorePaths: [], }, })); + expect(result.executionTarget).toBe(executionTarget); + if (result.executionTarget?.kind !== "remote" || result.executionTarget.transport !== "sandbox") throw new Error("Expected sandbox target"); + result.executionTarget.workFolderHome = "/home/daytona"; + await expect(result.executionTarget.runner!.syncOut!([])).resolves.toBe("/home/daytona"); }); it("realization failure: runtime.realizeWorkspace throws → EnvironmentRunError with code workspace_realization_failed", async () => { diff --git a/server/src/__tests__/sandbox-work-folders.test.ts b/server/src/__tests__/sandbox-work-folders.test.ts index cc515fe64a..a7d4e25c2b 100644 --- a/server/src/__tests__/sandbox-work-folders.test.ts +++ b/server/src/__tests__/sandbox-work-folders.test.ts @@ -37,11 +37,12 @@ describe("shared sandbox work-folder lifecycle", () => { const source = path.join(root, name); await exec("git", ["init", source]); await fs.writeFile(path.join(source, "tracked"), "initial\n"); + await fs.writeFile(path.join(source, ".gitignore"), "node_modules/\n"); await fs.symlink("tracked", path.join(source, "link")); await exec("git", ["-C", source, "add", "."]); await exec("git", ["-C", source, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"]); await db.insert(projectWorkspaces).values({ companyId, projectId, name, repoUrl: source, sourceType: "git_repo", isPrimary: name === "repo-one", - setupCommand: "printf 'initialized\\n' >> .setup-count" }); + setupCommand: "mkdir -p node_modules/acceptance && printf ready > node_modules/acceptance/installed && printf 'initialized\\n' >> .setup-count" }); } }, 60_000); afterAll(async () => { @@ -87,15 +88,19 @@ describe("shared sandbox work-folder lifecycle", () => { }, 120_000); it("reuses clones and restores saved unpushed work, staged changes, and task files after losing the sandbox", async () => { + const repositoryTaskId = randomUUID(); + await db.insert(issues).values({ id: repositoryTaskId, companyId, projectId, title: "Repository recovery", assigneeAgentId: agentId }); + const task = { taskId: repositoryTaskId }; const home = path.join(root, "sandbox"); const leaseId = randomUUID(); - const first = await prepare(home, leaseId); + const first = await prepare(home, leaseId, leaseId, null, task); expect(first.home).toBe(home); expect(first.manifest.repositories).toHaveLength(2); expect(first.primaryRepo).toBe(path.join(home, "repos/repo-one")); await fs.writeFile(path.join(home, "task/report.md"), "durable task file"); const repo = first.primaryRepo; expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n"); + await fs.writeFile(path.join(repo, "node_modules/acceptance/warm-cache"), "reusable"); await fs.writeFile(path.join(repo, "tracked"), "committed\n"); await exec("git", ["-C", repo, "add", "."]); await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "unpushed"]); @@ -105,13 +110,17 @@ describe("shared sandbox work-folder lifecycle", () => { await fs.writeFile(path.join(repo, "tracked"), "unstaged\n"); await fs.writeFile(path.join(repo, "untracked"), "untracked\n"); await first.stop(); active.splice(active.indexOf(first), 1); - const warm = await prepare(home, randomUUID(), leaseId); + const warm = await prepare(home, randomUUID(), leaseId, null, task); expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n"); + expect(await fs.readFile(path.join(repo, "node_modules/acceptance/warm-cache"), "utf8")).toBe("reusable"); expect(await fs.readFile(path.join(repo, "tracked"), "utf8")).toBe("unstaged\n"); await warm.stop(); active.splice(active.indexOf(warm), 1); await fs.rm(home, { recursive: true }); - const restored = await prepare(path.join(root, "replacement"), randomUUID()); - expect(await fs.readFile(path.join(restored.primaryRepo, ".setup-count"), "utf8")).toBe("initialized\n"); + const replacementId = randomUUID(); + const restored = await prepare(path.join(root, "replacement"), replacementId, replacementId, null, task); + expect(await fs.readFile(path.join(restored.primaryRepo, ".setup-count"), "utf8")).toBe("initialized\ninitialized\n"); + expect(await fs.readFile(path.join(restored.primaryRepo, "node_modules/acceptance/installed"), "utf8")).toBe("ready"); + await expect(fs.stat(path.join(restored.primaryRepo, "node_modules/acceptance/warm-cache"))).rejects.toMatchObject({ code: "ENOENT" }); expect(await fs.readFile(path.join(restored.home, "task/report.md"), "utf8")).toBe("durable task file"); expect((await exec("git", ["-C", restored.primaryRepo, "rev-parse", "HEAD"])).stdout.trim()).toBe(expectedHead); expect((await exec("git", ["-C", restored.primaryRepo, "show", ":tracked"])).stdout).toBe("staged\n"); diff --git a/server/src/__tests__/work-folders.test.ts b/server/src/__tests__/work-folders.test.ts index a51ac82230..d20ef577b7 100644 --- a/server/src/__tests__/work-folders.test.ts +++ b/server/src/__tests__/work-folders.test.ts @@ -57,17 +57,17 @@ 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 () => { + it("retains the last accepted operation time after all files are removed", async () => { const f = await folder(); - expect((await svc.list(f)).lastSavedAt).toBeNull(); + expect((await svc.list(f)).lastOperationAt).toBeNull(); await svc.write(f, { path: "note", body: Buffer.from("saved"), operationId: "write" }); - const saved = (await svc.list(f)).lastSavedAt; + const saved = (await svc.list(f)).lastOperationAt; 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); + expect(Date.parse(listing.lastOperationAt!)).toBeGreaterThanOrEqual(Date.parse(saved!)); + expect((await svc.list(f, { trash: true })).lastOperationAt).toBe(listing.lastOperationAt); }); it("retains a deleted copy after the same path is recreated", async () => { const f = await folder(); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 6cfb0fb557..3c2c450bf3 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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(), lastSavedAt: z.string().nullable() })) }, + responses: { ...workFolderErrors, 200: r.ok(z.object({ id: z.uuid(), owner: workFolderParams, files: z.array(workFileResponse), nextCursor: z.string().nullable(), lastOperationAt: 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() }) }, diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 8166e5f925..281f772aaa 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -372,6 +372,8 @@ export async function resolveEnvironmentExecutionTarget(input: { providerKey: parsed.config.provider, shellCommand, remoteCwd, + ...(typeof input.leaseMetadata?.workFolderHome === "string" + ? { workFolderHome: input.leaseMetadata.workFolderHome } : {}), enableSandboxDuplexBridge, runnerLifecyclePolicy: parsed.config.runnerLifecycleMode === "warm" diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 0c08deb714..7b900b965b 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -543,13 +543,14 @@ export function environmentRunOrchestrator( : [], }; if (executionTarget) { - executionTarget = { - ...executionTarget, + // Runner callbacks retain this host-owned target. Keep its identity so + // the later work-folder binding reaches native file-sync callbacks. + Object.assign(executionTarget, { ...(executionTarget.kind === "remote" && realizationMode === "in_place" ? { remoteCwd: authoritativeRoot } : {}), workspaceRealization: workspaceTargetMetadata, - } as AdapterExecutionTarget; + }); } } catch (err) { throw new EnvironmentRunError( diff --git a/server/src/services/sandbox-work-folders.ts b/server/src/services/sandbox-work-folders.ts index a583e9ac0b..8d007e025b 100644 --- a/server/src/services/sandbox-work-folders.ts +++ b/server/src/services/sandbox-work-folders.ts @@ -227,14 +227,12 @@ export async function prepareSandboxWorkFolders(input: { const root = path.posix.join(paths.repos!, binding.name); const probe = await target.runner!.execute({ command: "git", args: ["-C", root, "rev-parse", "--git-dir"], bypassSession: true, timeoutMs: 10_000 }); const freshCheckout = probe.exitCode !== 0; - let restoredCheckout = false; if (freshCheckout) { // Publish the checkout directory only after every restore object or // clone step completes. An interrupted attempt cannot masquerade as a // reusable checkout merely because it contains a .git directory. const temporary = path.posix.join(staging, `repo-${binding.id}-${randomUUID()}`); const restored = await repositories.restore(binding, temporary, staging); - restoredCheckout = restored; if (!restored) { const auth = await resolveGitAuth(workspace.repoUrl!); const result = await target.runner!.execute({ command: "git", args: [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", @@ -265,9 +263,10 @@ export async function prepareSandboxWorkFolders(input: { } await transport.moveRoot(temporary, root); } - // A complete checkpoint already contains the setup's durable outputs. - // Replacing the sandbox must not repeat completed project setup. - if ((!binding.setupComplete || (freshCheckout && !restoredCheckout)) && workspace.setupCommand) { + // Warm checkouts retain completed setup. A replacement only restores + // durable repository files, so setup must recreate ignored dependencies + // and caches that are deliberately outside the checkpoint guarantee. + if ((!binding.setupComplete || freshCheckout) && workspace.setupCommand) { const setup = await target.runner!.execute({ command: "sh", args: ["-c", workspace.setupCommand], cwd: root, bypassSession: true, timeoutMs: 300_000 }); if (setup.exitCode !== 0 || setup.timedOut) throw new Error(`Repository ${binding.name} setup failed`); } diff --git a/server/src/services/work-folders.ts b/server/src/services/work-folders.ts index 050565385e..5c955c4fa3 100644 --- a/server/src/services/work-folders.ts +++ b/server/src/services/work-folders.ts @@ -44,7 +44,7 @@ export function workFolderService(db: Db, storage: StorageProvider) { 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, + lastOperationAt: saved?.at?.toISOString() ?? null, files: rows.slice(0, limit).map(workFileDto), nextCursor: rows.length > limit ? rows[limit - 1]!.id : null }; } diff --git a/ui/src/api/work-folders.ts b/ui/src/api/work-folders.ts index 51d87f3c3a..df4b35487c 100644 --- a/ui/src/api/work-folders.ts +++ b/ui/src/api/work-folders.ts @@ -8,16 +8,16 @@ export const workFoldersApi = { async list(owner: WorkFolderOwner, trash = false) { const files: WorkFile[] = []; let cursor: string | null = null; - let lastSavedAt: string | null = null; + let lastOperationAt: 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; + if (page.lastOperationAt && (!lastOperationAt || page.lastOperationAt > lastOperationAt)) lastOperationAt = page.lastOperationAt; 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, lastSavedAt }; + return { files, lastOperationAt }; }, upload: (owner: WorkFolderOwner, file: File, filePath: string, operationId: string) => api.putRaw(`${base(owner)}/content?${new URLSearchParams({ path: filePath })}`, file, diff --git a/ui/src/components/WorkFolderBrowser.test.tsx b/ui/src/components/WorkFolderBrowser.test.tsx new file mode 100644 index 0000000000..9919cf4ba1 --- /dev/null +++ b/ui/src/components/WorkFolderBrowser.test.tsx @@ -0,0 +1,38 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { WorkFolderSyncStatus } from "@paperclipai/shared"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { WorkFolderBrowser } from "./WorkFolderBrowser"; + +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) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData([...key, "files", false], { files: [], lastOperationAt }); + client.setQueryData([...key, "sync"], statuses); + return renderToStaticMarkup(); +} +const checkpoint: WorkFolderSyncStatus = { runId: "run", state: "saved", active: false, + lastSavedAt: "2026-09-07T12:00:00.000Z", error: null, refreshRequested: false }; + +describe("work folder save feedback", () => { + it("labels direct file operations separately from agent checkpoints", () => { + const html = render([checkpoint], "2026-09-07T12:01:00.000Z"); + expect(html).toContain('role="status">Saved'); + expect(html).toContain("Last agent save"); + expect(html).toContain("Files updated"); + const manualOnly = render([], "2026-09-07T12:01:00.000Z"); + expect(manualOnly).toContain("Files updated"); + expect(manualOnly).not.toContain("Last agent save"); + }); + 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("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"); + }); +}); diff --git a/ui/src/components/WorkFolderBrowser.tsx b/ui/src/components/WorkFolderBrowser.tsx index b001ce6fef..28761bac8e 100644 --- a/ui/src/components/WorkFolderBrowser.tsx +++ b/ui/src/components/WorkFolderBrowser.tsx @@ -71,8 +71,10 @@ 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), filesQuery.data?.lastSavedAt] + 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
@@ -83,7 +85,9 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw }} /> - {saving ? "Saving…" : failed ? "Save failed" : lastSaved ? `Saved ${new Date(lastSaved).toLocaleTimeString()}` : "Saved files"} + {saving ? "Saving…" : saveFailed ? "Save failed" : "Saved"} + {lastSaved && Last agent save {new Date(lastSaved).toLocaleTimeString()}} + {lastOperation && (!lastSaved || lastOperation > lastSaved) && Files updated {new Date(lastOperation).toLocaleTimeString()}}
{!trash &&
setDirectory(event.target.value)} placeholder="Root folder" />