From 0da57695940bc38e1bb178bce712384f057a05d3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 6 Jun 2026 21:18:47 +0000 Subject: [PATCH] Implement workspace file resource listing --- packages/shared/src/index.ts | 6 + packages/shared/src/types/index.ts | 3 + .../src/types/workspace-file-resource.ts | 42 ++ packages/shared/src/validators/index.ts | 3 + .../src/validators/workspace-file-resource.ts | 16 + server/src/__tests__/file-resources.test.ts | 82 ++++ server/src/routes/file-resources.ts | 62 ++- .../src/services/workspace-file-resources.ts | 377 ++++++++++++++++++ ui/src/api/file-resources.ts | 24 +- ui/src/lib/queryKeys.ts | 2 + 10 files changed, 614 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4d63f70e77c..232387af5ef 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -451,6 +451,9 @@ export type { WorkspaceOperationStatus, WorkspaceFileContent, WorkspaceFileContentEncoding, + WorkspaceFileListItem, + WorkspaceFileListMode, + WorkspaceFileListResponse, WorkspaceFilePreviewKind, WorkspaceFileRef, WorkspaceFileResourceKind, @@ -1048,6 +1051,8 @@ export { executionWorkspaceCloseReadinessStateSchema, resolvedWorkspaceResourceSchema, workspaceFileContentSchema, + workspaceFileListModeSchema, + workspaceFileListQuerySchema, workspaceFilePreviewKindSchema, workspaceFileRefSchema, workspaceFileResourceKindSchema, @@ -1082,6 +1087,7 @@ export { type UpdateIssueWorkProduct, type CompanyArtifactsQuery, type UpdateExecutionWorkspace, + type WorkspaceFileListQuery, type WorkspaceFileResourceQuery, type IssueDocumentFormat, type UpsertIssueDocument, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 1ed46f6cee7..9b8ce79754e 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -219,6 +219,9 @@ export type { export type { WorkspaceFileContent, WorkspaceFileContentEncoding, + WorkspaceFileListItem, + WorkspaceFileListMode, + WorkspaceFileListResponse, WorkspaceFilePreviewKind, WorkspaceFileRef, WorkspaceFileResourceKind, diff --git a/packages/shared/src/types/workspace-file-resource.ts b/packages/shared/src/types/workspace-file-resource.ts index 937ae1c2255..bd39769ed4a 100644 --- a/packages/shared/src/types/workspace-file-resource.ts +++ b/packages/shared/src/types/workspace-file-resource.ts @@ -1,5 +1,6 @@ export type WorkspaceFileWorkspaceKind = "execution_workspace" | "project_workspace"; export type WorkspaceFileSelector = "auto" | "execution" | "project"; +export type WorkspaceFileListMode = "all" | "recent" | "changed"; export type WorkspaceFilePreviewKind = "text" | "image" | "pdf" | "unsupported"; export type WorkspaceFileResourceKind = "file" | "remote_resource"; export type WorkspaceFileContentEncoding = "utf8" | "base64"; @@ -41,3 +42,44 @@ export interface WorkspaceFileContent { data: string; }; } + +export interface WorkspaceFileListItem { + kind: "file"; + provider: "local_fs" | "git_worktree" | string; + title: string; + relativePath: string; + displayPath: string; + workspaceLabel: string; + workspaceKind: WorkspaceFileWorkspaceKind; + workspaceId: string; + contentType?: string | null; + byteSize?: number | null; + modifiedAt?: string | null; + previewKind: Exclude; + capabilities: { + preview: true; + download: false; + listChildren: false; + }; +} + +export interface WorkspaceFileListResponse { + kind: "workspace_file_list"; + state: "available" | "unavailable"; + unavailableReason?: string | null; + workspace: { + provider: "local_fs" | "git_worktree" | string; + workspaceLabel: string; + workspaceKind: WorkspaceFileWorkspaceKind; + workspaceId: string; + } | null; + query: { + workspace: WorkspaceFileSelector; + mode: WorkspaceFileListMode; + q: string | null; + limit: number; + }; + items: WorkspaceFileListItem[]; + scannedCount: number; + truncated: boolean; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 076204f1ffa..683950de77d 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -360,6 +360,8 @@ export { export { resolvedWorkspaceResourceSchema, + workspaceFileListModeSchema, + workspaceFileListQuerySchema, workspaceFileContentSchema, workspaceFilePreviewKindSchema, workspaceFileRefSchema, @@ -367,6 +369,7 @@ export { workspaceFileResourceQuerySchema, workspaceFileSelectorSchema, workspaceFileWorkspaceKindSchema, + type WorkspaceFileListQuery, type WorkspaceFileResourceQuery, } from "./workspace-file-resource.js"; diff --git a/packages/shared/src/validators/workspace-file-resource.ts b/packages/shared/src/validators/workspace-file-resource.ts index f525c16ac4f..9e28eda5043 100644 --- a/packages/shared/src/validators/workspace-file-resource.ts +++ b/packages/shared/src/validators/workspace-file-resource.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const workspaceFileWorkspaceKindSchema = z.enum(["execution_workspace", "project_workspace"]); export const workspaceFileSelectorSchema = z.enum(["auto", "execution", "project"]).default("auto"); +export const workspaceFileListModeSchema = z.enum(["all", "recent", "changed"]).default("all"); export const workspaceFilePreviewKindSchema = z.enum(["text", "image", "pdf", "unsupported"]); export const workspaceFileResourceKindSchema = z.enum(["file", "remote_resource"]); @@ -27,6 +28,20 @@ export const workspaceFileResourceQuerySchema = z.object({ workspace: workspaceFileSelectorSchema.optional(), }); +export const workspaceFileListQuerySchema = z.object({ + workspace: workspaceFileSelectorSchema.optional(), + mode: workspaceFileListModeSchema.optional(), + q: z + .string() + .max(200) + .refine((value) => !/[\x00-\x1f\x7f]/.test(value), { + message: "Workspace file search contains an invalid character", + params: { code: "invalid_query" }, + }) + .optional(), + limit: z.coerce.number().int().min(1).max(500).default(100), +}); + export const resolvedWorkspaceResourceSchema = z.object({ kind: workspaceFileResourceKindSchema, provider: z.string().min(1), @@ -55,3 +70,4 @@ export const workspaceFileContentSchema = z.object({ }); export type WorkspaceFileResourceQuery = z.infer; +export type WorkspaceFileListQuery = z.infer; diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index dd40b18340f..ca88bdcbae5 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -206,6 +206,68 @@ describeEmbeddedPostgres("workspace file resources", () => { expect(resolved.capabilities.preview).toBe(true); }); + it("lists and searches safe file candidates from the preferred execution workspace", async () => { + const { root, projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.mkdir(path.join(executionRoot, "src"), { recursive: true }); + await fs.mkdir(path.join(executionRoot, "node_modules", "pkg"), { recursive: true }); + await fs.writeFile(path.join(executionRoot, "src", "app.ts"), "export const ok = true;\n", "utf8"); + await fs.writeFile(path.join(executionRoot, ".env"), "TOKEN=secret\n", "utf8"); + await fs.writeFile(path.join(executionRoot, "node_modules", "pkg", "index.ts"), "export {}\n", "utf8"); + await fs.writeFile(path.join(executionRoot, "blob.bin"), Buffer.from([0, 1, 2, 3])); + await fs.writeFile(path.join(projectRoot, "src-project.ts"), "export const project = true;\n", "utf8"); + await fs.writeFile(path.join(root, "outside.ts"), "secret\n", "utf8"); + await fs.symlink(path.join(root, "outside.ts"), path.join(executionRoot, "escape.ts")); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "auto", q: "src", limit: 10 }); + + expect(res.status).toBe(200); + expect(res.body.state).toBe("available"); + expect(res.body.workspace.workspaceKind).toBe("execution_workspace"); + expect(res.body.items.map((item: { displayPath: string }) => item.displayPath)).toEqual(["src/app.ts"]); + expect(JSON.stringify(res.body)).not.toContain(root); + expect(JSON.stringify(res.body)).not.toContain(".env"); + expect(JSON.stringify(res.body)).not.toContain("node_modules"); + expect(JSON.stringify(res.body)).not.toContain("escape.ts"); + expect(JSON.stringify(res.body)).not.toContain("blob.bin"); + }); + + it("supports recent mode, limit caps, and list activity logging", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "old.ts"), "export const old = true;\n", "utf8"); + await fs.writeFile(path.join(projectRoot, "new.ts"), "export const newer = true;\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", mode: "recent", limit: 1 }); + + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(1); + expect(res.body.truncated).toBe(true); + + const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); + const listRead = rows.find((row) => row.action === "issue.file_resource_list_read"); + expect(listRead).toBeTruthy(); + expect(JSON.stringify(listRead?.details)).not.toContain(projectRoot); + }); + it("rejects control characters in the path without crashing the audit log", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); @@ -335,6 +397,21 @@ describeEmbeddedPostgres("workspace file resources", () => { path: "http://169.254.169.254/latest/meta-data/", workspace: "project", })).rejects.toMatchObject({ status: 422 }); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const listed = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project" }); + expect(listed.status).toBe(200); + expect(listed.body.state).toBe("unavailable"); + expect(listed.body.unavailableReason).toBe("remote_workspace"); + expect(listed.body.items).toEqual([]); }); it("blocks agents and cross-company board users before content reads", async () => { @@ -358,6 +435,8 @@ describeEmbeddedPostgres("workspace file resources", () => { expect((await request(agentApp).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ path: "README.md" })).status).toBe(403); expect((await request(boardApp).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ path: "README.md" })).status).toBe(403); + expect((await request(agentApp).get(`/api/issues/${graph.issueId}/file-resources/list`)).status).toBe(403); + expect((await request(boardApp).get(`/api/issues/${graph.issueId}/file-resources/list`)).status).toBe(403); }); it("logs successful content reads and denied security-relevant attempts", async () => { @@ -395,6 +474,9 @@ describe("file resource route guards", () => { }); const service: WorkspaceFileResourceService = { getIssue: vi.fn(async () => ({ companyId: "company-1" })), + list: vi.fn(async () => { + throw new Error("not used"); + }), resolve: vi.fn(async () => { slowReadStarted?.(); await slowRead; diff --git a/server/src/routes/file-resources.ts b/server/src/routes/file-resources.ts index 847d351ed99..b38c1f391ac 100644 --- a/server/src/routes/file-resources.ts +++ b/server/src/routes/file-resources.ts @@ -2,9 +2,11 @@ import { Router } from "express"; import { ZodError } from "zod"; import type { Db } from "@paperclipai/db"; import { + workspaceFileListQuerySchema, workspaceFileResourceQuerySchema, type ResolvedWorkspaceResource, type WorkspaceFileContent, + type WorkspaceFileListResponse, } from "@paperclipai/shared"; import { HttpError, unprocessable } from "../errors.js"; import { workspaceFileResourceService } from "../services/index.js"; @@ -13,6 +15,12 @@ import { logActivity } from "../services/activity-log.js"; export type WorkspaceFileResourceService = { getIssue(issueId: string): Promise<{ companyId: string }>; + list(issueId: string, input: { + workspace?: "auto" | "execution" | "project" | null; + mode?: "all" | "recent" | "changed" | null; + q?: string | null; + limit?: number | null; + }): Promise; resolve(issueId: string, input: { path: string; workspace?: "auto" | "execution" | "project" | null }): Promise; readContent(issueId: string, input: { path: string; workspace?: "auto" | "execution" | "project" | null }): Promise; }; @@ -85,8 +93,29 @@ function readQuery(query: unknown) { }; } +function readListQuery(query: unknown) { + let parsed; + try { + parsed = workspaceFileListQuerySchema.parse(query); + } catch (error) { + if (error instanceof ZodError) { + const refinement = error.errors.find( + (issue) => (issue as { params?: { code?: string } }).params?.code === "invalid_query", + ); + if (refinement) throw unprocessable(refinement.message, { code: "invalid_query" }); + } + throw error; + } + return { + workspace: parsed.workspace ?? "auto", + mode: parsed.mode ?? "all", + q: parsed.q?.trim() || null, + limit: parsed.limit, + }; +} + function activityDetails(input: { - outcome: "success" | "denied"; + outcome: "success" | "denied" | "unavailable"; workspaceKind?: string | null; workspaceId?: string | null; displayPath?: string | null; @@ -147,6 +176,37 @@ export function fileResourceRoutes(db: Db, opts: { }); } + router.get("/issues/:issueId/file-resources/list", async (req, res) => { + assertBoard(req); + const issue = await svc.getIssue(req.params.issueId); + assertCompanyAccess(req, issue.companyId); + const actor = getActorInfo(req); + const query = readListQuery(req.query); + const release = limiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId)); + try { + const result = await svc.list(req.params.issueId, query); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + action: "issue.file_resource_list_read", + entityType: "issue", + entityId: req.params.issueId, + agentId: actor.agentId, + runId: actor.runId, + details: activityDetails({ + outcome: result.state === "available" ? "success" : "unavailable", + workspaceKind: result.workspace?.workspaceKind ?? null, + workspaceId: result.workspace?.workspaceId ?? null, + denialReason: result.unavailableReason ?? null, + }), + }); + res.json(result); + } finally { + release(); + } + }); + router.get("/issues/:issueId/file-resources/resolve", async (req, res) => { assertBoard(req); const issue = await svc.getIssue(req.params.issueId); diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index 7abee6eef5c..da81401bda9 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -1,11 +1,16 @@ +import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { executionWorkspaces, issues, projectWorkspaces } from "@paperclipai/db"; import type { ResolvedWorkspaceResource, WorkspaceFileContent, + WorkspaceFileListItem, + WorkspaceFileListMode, + WorkspaceFileListResponse, WorkspaceFilePreviewKind, WorkspaceFileSelector, WorkspaceFileWorkspaceKind, @@ -14,8 +19,14 @@ import { HttpError, notFound, unprocessable } from "../errors.js"; export const WORKSPACE_FILE_TEXT_MAX_BYTES = 512 * 1024; export const WORKSPACE_FILE_MEDIA_MAX_BYTES = 10 * 1024 * 1024; +export const WORKSPACE_FILE_LIST_DEFAULT_LIMIT = 100; +export const WORKSPACE_FILE_LIST_MAX_LIMIT = 500; +export const WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES = 5_000; const MAX_RELATIVE_PATH_BYTES = 4096; const TEXT_SNIFF_BYTES = 4096; +const MAX_LIST_DEPTH = 16; +const GIT_STATUS_MAX_BUFFER_BYTES = 1024 * 1024; +const execFileAsync = promisify(execFile); const DENIED_SEGMENTS = new Set([ ".git", @@ -99,6 +110,13 @@ type LocalResolvedFile = { realPath: string; }; +type WorkspaceFileListQueryInput = { + workspace?: WorkspaceFileSelector | null; + mode?: WorkspaceFileListMode | null; + q?: string | null; + limit?: number | null; +}; + function previewCapForKind(kind: WorkspaceFilePreviewKind) { return kind === "image" || kind === "pdf" ? WORKSPACE_FILE_MEDIA_MAX_BYTES : WORKSPACE_FILE_TEXT_MAX_BYTES; } @@ -107,6 +125,11 @@ function relativePathFromReal(rootReal: string, targetReal: string) { return path.relative(rootReal, targetReal).split(path.sep).join(path.posix.sep); } +function isInsideRoot(rootReal: string, targetReal: string) { + const realRelative = path.relative(rootReal, targetReal); + return realRelative === "" || (!realRelative.startsWith("..") && !path.isAbsolute(realRelative)); +} + function normalizeWorkspaceRelativePath(input: string): NormalizedPath { const trimmed = input.trim(); if (!trimmed) throw unprocessable("Workspace file path is required", { code: "invalid_path" }); @@ -154,6 +177,10 @@ function throwIfDenied(segments: string[]) { } } +function shouldPruneSegments(segments: string[]) { + return denyReasonForPathSegments(segments) != null; +} + function contentTypeForPath(filePath: string): string | null { const ext = path.extname(filePath).toLowerCase(); if (IMAGE_CONTENT_TYPES.has(ext)) return IMAGE_CONTENT_TYPES.get(ext) ?? null; @@ -173,6 +200,37 @@ function previewKindForKnownContentType(contentType: string | null): WorkspaceFi return "unsupported"; } +function listItemFromStat(input: { + candidate: WorkspaceCandidate; + relativePath: string; + stat: { size: number; mtime: Date }; +}): WorkspaceFileListItem | null { + const contentType = contentTypeForPath(input.relativePath); + const previewKind = previewKindForKnownContentType(contentType); + if (!previewKind || previewKind === "unsupported") return null; + if (input.stat.size > previewCapForKind(previewKind)) return null; + + return { + kind: "file", + provider: input.candidate.provider, + title: path.posix.basename(input.relativePath), + relativePath: input.relativePath, + displayPath: input.relativePath, + workspaceLabel: input.candidate.label, + workspaceKind: input.candidate.workspaceKind, + workspaceId: input.candidate.workspaceId, + contentType: contentType ?? (previewKind === "text" ? "text/plain; charset=utf-8" : "application/octet-stream"), + byteSize: input.stat.size, + modifiedAt: input.stat.mtime.toISOString(), + previewKind, + capabilities: { + preview: true, + download: false, + listChildren: false, + }, + }; +} + function looksLikeText(buffer: Buffer) { if (buffer.length === 0) return true; let controlBytes = 0; @@ -326,6 +384,237 @@ async function readStableFile(realPath: string, maxBytes: number) { } } +function unavailableFileList(input: { + selector: WorkspaceFileSelector; + mode: WorkspaceFileListMode; + q: string | null; + limit: number; + candidate?: WorkspaceCandidate | null; + reason: string; +}): WorkspaceFileListResponse { + return { + kind: "workspace_file_list", + state: "unavailable", + unavailableReason: input.reason, + workspace: input.candidate + ? { + provider: input.candidate.provider, + workspaceLabel: input.candidate.label, + workspaceKind: input.candidate.workspaceKind, + workspaceId: input.candidate.workspaceId, + } + : null, + query: { + workspace: input.selector, + mode: input.mode, + q: input.q, + limit: input.limit, + }, + items: [], + scannedCount: 0, + truncated: false, + }; +} + +function availableFileList(input: { + selector: WorkspaceFileSelector; + mode: WorkspaceFileListMode; + q: string | null; + limit: number; + candidate: WorkspaceCandidate; + items: WorkspaceFileListItem[]; + scannedCount: number; + truncated: boolean; +}): WorkspaceFileListResponse { + return { + kind: "workspace_file_list", + state: "available", + workspace: { + provider: input.candidate.provider, + workspaceLabel: input.candidate.label, + workspaceKind: input.candidate.workspaceKind, + workspaceId: input.candidate.workspaceId, + }, + query: { + workspace: input.selector, + mode: input.mode, + q: input.q, + limit: input.limit, + }, + items: input.items, + scannedCount: input.scannedCount, + truncated: input.truncated, + }; +} + +function matchesSearch(relativePath: string, normalizedQuery: string | null) { + return !normalizedQuery || relativePath.toLowerCase().includes(normalizedQuery); +} + +async function listLocalFileCandidate(input: { + candidate: WorkspaceCandidate; + rootReal: string; + relativePath: string; + normalizedQuery: string | null; +}): Promise { + let normalized: NormalizedPath; + try { + normalized = normalizeWorkspaceRelativePath(input.relativePath); + } catch { + return null; + } + if (shouldPruneSegments(normalized.segments)) return null; + if (!matchesSearch(normalized.relativePath, input.normalizedQuery)) return null; + + const targetLexical = path.resolve(input.rootReal, ...normalized.segments); + let targetReal: string; + let stat; + try { + targetReal = await fs.realpath(targetLexical); + if (!isInsideRoot(input.rootReal, targetReal)) return null; + const realRelative = relativePathFromReal(input.rootReal, targetReal); + if (shouldPruneSegments(realRelative.split("/").filter(Boolean))) return null; + stat = await fs.lstat(targetLexical); + } catch { + return null; + } + if (stat.isSymbolicLink() || !stat.isFile()) return null; + return listItemFromStat({ candidate: input.candidate, relativePath: normalized.relativePath, stat }); +} + +async function enumerateWorkspaceFiles(input: { + candidate: WorkspaceCandidate; + rootReal: string; + mode: WorkspaceFileListMode; + normalizedQuery: string | null; + limit: number; +}) { + const dirs: Array<{ realPath: string; relativePath: string; depth: number }> = [ + { realPath: input.rootReal, relativePath: "", depth: 0 }, + ]; + const items: WorkspaceFileListItem[] = []; + let scannedCount = 0; + let truncated = false; + let hitScanCap = false; + + while (dirs.length > 0) { + const dir = dirs.shift()!; + let entries; + try { + entries = await fs.readdir(dir.realPath, { withFileTypes: true }); + } catch { + continue; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + scannedCount += 1; + if (scannedCount > WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) { + truncated = true; + hitScanCap = true; + break; + } + if (entry.name.includes("\0") || entry.name.includes("/") || entry.name.includes("\\")) continue; + + const relativePath = dir.relativePath ? `${dir.relativePath}/${entry.name}` : entry.name; + const segments = relativePath.split("/").filter(Boolean); + if (shouldPruneSegments(segments)) continue; + if (entry.isSymbolicLink()) continue; + + if (entry.isDirectory()) { + if (dir.depth >= MAX_LIST_DEPTH) { + truncated = true; + continue; + } + dirs.push({ + realPath: path.join(dir.realPath, entry.name), + relativePath, + depth: dir.depth + 1, + }); + continue; + } + if (!entry.isFile()) continue; + + const item = await listLocalFileCandidate({ + candidate: input.candidate, + rootReal: input.rootReal, + relativePath, + normalizedQuery: input.normalizedQuery, + }); + if (item) items.push(item); + + if (input.mode !== "recent" && items.length >= input.limit) { + truncated = true; + break; + } + } + if (hitScanCap || (truncated && input.mode !== "recent")) break; + } + + if (input.mode === "recent") { + items.sort((a, b) => (b.modifiedAt ?? "").localeCompare(a.modifiedAt ?? "") || a.displayPath.localeCompare(b.displayPath)); + truncated = truncated || items.length > input.limit; + return { items: items.slice(0, input.limit), scannedCount, truncated }; + } + + return { items, scannedCount, truncated }; +} + +function parseGitStatusPaths(stdout: string) { + const tokens = stdout.split("\0").filter(Boolean); + const paths: string[] = []; + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]!; + if (token.length < 4) continue; + const status = token.slice(0, 2); + const filePath = token.slice(3); + if (filePath) paths.push(filePath); + if (status.includes("R") || status.includes("C")) i += 1; + if (paths.length >= WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) break; + } + return paths; +} + +async function listChangedWorkspaceFiles(input: { + candidate: WorkspaceCandidate; + rootReal: string; + normalizedQuery: string | null; + limit: number; +}) { + let stdout: string; + try { + const result = await execFileAsync( + "git", + ["-C", input.rootReal, "status", "--porcelain=v1", "-z", "--untracked-files=all"], + { maxBuffer: GIT_STATUS_MAX_BUFFER_BYTES }, + ); + stdout = result.stdout; + } catch { + return { unavailableReason: "changed_unavailable" as const }; + } + + const paths = parseGitStatusPaths(stdout); + const items: WorkspaceFileListItem[] = []; + let scannedCount = 0; + for (const filePath of paths) { + scannedCount += 1; + const item = await listLocalFileCandidate({ + candidate: input.candidate, + rootReal: input.rootReal, + relativePath: filePath, + normalizedQuery: input.normalizedQuery, + }); + if (item) items.push(item); + if (items.length >= input.limit) break; + } + items.sort((a, b) => a.displayPath.localeCompare(b.displayPath)); + return { + items, + scannedCount, + truncated: paths.length > scannedCount || items.length >= input.limit, + }; +} + export function workspaceFileResourceService(db: Db) { async function getIssue(issueId: string): Promise { const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)).limit(1); @@ -426,6 +715,93 @@ export function workspaceFileResourceService(db: Db) { throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" }); } + async function list(issueId: string, input: WorkspaceFileListQueryInput = {}): Promise { + const issue = await getIssue(issueId); + const selector = input.workspace ?? "auto"; + const mode = input.mode ?? "all"; + const limit = Math.min( + WORKSPACE_FILE_LIST_MAX_LIMIT, + Math.max(1, Math.floor(input.limit ?? WORKSPACE_FILE_LIST_DEFAULT_LIMIT)), + ); + const q = input.q?.trim() || null; + const normalizedQuery = q?.toLowerCase() ?? null; + const candidates = await listCandidates(issue, selector); + if (candidates.length === 0) { + return unavailableFileList({ selector, mode, q, limit, reason: "no_workspace" }); + } + + let firstUnavailable: { candidate: WorkspaceCandidate; reason: string } | null = null; + for (const candidate of candidates) { + if (candidate.remote) { + firstUnavailable ??= { candidate, reason: "remote_workspace" }; + if (selector !== "auto") { + return unavailableFileList({ selector, mode, q, limit, candidate, reason: "remote_workspace" }); + } + continue; + } + + let rootReal: string; + try { + rootReal = await fs.realpath(candidate.rootPath!); + } catch { + firstUnavailable ??= { candidate, reason: "workspace_unavailable" }; + if (selector !== "auto") { + return unavailableFileList({ selector, mode, q, limit, candidate, reason: "workspace_unavailable" }); + } + continue; + } + + if (mode === "changed") { + const changed = await listChangedWorkspaceFiles({ candidate, rootReal, normalizedQuery, limit }); + if ("unavailableReason" in changed) { + const reason = changed.unavailableReason ?? "changed_unavailable"; + firstUnavailable ??= { candidate, reason }; + if (selector !== "auto") { + return unavailableFileList({ selector, mode, q, limit, candidate, reason }); + } + continue; + } + return availableFileList({ + selector, + mode, + q, + limit, + candidate, + items: changed.items, + scannedCount: changed.scannedCount, + truncated: changed.truncated, + }); + } + + const listed = await enumerateWorkspaceFiles({ + candidate, + rootReal, + mode, + normalizedQuery, + limit, + }); + return availableFileList({ + selector, + mode, + q, + limit, + candidate, + items: listed.items, + scannedCount: listed.scannedCount, + truncated: listed.truncated, + }); + } + + return unavailableFileList({ + selector, + mode, + q, + limit, + candidate: firstUnavailable?.candidate ?? null, + reason: firstUnavailable?.reason ?? "no_local_workspace", + }); + } + async function readContent(issueId: string, input: { path: string; workspace?: WorkspaceFileSelector | null; @@ -479,6 +855,7 @@ export function workspaceFileResourceService(db: Db) { return { getIssue, + list, resolve, readContent, }; diff --git a/ui/src/api/file-resources.ts b/ui/src/api/file-resources.ts index 344cc7bb510..a333757e86d 100644 --- a/ui/src/api/file-resources.ts +++ b/ui/src/api/file-resources.ts @@ -1,6 +1,8 @@ import type { ResolvedWorkspaceResource, WorkspaceFileContent, + WorkspaceFileListMode, + WorkspaceFileListResponse, WorkspaceFileSelector, } from "@paperclipai/shared"; import { api } from "./client"; @@ -10,16 +12,34 @@ export interface FileResourceQuery { workspace?: WorkspaceFileSelector; } -function buildQuery(query: FileResourceQuery): string { +export interface FileResourceListQuery { + workspace?: WorkspaceFileSelector; + mode?: WorkspaceFileListMode; + q?: string | null; + limit?: number; +} + +function buildQuery(query: FileResourceQuery | FileResourceListQuery): string { const params = new URLSearchParams(); - params.set("path", query.path); + if ("path" in query) params.set("path", query.path); if (query.workspace && query.workspace !== "auto") { params.set("workspace", query.workspace); } + if ("mode" in query && query.mode && query.mode !== "all") params.set("mode", query.mode); + if ("q" in query && query.q) params.set("q", query.q); + if ("limit" in query && query.limit) params.set("limit", String(query.limit)); return params.toString(); } export const fileResourcesApi = { + list(issueId: string, query: FileResourceListQuery = {}): Promise { + const search = buildQuery(query); + const suffix = search ? `?${search}` : ""; + return api.get( + `/issues/${encodeURIComponent(issueId)}/file-resources/list${suffix}`, + ); + }, + resolve(issueId: string, query: FileResourceQuery): Promise { return api.get( `/issues/${encodeURIComponent(issueId)}/file-resources/resolve?${buildQuery(query)}`, diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 6727870f8cd..bd2cf2a6ad5 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -87,6 +87,8 @@ export const queryKeys = { liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const, activeRun: (issueId: string) => ["issues", "active-run", issueId] as const, workProducts: (issueId: string) => ["issues", "work-products", issueId] as const, + fileResources: (issueId: string, options: { workspace?: string; mode?: string; q?: string | null; limit?: number } = {}) => + ["issues", "file-resources", issueId, "list", options] as const, fileResource: (issueId: string, path: string, workspace: string) => ["issues", "file-resources", issueId, "resolve", path, workspace] as const, fileResourceContent: (issueId: string, path: string, workspace: string) =>