mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Implement workspace file resource listing
This commit is contained in:
1 parent
fdb252c973
commit
0da5769594
10 files changed
+614
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user