Implement workspace file resource listing

This commit is contained in:
Dotta committed 2026-06-09 21:35:48 +00:00
1 parent fdb252c973
commit 0da5769594
10 files changed
+614 -3

No files matched your search

+6
View File
@@ -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,
+3
View File
@@ -219,6 +219,9 @@ export type {
export type {
WorkspaceFileContent,
WorkspaceFileContentEncoding,
WorkspaceFileListItem,
WorkspaceFileListMode,
WorkspaceFileListResponse,
WorkspaceFilePreviewKind,
WorkspaceFileRef,
WorkspaceFileResourceKind,
@@ -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<WorkspaceFilePreviewKind, "unsupported">;
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;
}
+3
View File
@@ -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";
@@ -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<typeof workspaceFileResourceQuerySchema>;
export type WorkspaceFileListQuery = z.infer<typeof workspaceFileListQuerySchema>;
@@ -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;
+61 -1
View File
@@ -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<WorkspaceFileListResponse>;
resolve(issueId: string, input: { path: string; workspace?: "auto" | "execution" | "project" | null }): Promise<ResolvedWorkspaceResource>;
readContent(issueId: string, input: { path: string; workspace?: "auto" | "execution" | "project" | null }): Promise<WorkspaceFileContent>;
};
@@ -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);
@@ -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<WorkspaceFileListItem | null> {
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<IssueRow> {
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<WorkspaceFileListResponse> {
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,
};
+22 -2
View File
@@ -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<WorkspaceFileListResponse> {
const search = buildQuery(query);
const suffix = search ? `?${search}` : "";
return api.get<WorkspaceFileListResponse>(
`/issues/${encodeURIComponent(issueId)}/file-resources/list${suffix}`,
);
},
resolve(issueId: string, query: FileResourceQuery): Promise<ResolvedWorkspaceResource> {
return api.get<ResolvedWorkspaceResource>(
`/issues/${encodeURIComponent(issueId)}/file-resources/resolve?${buildQuery(query)}`,
+2
View File
@@ -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) =>