mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
Add sandbox work-folder persistence and shared lifecycle
Introduce company-scoped task, agent, user and project files, recoverable trash, object-backed repository checkpoints, and a shared 180-second lifecycle for sandbox execution. Wire file browsing and save status into the owner surfaces. This is an implementation checkpoint; staging acceptance and the complete merge gate remain outstanding.
This commit is contained in:
1 parent
392ab26b1e
commit
a13bb6b395
51 files changed
+176079
-37
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import type { WorkFile, WorkFolderListing, WorkFolderOwner, WorkFolderSyncStatus } from "@paperclipai/shared";
|
||||
import { api, ApiError } from "./client";
|
||||
|
||||
function base(owner: WorkFolderOwner) {
|
||||
return `/companies/${encodeURIComponent(owner.companyId)}/work-folders/${owner.scope}/${encodeURIComponent(owner.ownerId)}`;
|
||||
}
|
||||
export const workFoldersApi = {
|
||||
async list(owner: WorkFolderOwner, trash = false) {
|
||||
const files: WorkFile[] = [];
|
||||
let cursor: 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);
|
||||
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;
|
||||
},
|
||||
upload: (owner: WorkFolderOwner, file: File, filePath: string, operationId: string) =>
|
||||
api.putRaw(`${base(owner)}/content?${new URLSearchParams({ path: filePath })}`, file,
|
||||
{ headers: { "X-File-Content-Type": file.type || "application/octet-stream", "Idempotency-Key": operationId } }),
|
||||
operation: (owner: WorkFolderOwner, operation: { action: "mkdir" | "delete"; path: string } | { action: "restore" | "purge"; fileId: string }, operationId: string) =>
|
||||
api.post(`${base(owner)}/operations`, operation, { headers: { "Idempotency-Key": operationId } }),
|
||||
downloadUrl: (owner: WorkFolderOwner, filePath: string) => `/api${base(owner)}/content?${new URLSearchParams({ path: filePath })}`,
|
||||
async preview(owner: WorkFolderOwner, file: WorkFile) {
|
||||
if (file.byteSize > 8 * 1024 * 1024) throw new Error("Download this file to view it; previews are limited to 8 MB");
|
||||
const response = await fetch(this.downloadUrl(owner, file.path), { credentials: "include", cache: "no-store" });
|
||||
if (!response.ok) throw new ApiError("File preview could not be loaded", response.status, null);
|
||||
const contentLength = Number(response.headers.get("Content-Length"));
|
||||
if (!Number.isFinite(contentLength) || contentLength > 8 * 1024 * 1024) { await response.body?.cancel(); throw new Error("File exceeds preview size limit"); }
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("File preview could not be loaded");
|
||||
const parts: ArrayBuffer[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
length += chunk.value.byteLength;
|
||||
if (length > 8 * 1024 * 1024) throw new Error("File exceeds preview size limit");
|
||||
parts.push(chunk.value.slice().buffer);
|
||||
}
|
||||
} finally { await reader.cancel(); reader.releaseLock(); }
|
||||
const blob = new Blob(parts, { type: file.contentType });
|
||||
const image = /^image\/(png|jpeg|gif|webp|avif)$/.test(file.contentType);
|
||||
const text = file.contentType.startsWith("text/") || /\.(md|txt|json|ya?ml|toml|csv|log|[cm]?[jt]sx?|py|sh|rs|go|css|html|xml)$/i.test(file.path) || file.byteSize === 0;
|
||||
if (!image && !text) throw new Error("Preview is unavailable for this file type; download it to view it");
|
||||
const data = image ? await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader(); reader.onerror = () => reject(new Error("Preview could not be read"));
|
||||
reader.onload = () => resolve(String(reader.result).split(",")[1]!); reader.readAsDataURL(blob);
|
||||
}) : await blob.text();
|
||||
return { resource: { title: file.path.split("/").at(-1)!, displayPath: file.path, contentType: file.contentType,
|
||||
previewKind: image ? "image" as const : "text" as const }, content: { encoding: image ? "base64" as const : "utf8" as const, data } };
|
||||
},
|
||||
sync: (owner: WorkFolderOwner) => api.get<WorkFolderSyncStatus[]>(`${base(owner)}/sync`),
|
||||
refresh: (owner: WorkFolderOwner, runId: string) => api.post(`${base(owner)}/refresh`, { runId }),
|
||||
};
|
||||
@@ -109,7 +109,7 @@ function middleTruncatePath(path: string, maxLen = 80): string {
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
|
||||
function isMarkdownResource(resource: ResolvedWorkspaceResource): boolean {
|
||||
function isMarkdownResource(resource: Pick<ResolvedWorkspaceResource, "title" | "displayPath" | "contentType">): boolean {
|
||||
const contentType = resource.contentType?.toLowerCase() ?? "";
|
||||
if (contentType.includes("markdown")) return true;
|
||||
const path = (resource.displayPath || resource.title).toLowerCase();
|
||||
@@ -231,7 +231,10 @@ export function FileViewerMetadataRow({
|
||||
}
|
||||
|
||||
interface FileContentViewerProps {
|
||||
content: WorkspaceFileContent;
|
||||
content: {
|
||||
resource: Pick<ResolvedWorkspaceResource, "title" | "displayPath" | "contentType" | "previewKind">;
|
||||
content: WorkspaceFileContent["content"];
|
||||
};
|
||||
highlightedLine: number | null;
|
||||
onLoaded?: (summary: string) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, FolderOpen, FolderPlus, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react";
|
||||
import type { WorkFile, WorkFolderOwner } from "@paperclipai/shared";
|
||||
import { workFoldersApi } from "@/api/work-folders";
|
||||
import { FileTree, type FileTreeNode } from "@/components/FileTree";
|
||||
import { FileContentViewer } from "@/components/FileViewerSheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
function tree(files: WorkFile[]) {
|
||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||
for (const file of files) {
|
||||
let parent = root;
|
||||
const parts = file.path.split("/");
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const name = parts[i]!;
|
||||
let node = parent.children.find((child) => child.name === name);
|
||||
if (!node) {
|
||||
node = { name, path: parts.slice(0, i + 1).join("/"), kind: i < parts.length - 1 || file.kind === "directory" ? "dir" : "file", children: [] };
|
||||
parent.children.push(node);
|
||||
}
|
||||
parent = node;
|
||||
}
|
||||
}
|
||||
function sort(nodes: FileTreeNode[]) { nodes.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)); nodes.forEach((node) => sort(node.children)); }
|
||||
sort(root.children); return root.children;
|
||||
}
|
||||
|
||||
export function WorkFolderButton({ owner, label = "Files" }: { owner: WorkFolderOwner; label?: string }) {
|
||||
return <Dialog><DialogTrigger asChild><Button variant="outline" size="sm"><FolderOpen aria-hidden />{label}</Button></DialogTrigger>
|
||||
<DialogContent className="flex max-h-screen flex-col sm:max-w-5xl">
|
||||
<DialogHeader><DialogTitle>{label}</DialogTitle><DialogDescription>
|
||||
{owner.scope === "user" ? "Your private Paperclip files in this company." : `Files shared with this ${owner.scope}'s sandbox runs.`} Changes from running agents are saved every three minutes and when a run ends.
|
||||
</DialogDescription></DialogHeader>
|
||||
<WorkFolderBrowser key={`${owner.companyId}:${owner.scope}:${owner.ownerId}`} owner={owner} />
|
||||
</DialogContent></Dialog>;
|
||||
}
|
||||
|
||||
export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[] }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [trash, setTrash] = useState(false);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(new Set<string>());
|
||||
const [directory, setDirectory] = useState("");
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const directoryId = useId();
|
||||
const key = ["work-folders", owner.companyId, owner.scope, owner.ownerId];
|
||||
const filesQuery = useQuery({ queryKey: [...key, "files", trash], queryFn: () => workFoldersApi.list(owner, trash),
|
||||
enabled: !exampleFiles, refetchInterval: 15_000, retry: false });
|
||||
const syncQuery = useQuery({ queryKey: [...key, "sync"], queryFn: () => workFoldersApi.sync(owner), enabled: !exampleFiles, refetchInterval: 5000, retry: false });
|
||||
const files = exampleFiles ?? filesQuery.data ?? [];
|
||||
const selected = files.find((file) => file.path === selectedPath);
|
||||
const nodes = useMemo(() => tree(files), [files]);
|
||||
const preview = useQuery({ queryKey: [...key, "preview", selected?.path, selected?.sha256],
|
||||
queryFn: () => workFoldersApi.preview(owner, selected!), enabled: !exampleFiles && !trash && selected?.kind === "file", retry: false });
|
||||
const mutation = useMutation({ mutationFn: async (action: { type: "upload"; files: File[] } | { type: "mkdir" } | { type: "delete"; path: string } | { type: "restore" | "purge"; fileId: string } | { type: "refresh" }) => {
|
||||
if (action.type === "upload") for (const file of action.files) await workFoldersApi.upload(owner, file, directory ? `${directory}/${file.name}` : file.name, crypto.randomUUID());
|
||||
else if (action.type === "mkdir") { await workFoldersApi.operation(owner, { action: "mkdir", path: directory }, crypto.randomUUID()); setExpanded((before) => new Set([...before, directory])); }
|
||||
else if (action.type === "delete") await workFoldersApi.operation(owner, { action: "delete", path: action.path }, crypto.randomUUID());
|
||||
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) => {
|
||||
setAnnouncement(action.type === "refresh" ? "Refresh requested for the next safe run boundary." : "Files saved.");
|
||||
await queryClient.invalidateQueries({ queryKey: key });
|
||||
} });
|
||||
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).filter((value): value is string => Boolean(value)).sort().at(-1);
|
||||
const disabled = mutation.isPending || Boolean(exampleFiles);
|
||||
return <div className="flex min-h-0 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={disabled || trash} onClick={() => fileInput.current?.click()}><Upload aria-hidden />Upload</Button>
|
||||
<input ref={fileInput} className="hidden" aria-label="Upload work files" type="file" multiple onChange={(event) => {
|
||||
const chosen = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = "";
|
||||
if (chosen.length) mutation.mutate({ type: "upload", files: chosen });
|
||||
}} />
|
||||
<Button variant={trash ? "secondary" : "outline"} size="sm" onClick={() => { setTrash(!trash); setSelectedPath(null); }}><Trash2 aria-hidden />{trash ? "Back to files" : "Trash"}</Button>
|
||||
<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…" : failed ? "Save failed" : lastSaved ? `Saved ${new Date(lastSaved).toLocaleTimeString()}` : "Saved files"}</span>
|
||||
</div>
|
||||
{!trash && <div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex-1 space-y-1"><Label htmlFor={directoryId}>Folder path</Label><Input id={directoryId} value={directory} onChange={(event) => setDirectory(event.target.value)} placeholder="Root folder" /></div>
|
||||
<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>}
|
||||
<p className="sr-only" aria-live="polite">{announcement}</p>
|
||||
{trash ? <div className="max-h-96 overflow-auto">{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><Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "restore", fileId: file.id })}><RotateCcw aria-hidden />Restore</Button>
|
||||
<AlertDialog><AlertDialogTrigger asChild><Button size="sm" variant="ghost" disabled={disabled}>Purge…</Button></AlertDialogTrigger>
|
||||
<AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Permanently delete {file.path}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This deleted copy and its deleted children will no longer be recoverable.</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => mutation.mutate({ type: "purge", fileId: file.id })}>Permanently delete</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent></AlertDialog>
|
||||
</div>)}</div> : <div className="grid min-h-0 gap-3 md:grid-cols-3">
|
||||
<div className="max-h-96 overflow-auto rounded-md border"><FileTree nodes={nodes} selectedFile={selectedPath} expandedDirs={expanded}
|
||||
onToggleDir={(filePath) => { setSelectedPath(filePath); setExpanded((before) => { const next = new Set(before); if (next.has(filePath)) next.delete(filePath); else next.add(filePath); return next; }); }}
|
||||
onSelectFile={setSelectedPath} loading={!exampleFiles && filesQuery.isLoading} empty={{ title: "No files yet", description: "Upload files here, or create them during a sandbox run." }} ariaLabel={`${owner.scope} files`} /></div>
|
||||
<div className="flex min-h-0 flex-col gap-2 md:col-span-2">
|
||||
{selected && <div className="flex items-center gap-2"><span className="min-w-0 flex-1 truncate text-sm">{selected.path}</span>
|
||||
{selected.kind === "file" && !exampleFiles && <Button asChild size="sm" variant="outline"><a href={workFoldersApi.downloadUrl(owner, selected.path)} download><Download aria-hidden />Download</a></Button>}
|
||||
<Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "delete", path: selected.path })}><Trash2 aria-hidden />Delete</Button></div>}
|
||||
{preview.isLoading ? <p className="text-sm text-muted-foreground">Loading preview…</p> : preview.error ? <p role="alert" className="text-sm text-muted-foreground">{preview.error.message}</p> : preview.data ?
|
||||
<div className="flex max-h-96 min-h-0 flex-col overflow-auto rounded-md border"><FileContentViewer content={preview.data} highlightedLine={null} /></div> : <p className="text-sm text-muted-foreground">Select a file to preview it.</p>}
|
||||
</div>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import { cn } from "../lib/utils";
|
||||
import { describeRunRetryState } from "../lib/runRetryState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { AuditFeed } from "./audit/AuditFeed";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -1373,6 +1374,8 @@ export function AgentDetail() {
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
<WorkFolderButton owner={{ companyId: agent.companyId, scope: "agent", ownerId: agent.id }} label="Agent files" />
|
||||
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
{isPendingApproval && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { WorkFolderBrowser } from "@/components/WorkFolderBrowser";
|
||||
import { ServicesList } from "./apps/app-detail/ServicesPanel";
|
||||
import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip";
|
||||
import type { ComposioServiceRow } from "./apps/composio-services";
|
||||
@@ -482,6 +483,14 @@ export function DesignGuide() {
|
||||
{/* ============================================================ */}
|
||||
{/* COVERAGE */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Work folders">
|
||||
<WorkFolderBrowser owner={{ companyId: "example", scope: "task", ownerId: "example" }} exampleFiles={[
|
||||
{ id: "notes", path: "notes", kind: "directory", byteSize: 0, sha256: null, executable: false, contentType: "application/octet-stream", deletedAt: null, updatedAt: "2026-09-07T00:00:00Z" },
|
||||
{ id: "readme", path: "notes/README.md", kind: "file", byteSize: 24, sha256: null, executable: false, contentType: "text/markdown", deletedAt: null, updatedAt: "2026-09-07T00:00:00Z" },
|
||||
]} />
|
||||
<WorkFolderBrowser owner={{ companyId: "example", scope: "user", ownerId: "example" }} exampleFiles={[]} />
|
||||
</Section>
|
||||
|
||||
<Section title="Component Coverage">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This page should be updated when new UI primitives or app-level patterns ship.
|
||||
|
||||
@@ -171,6 +171,7 @@ import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
||||
import { workModeMetaFor } from "../lib/work-mode-meta";
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
|
||||
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
|
||||
import { IssueOutputSection } from "../components/issue-output/IssueOutputSection";
|
||||
@@ -7469,6 +7470,8 @@ export function IssueDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkFolderButton owner={{ companyId: issue.companyId, scope: "task", ownerId: issue.id }} label="Task files" />
|
||||
|
||||
{taskChatShellEnabled ? null : (
|
||||
<IssueOutputSection
|
||||
workProducts={workProducts}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
|
||||
|
||||
function deriveInitials(name: string) {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
@@ -271,6 +272,7 @@ export function ProfileSettings() {
|
||||
</form>
|
||||
|
||||
<InboxAgentPolicyControl companyId={selectedCompanyId} />
|
||||
{selectedCompanyId && <WorkFolderButton owner={{ companyId: selectedCompanyId, scope: "user", ownerId: sessionQuery.data.user.id }} label="My files" />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import {
|
||||
@@ -821,6 +822,7 @@ export function ProjectDetail() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<WorkFolderButton owner={{ companyId: project.companyId, scope: "project", ownerId: project.id }} label="Project files" />
|
||||
<StarToggle
|
||||
size="button"
|
||||
starred={projectStarred}
|
||||
|
||||
Reference in new issue
Block a user