Implement cross-project file resources

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 21:35:48 +00:00
1 parent f67c02f894
commit 4f42079974
5 files changed
+368 -32

No files matched your search

@@ -7,7 +7,9 @@ export type WorkspaceFileContentEncoding = "utf8" | "base64";
export interface WorkspaceFileRef {
kind: "workspace_file";
issueId: string;
issueId?: string;
projectId?: string;
projectName?: string;
workspaceKind: WorkspaceFileWorkspaceKind;
workspaceId: string;
relativePath: string;
@@ -24,6 +26,8 @@ export interface ResolvedWorkspaceResource {
workspaceLabel: string;
workspaceKind: WorkspaceFileWorkspaceKind;
workspaceId: string;
projectId?: string | null;
projectName?: string | null;
contentType?: string | null;
byteSize?: number | null;
previewKind: WorkspaceFilePreviewKind;
@@ -52,6 +56,8 @@ export interface WorkspaceFileListItem {
workspaceLabel: string;
workspaceKind: WorkspaceFileWorkspaceKind;
workspaceId: string;
projectId?: string | null;
projectName?: string | null;
contentType?: string | null;
byteSize?: number | null;
modifiedAt?: string | null;
@@ -72,6 +78,8 @@ export interface WorkspaceFileListResponse {
workspaceLabel: string;
workspaceKind: WorkspaceFileWorkspaceKind;
workspaceId: string;
projectId?: string | null;
projectName?: string | null;
} | null;
query: {
workspace: WorkspaceFileSelector;
@@ -14,7 +14,9 @@ export const workspaceFileResourceKindSchema = z.enum(["file", "remote_resource"
export const workspaceFileRefSchema = z.object({
kind: z.literal("workspace_file"),
issueId: z.string().uuid(),
issueId: z.string().uuid().optional(),
projectId: z.string().uuid().optional(),
projectName: z.string().min(1).optional(),
workspaceKind: workspaceFileWorkspaceKindSchema,
workspaceId: z.string().uuid(),
relativePath: z.string().min(1),
@@ -24,6 +26,8 @@ export const workspaceFileRefSchema = z.object({
});
export const workspaceFileResourceQuerySchema = z.object({
projectId: z.string().uuid().optional(),
workspaceId: z.string().uuid().optional(),
path: z
.string()
.min(1)
@@ -32,9 +36,15 @@ export const workspaceFileResourceQuerySchema = z.object({
params: { code: "invalid_path" },
}),
workspace: workspaceFileSelectorSchema.optional(),
}).refine((value) => Boolean(value.projectId) === Boolean(value.workspaceId), {
message: "Workspace file target requires both projectId and workspaceId",
path: ["workspaceId"],
params: { code: "invalid_target" },
});
export const workspaceFileListQuerySchema = z.object({
projectId: z.string().uuid().optional(),
workspaceId: z.string().uuid().optional(),
workspace: workspaceFileSelectorSchema.optional(),
mode: workspaceFileListModeSchema.optional(),
q: z
@@ -49,6 +59,10 @@ export const workspaceFileListQuerySchema = z.object({
})
.optional(),
limit: z.coerce.number().int().min(1).max(100).default(25),
}).refine((value) => Boolean(value.projectId) === Boolean(value.workspaceId), {
message: "Workspace file target requires both projectId and workspaceId",
path: ["workspaceId"],
params: { code: "invalid_target" },
});
export const resolvedWorkspaceResourceSchema = z.object({
@@ -59,6 +73,8 @@ export const resolvedWorkspaceResourceSchema = z.object({
workspaceLabel: z.string().min(1),
workspaceKind: workspaceFileWorkspaceKindSchema,
workspaceId: z.string().uuid(),
projectId: z.string().uuid().nullable().optional(),
projectName: z.string().min(1).nullable().optional(),
contentType: z.string().nullable().optional(),
byteSize: z.number().int().nonnegative().nullable().optional(),
previewKind: workspaceFilePreviewKindSchema,
+142 -1
View File
@@ -32,21 +32,30 @@ type TestGraph = {
issueId: string;
otherIssueId: string;
projectId: string;
projectWorkspaceId: string;
targetProjectId: string;
targetProjectWorkspaceId: string;
otherProjectId: string;
otherProjectWorkspaceId: string;
workspaceRoot: string;
targetWorkspaceRoot: string;
executionRoot: string;
};
async function makeWorkspace() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-file-resources-"));
const projectRoot = path.join(root, "project");
const targetProjectRoot = path.join(root, "target-project");
const executionRoot = path.join(root, "execution");
await fs.mkdir(projectRoot, { recursive: true });
await fs.mkdir(targetProjectRoot, { recursive: true });
await fs.mkdir(executionRoot, { recursive: true });
return { root, projectRoot, executionRoot };
return { root, projectRoot, targetProjectRoot, executionRoot };
}
async function seedGraph(db: Db, input: {
projectRoot: string;
targetProjectRoot?: string;
executionRoot?: string | null;
projectSourceType?: string;
}): Promise<TestGraph> {
@@ -56,8 +65,10 @@ async function seedGraph(db: Db, input: {
const goalId = crypto.randomUUID();
const otherGoalId = crypto.randomUUID();
const projectId = crypto.randomUUID();
const targetProjectId = crypto.randomUUID();
const otherProjectId = crypto.randomUUID();
const projectWorkspaceId = crypto.randomUUID();
const targetProjectWorkspaceId = crypto.randomUUID();
const otherProjectWorkspaceId = crypto.randomUUID();
const executionWorkspaceId = crypto.randomUUID();
const issueId = crypto.randomUUID();
@@ -73,6 +84,7 @@ async function seedGraph(db: Db, input: {
]);
await db.insert(projects).values([
{ id: projectId, companyId, goalId, name: "Project", status: "in_progress" },
{ id: targetProjectId, companyId, goalId, name: "Target project", status: "in_progress" },
{ id: otherProjectId, companyId: otherCompanyId, goalId: otherGoalId, name: "Other project", status: "in_progress" },
]);
await db.insert(projectWorkspaces).values([
@@ -94,6 +106,15 @@ async function seedGraph(db: Db, input: {
cwd: input.projectRoot,
isPrimary: true,
},
{
id: targetProjectWorkspaceId,
companyId,
projectId: targetProjectId,
name: "Target workspace",
sourceType: "local_path",
cwd: input.targetProjectRoot ?? input.projectRoot,
isPrimary: true,
},
]);
await db.insert(issues).values([
{
@@ -139,7 +160,13 @@ async function seedGraph(db: Db, input: {
issueId,
otherIssueId,
projectId,
projectWorkspaceId,
targetProjectId,
targetProjectWorkspaceId,
otherProjectId,
otherProjectWorkspaceId,
workspaceRoot: input.projectRoot,
targetWorkspaceRoot: input.targetProjectRoot ?? input.projectRoot,
executionRoot: input.executionRoot ?? input.projectRoot,
};
}
@@ -215,6 +242,120 @@ describeEmbeddedPostgres("workspace file resources", () => {
expect(resolved.capabilities.preview).toBe(true);
});
it("resolves explicit same-company cross-project workspace files and logs target details", async () => {
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
await fs.mkdir(path.join(targetProjectRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(targetProjectRoot, "docs", "README.md"), "# Target project\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/content`)
.query({
projectId: graph.targetProjectId,
workspaceId: graph.targetProjectWorkspaceId,
path: "docs/README.md",
});
expect(res.status).toBe(200);
expect(res.body.resource).toMatchObject({
workspaceKind: "project_workspace",
workspaceId: graph.targetProjectWorkspaceId,
projectId: graph.targetProjectId,
projectName: "Target project",
displayPath: "Target project / docs/README.md",
});
expect(res.body.content.data).toContain("# Target project");
expect(JSON.stringify(res.body)).not.toContain(root);
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId));
const read = rows.find((row) => row.action === "issue.file_resource_content_read");
expect(read?.details).toMatchObject({
outcome: "success",
workspaceId: graph.targetProjectWorkspaceId,
projectId: graph.targetProjectId,
projectName: "Target project",
displayPath: "Target project / docs/README.md",
});
expect(JSON.stringify(read?.details)).not.toContain(targetProjectRoot);
});
it("denies explicit cross-company project workspaces", async () => {
const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
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/resolve`)
.query({
projectId: graph.otherProjectId,
workspaceId: graph.otherProjectWorkspaceId,
path: "README.md",
});
expect(res.status).toBe(403);
expect(res.body?.details?.code).toBe("cross_company_workspace");
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId));
const denied = rows.find((row) => row.action === "issue.file_resource_resolve_denied");
expect(denied?.details).toMatchObject({
outcome: "denied",
projectId: graph.otherProjectId,
workspaceId: graph.otherProjectWorkspaceId,
denialReason: "cross_company_workspace",
});
});
it("rejects explicit project/workspace mismatches", async () => {
const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
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/resolve`)
.query({
projectId: graph.targetProjectId,
workspaceId: graph.projectWorkspaceId,
path: "README.md",
});
expect(res.status).toBe(422);
expect(res.body?.details?.code).toBe("workspace_project_mismatch");
});
it("blocks symlink escapes from explicit cross-project workspaces", async () => {
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
await fs.writeFile(path.join(root, "outside-target.txt"), "secret\n", "utf8");
await fs.symlink(path.join(root, "outside-target.txt"), path.join(targetProjectRoot, "escape.txt"));
await expect(workspaceFileResourceService(db).readContent(graph.issueId, {
projectId: graph.targetProjectId,
workspaceId: graph.targetProjectWorkspaceId,
path: "escape.txt",
})).rejects.toMatchObject({
status: 403,
details: { code: "outside_workspace" },
});
});
it("resolves and reads video workspace files as base64 previews", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
+108 -13
View File
@@ -17,18 +17,20 @@ export type WorkspaceFileResourceService = {
getIssue(issueId: string): Promise<{ companyId: string }>;
list(issueId: string, input: {
workspace?: "auto" | "execution" | "project" | null;
projectId?: string | null;
workspaceId?: string | null;
mode?: "all" | "recent" | "changed" | null;
q?: string | null;
limit?: number | null;
}, opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> }): Promise<WorkspaceFileListResponse>;
resolve(
issueId: string,
input: { path: string; workspace?: "auto" | "execution" | "project" | null },
input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null },
opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
): Promise<ResolvedWorkspaceResource>;
readContent(
issueId: string,
input: { path: string; workspace?: "auto" | "execution" | "project" | null },
input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null },
opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
): Promise<WorkspaceFileContent>;
};
@@ -106,16 +108,20 @@ function readQuery(query: unknown) {
parsed = workspaceFileResourceQuerySchema.parse(query);
} catch (error) {
if (error instanceof ZodError) {
const refinement = error.errors.find(
(issue) => (issue as { params?: { code?: string } }).params?.code === "invalid_path",
);
if (refinement) throw unprocessable(refinement.message, { code: "invalid_path" });
const refinement = error.errors.find((issue) => {
const code = (issue as { params?: { code?: string } }).params?.code;
return code === "invalid_path" || code === "invalid_target";
});
const code = (refinement as { params?: { code?: string } } | undefined)?.params?.code;
if (refinement) throw unprocessable(refinement.message, { code: code ?? "invalid_path" });
}
throw error;
}
return {
path: parsed.path,
workspace: parsed.workspace ?? "auto",
projectId: parsed.projectId ?? null,
workspaceId: parsed.workspaceId ?? null,
};
}
@@ -125,16 +131,20 @@ function readListQuery(query: unknown) {
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" });
const refinement = error.errors.find((issue) => {
const code = (issue as { params?: { code?: string } }).params?.code;
return code === "invalid_query" || code === "invalid_target";
});
const code = (refinement as { params?: { code?: string } } | undefined)?.params?.code;
if (refinement) throw unprocessable(refinement.message, { code: code ?? "invalid_query" });
throw unprocessable("Workspace file list query is invalid", { code: "invalid_query" });
}
throw error;
}
return {
workspace: parsed.workspace ?? "auto",
projectId: parsed.projectId ?? null,
workspaceId: parsed.workspaceId ?? null,
mode: parsed.mode ?? "all",
q: parsed.q?.trim() || null,
limit: parsed.limit,
@@ -145,6 +155,8 @@ function activityDetails(input: {
outcome: "success" | "denied" | "unavailable";
workspaceKind?: string | null;
workspaceId?: string | null;
projectId?: string | null;
projectName?: string | null;
displayPath?: string | null;
denialReason?: string | null;
byteSize?: number | null;
@@ -154,6 +166,8 @@ function activityDetails(input: {
outcome: input.outcome,
...(input.workspaceKind ? { workspaceKind: input.workspaceKind } : {}),
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.projectName ? { projectName: input.projectName } : {}),
...(input.displayPath ? { displayPath: input.displayPath } : {}),
...(input.denialReason ? { denialReason: input.denialReason } : {}),
...(typeof input.byteSize === "number" ? { byteSize: input.byteSize } : {}),
@@ -167,6 +181,8 @@ function listActivityDetails(input: {
mode: "all" | "recent" | "changed";
workspaceKind?: string | null;
workspaceId?: string | null;
projectId?: string | null;
projectName?: string | null;
resultCount?: number | null;
scannedCount?: number | null;
truncated?: boolean | null;
@@ -178,6 +194,8 @@ function listActivityDetails(input: {
mode: input.mode,
...(input.workspaceKind ? { workspaceKind: input.workspaceKind } : {}),
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.projectName ? { projectName: input.projectName } : {}),
...(typeof input.resultCount === "number" ? { resultCount: input.resultCount } : {}),
...(typeof input.scannedCount === "number" ? { scannedCount: input.scannedCount } : {}),
...(typeof input.truncated === "boolean" ? { truncated: input.truncated } : {}),
@@ -200,10 +218,21 @@ function safeListAuditQuery(query: unknown): {
return { workspace, mode };
}
function safeAuditTarget(query: unknown): { projectId: string | null; workspaceId: string | null } {
if (!query || typeof query !== "object") return { projectId: null, workspaceId: null };
const record = query as Record<string, unknown>;
return {
projectId: typeof record.projectId === "string" ? record.projectId : null,
workspaceId: typeof record.workspaceId === "string" ? record.workspaceId : null,
};
}
function safeAuditDisplayPath(query: unknown): string {
if (!query || typeof query !== "object") return "";
const path = (query as Record<string, unknown>).path;
return typeof path === "string" ? path : "";
if (typeof path !== "string") return "";
if (/[\x00-\x1f\x7f]/.test(path)) return "";
return path;
}
function denialReasonFromError(error: unknown) {
@@ -231,6 +260,8 @@ export function fileResourceRoutes(db: Db, opts: {
actor: ReturnType<typeof getActorInfo>;
issueId: string;
displayPath: string;
projectId?: string | null;
workspaceId?: string | null;
error: unknown;
action?: "issue.file_resource_content_denied" | "issue.file_resource_resolve_denied";
}) {
@@ -246,6 +277,8 @@ export function fileResourceRoutes(db: Db, opts: {
details: activityDetails({
outcome: "denied",
displayPath: input.displayPath,
projectId: input.projectId,
workspaceId: input.workspaceId,
denialReason: denialReasonFromError(input.error),
}),
});
@@ -256,6 +289,7 @@ export function fileResourceRoutes(db: Db, opts: {
actor: ReturnType<typeof getActorInfo>;
issueId: string;
query: { workspace: "auto" | "execution" | "project"; mode: "all" | "recent" | "changed" };
target?: { projectId: string | null; workspaceId: string | null };
error: unknown;
}) {
await logActivity(db, {
@@ -271,6 +305,8 @@ export function fileResourceRoutes(db: Db, opts: {
outcome: "denied",
workspaceSelector: input.query.workspace,
mode: input.query.mode,
projectId: input.target?.projectId ?? null,
workspaceId: input.target?.workspaceId ?? null,
denialReason: denialReasonFromError(input.error),
}),
});
@@ -278,6 +314,7 @@ export function fileResourceRoutes(db: Db, opts: {
router.get("/issues/:issueId/file-resources/list", async (req, res) => {
const auditQuery = safeListAuditQuery(req.query);
const auditTarget = safeAuditTarget(req.query);
try {
assertBoard(req);
} catch (error) {
@@ -287,6 +324,7 @@ export function fileResourceRoutes(db: Db, opts: {
actor: getActorInfo(req),
issueId: req.params.issueId,
query: auditQuery,
target: auditTarget,
error,
});
}
@@ -302,6 +340,7 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
query: auditQuery,
target: auditTarget,
error,
});
throw error;
@@ -316,6 +355,7 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
query: auditQuery,
target: auditTarget,
error,
});
throw error;
@@ -330,6 +370,7 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
query,
target: { projectId: query.projectId, workspaceId: query.workspaceId },
error,
});
throw error;
@@ -352,6 +393,8 @@ export function fileResourceRoutes(db: Db, opts: {
mode: result.query.mode,
workspaceKind: result.workspace?.workspaceKind ?? null,
workspaceId: result.workspace?.workspaceId ?? null,
projectId: result.workspace?.projectId ?? null,
projectName: result.workspace?.projectName ?? null,
resultCount: result.items.length,
scannedCount: result.scannedCount,
truncated: result.truncated,
@@ -365,6 +408,7 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
query,
target: { projectId: query.projectId, workspaceId: query.workspaceId },
error,
});
throw error;
@@ -374,6 +418,7 @@ export function fileResourceRoutes(db: Db, opts: {
});
router.get("/issues/:issueId/file-resources/resolve", async (req, res) => {
const auditTarget = safeAuditTarget(req.query);
try {
assertBoard(req);
} catch (error) {
@@ -383,6 +428,8 @@ export function fileResourceRoutes(db: Db, opts: {
actor: getActorInfo(req),
issueId: req.params.issueId,
displayPath: safeAuditDisplayPath(req.query),
projectId: auditTarget.projectId,
workspaceId: auditTarget.workspaceId,
error,
action: "issue.file_resource_resolve_denied",
});
@@ -399,12 +446,29 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
displayPath: safeAuditDisplayPath(req.query),
projectId: auditTarget.projectId,
workspaceId: auditTarget.workspaceId,
error,
action: "issue.file_resource_resolve_denied",
});
throw error;
}
let query: ReturnType<typeof readQuery>;
try {
query = readQuery(req.query);
} catch (error) {
await logDeniedAttempt({
companyId: issue.companyId,
actor,
issueId: req.params.issueId,
displayPath: safeAuditDisplayPath(req.query),
projectId: auditTarget.projectId,
workspaceId: auditTarget.workspaceId,
error,
action: "issue.file_resource_resolve_denied",
});
throw error;
}
const query = readQuery(req.query);
let release: (() => void) | null = null;
try {
release = limiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId));
@@ -414,6 +478,8 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
displayPath: query.path,
projectId: query.projectId,
workspaceId: query.workspaceId,
error,
action: "issue.file_resource_resolve_denied",
});
@@ -434,6 +500,8 @@ export function fileResourceRoutes(db: Db, opts: {
outcome: "success",
workspaceKind: result.workspaceKind,
workspaceId: result.workspaceId,
projectId: result.projectId ?? null,
projectName: result.projectName ?? null,
displayPath: result.displayPath,
byteSize: result.byteSize ?? null,
contentType: result.contentType ?? null,
@@ -447,6 +515,8 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
displayPath: query.path,
projectId: query.projectId,
workspaceId: query.workspaceId,
error,
action: "issue.file_resource_resolve_denied",
});
@@ -457,6 +527,7 @@ export function fileResourceRoutes(db: Db, opts: {
});
router.get("/issues/:issueId/file-resources/content", async (req, res) => {
const auditTarget = safeAuditTarget(req.query);
try {
assertBoard(req);
} catch (error) {
@@ -466,6 +537,8 @@ export function fileResourceRoutes(db: Db, opts: {
actor: getActorInfo(req),
issueId: req.params.issueId,
displayPath: safeAuditDisplayPath(req.query),
projectId: auditTarget.projectId,
workspaceId: auditTarget.workspaceId,
error,
});
}
@@ -481,11 +554,27 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
displayPath: safeAuditDisplayPath(req.query),
projectId: auditTarget.projectId,
workspaceId: auditTarget.workspaceId,
error,
});
throw error;
}
let query: ReturnType<typeof readQuery>;
try {
query = readQuery(req.query);
} catch (error) {
await logDeniedAttempt({
companyId: issue.companyId,
actor,
issueId: req.params.issueId,
displayPath: safeAuditDisplayPath(req.query),
projectId: auditTarget.projectId,
workspaceId: auditTarget.workspaceId,
error,
});
throw error;
}
const query = readQuery(req.query);
let release: (() => void) | null = null;
try {
release = limiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId));
@@ -495,6 +584,8 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
displayPath: query.path,
projectId: query.projectId,
workspaceId: query.workspaceId,
error,
});
throw error;
@@ -509,6 +600,8 @@ export function fileResourceRoutes(db: Db, opts: {
actor,
issueId: req.params.issueId,
displayPath: query.path,
projectId: query.projectId,
workspaceId: query.workspaceId,
error,
});
throw error;
@@ -528,6 +621,8 @@ export function fileResourceRoutes(db: Db, opts: {
outcome: "success",
workspaceKind: result.resource.workspaceKind,
workspaceId: result.resource.workspaceId,
projectId: result.resource.projectId ?? null,
projectName: result.resource.projectName ?? null,
displayPath: result.resource.displayPath,
byteSize: result.resource.byteSize ?? null,
contentType: result.resource.contentType ?? null,
+92 -16
View File
@@ -4,7 +4,7 @@ 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 { executionWorkspaces, issues, projects, projectWorkspaces } from "@paperclipai/db";
import type {
ResolvedWorkspaceResource,
WorkspaceFileContent,
@@ -101,6 +101,8 @@ type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
type WorkspaceCandidate = {
workspaceKind: WorkspaceFileWorkspaceKind;
workspaceId: string;
projectId?: string | null;
projectName?: string | null;
provider: string;
label: string;
rootPath: string | null;
@@ -119,11 +121,18 @@ type LocalResolvedFile = {
type WorkspaceFileListQueryInput = {
workspace?: WorkspaceFileSelector | null;
projectId?: string | null;
workspaceId?: string | null;
mode?: WorkspaceFileListMode | null;
q?: string | null;
limit?: number | null;
};
type WorkspaceTargetInput = {
projectId?: string | null;
workspaceId?: string | null;
};
function previewCapForKind(kind: WorkspaceFilePreviewKind) {
return kind === "image" || kind === "video" || kind === "pdf"
? WORKSPACE_FILE_MEDIA_MAX_BYTES
@@ -226,10 +235,12 @@ function listItemFromStat(input: {
provider: input.candidate.provider,
title: path.posix.basename(input.relativePath),
relativePath: input.relativePath,
displayPath: input.relativePath,
displayPath: input.candidate.projectName ? `${input.candidate.projectName} / ${input.relativePath}` : input.relativePath,
workspaceLabel: input.candidate.label,
workspaceKind: input.candidate.workspaceKind,
workspaceId: input.candidate.workspaceId,
projectId: input.candidate.projectId ?? null,
projectName: input.candidate.projectName ?? null,
contentType: contentType ?? (previewKind === "text" ? "text/plain; charset=utf-8" : "application/octet-stream"),
byteSize: input.stat.size,
modifiedAt: input.stat.mtime.toISOString(),
@@ -268,10 +279,12 @@ function remoteResource(candidate: WorkspaceCandidate, relativePath: string): Re
kind: "remote_resource",
provider: candidate.provider || "remote_managed",
title: path.posix.basename(relativePath),
displayPath: relativePath,
displayPath: candidate.projectName ? `${candidate.projectName} / ${relativePath}` : relativePath,
workspaceLabel: candidate.label,
workspaceKind: candidate.workspaceKind,
workspaceId: candidate.workspaceId,
projectId: candidate.projectId ?? null,
projectName: candidate.projectName ?? null,
contentType: null,
byteSize: null,
previewKind: "unsupported",
@@ -298,13 +311,18 @@ function candidateFromExecutionWorkspace(row: ExecutionWorkspaceRow): WorkspaceC
};
}
function candidateFromProjectWorkspace(row: ProjectWorkspaceRow): WorkspaceCandidate {
function candidateFromProjectWorkspace(
row: ProjectWorkspaceRow,
project?: { id: string; name: string } | null,
): WorkspaceCandidate {
const provider = row.sourceType === "git_worktree" ? "git_worktree" : row.sourceType === "local_path" ? "local_fs" : row.sourceType;
const rootPath = row.cwd ?? null;
const remote = !["local_fs", "git_worktree"].includes(provider) || !rootPath;
return {
workspaceKind: "project_workspace",
workspaceId: row.id,
projectId: project?.id ?? row.projectId,
projectName: project?.name ?? null,
provider,
label: row.name,
rootPath,
@@ -361,10 +379,12 @@ async function statLocalCandidate(candidate: WorkspaceCandidate, normalized: Nor
kind: "file",
provider: candidate.provider,
title: path.posix.basename(normalized.relativePath),
displayPath: normalized.relativePath,
displayPath: candidate.projectName ? `${candidate.projectName} / ${normalized.relativePath}` : normalized.relativePath,
workspaceLabel: candidate.label,
workspaceKind: candidate.workspaceKind,
workspaceId: candidate.workspaceId,
projectId: candidate.projectId ?? null,
projectName: candidate.projectName ?? null,
contentType: contentType ?? (previewKind === "text" ? "text/plain; charset=utf-8" : "application/octet-stream"),
byteSize: stat.size,
previewKind,
@@ -413,6 +433,8 @@ function unavailableFileList(input: {
workspaceLabel: input.candidate.label,
workspaceKind: input.candidate.workspaceKind,
workspaceId: input.candidate.workspaceId,
projectId: input.candidate.projectId ?? null,
projectName: input.candidate.projectName ?? null,
}
: null,
query: {
@@ -445,6 +467,8 @@ function availableFileList(input: {
workspaceLabel: input.candidate.label,
workspaceKind: input.candidate.workspaceKind,
workspaceId: input.candidate.workspaceId,
projectId: input.candidate.projectId ?? null,
projectName: input.candidate.projectName ?? null,
},
query: {
workspace: input.selector,
@@ -637,7 +661,38 @@ export function workspaceFileResourceService(db: Db) {
return issue;
}
async function listCandidates(issue: IssueRow, selector: WorkspaceFileSelector): Promise<WorkspaceCandidate[]> {
async function targetProjectWorkspaceCandidate(
issue: IssueRow,
target: WorkspaceTargetInput,
): Promise<WorkspaceCandidate | null> {
const projectId = target.projectId ?? null;
const workspaceId = target.workspaceId ?? null;
if (!projectId && !workspaceId) return null;
if (!projectId || !workspaceId) {
throw unprocessable("Workspace file target requires both projectId and workspaceId", { code: "invalid_target" });
}
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
const [workspace] = await db.select().from(projectWorkspaces).where(eq(projectWorkspaces.id, workspaceId)).limit(1);
if (!project || !workspace) throw notFound("Project workspace not found");
if (project.companyId !== issue.companyId || workspace.companyId !== issue.companyId) {
throw new HttpError(403, "Project workspace belongs to another company", { code: "cross_company_workspace" });
}
if (workspace.projectId !== project.id) {
throw unprocessable("Workspace does not belong to the selected project", { code: "workspace_project_mismatch" });
}
return candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name });
}
async function listCandidates(
issue: IssueRow,
selector: WorkspaceFileSelector,
target: WorkspaceTargetInput = {},
): Promise<WorkspaceCandidate[]> {
const explicitTarget = await targetProjectWorkspaceCandidate(issue, target);
if (explicitTarget) return [explicitTarget];
const candidates: WorkspaceCandidate[] = [];
if ((selector === "auto" || selector === "execution") && issue.projectId) {
const executionIds = [issue.executionWorkspaceId].filter((id): id is string => Boolean(id));
@@ -700,11 +755,14 @@ export function workspaceFileResourceService(db: Db) {
async function resolve(issueId: string, input: {
path: string;
workspace?: WorkspaceFileSelector | null;
projectId?: string | null;
workspaceId?: string | null;
}, opts: { issue?: IssueRow } = {}): Promise<ResolvedWorkspaceResource> {
const issue = opts.issue ?? await getIssue(issueId);
const selector = input.workspace ?? "auto";
const explicitTarget = Boolean(input.projectId || input.workspaceId);
const normalized = normalizeWorkspaceRelativePath(input.path);
const candidates = await listCandidates(issue, selector);
const candidates = await listCandidates(issue, selector, input);
if (candidates.length === 0) {
throw unprocessable("No workspace is available for this issue", { code: "no_workspace" });
}
@@ -712,13 +770,19 @@ export function workspaceFileResourceService(db: Db) {
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.remote) {
if (selector !== "auto") return remoteResource(candidate, normalized.relativePath);
if (explicitTarget || selector !== "auto") return remoteResource(candidate, normalized.relativePath);
continue;
}
try {
return (await statLocalCandidate(candidate, normalized)).resource;
} catch (error) {
if (error instanceof Error && "status" in error && (error as { status?: number }).status === 404 && selector === "auto") {
if (
!explicitTarget &&
error instanceof Error &&
"status" in error &&
(error as { status?: number }).status === 404 &&
selector === "auto"
) {
lastNotFound = error;
continue;
}
@@ -737,6 +801,7 @@ export function workspaceFileResourceService(db: Db) {
): Promise<WorkspaceFileListResponse> {
const issue = opts.issue ?? await getIssue(issueId);
const selector = input.workspace ?? "auto";
const explicitTarget = Boolean(input.projectId || input.workspaceId);
const mode = input.mode ?? "all";
const limit = Math.min(
WORKSPACE_FILE_LIST_MAX_LIMIT,
@@ -744,7 +809,7 @@ export function workspaceFileResourceService(db: Db) {
);
const q = input.q?.trim() || null;
const normalizedQuery = q?.toLowerCase() ?? null;
const candidates = await listCandidates(issue, selector);
const candidates = await listCandidates(issue, selector, input);
if (candidates.length === 0) {
return unavailableFileList({ selector, mode, q, limit, reason: "no_workspace" });
}
@@ -753,7 +818,7 @@ export function workspaceFileResourceService(db: Db) {
for (const candidate of candidates) {
if (candidate.remote) {
firstUnavailable ??= { candidate, reason: "remote_workspace" };
if (selector !== "auto") {
if (explicitTarget || selector !== "auto") {
return unavailableFileList({ selector, mode, q, limit, candidate, reason: "remote_workspace" });
}
continue;
@@ -764,7 +829,7 @@ export function workspaceFileResourceService(db: Db) {
rootReal = await fs.realpath(candidate.rootPath!);
} catch {
firstUnavailable ??= { candidate, reason: "workspace_unavailable" };
if (selector !== "auto") {
if (explicitTarget || selector !== "auto") {
return unavailableFileList({ selector, mode, q, limit, candidate, reason: "workspace_unavailable" });
}
continue;
@@ -775,7 +840,7 @@ export function workspaceFileResourceService(db: Db) {
if ("unavailableReason" in changed) {
const reason = changed.unavailableReason ?? "changed_unavailable";
firstUnavailable ??= { candidate, reason };
if (selector !== "auto") {
if (explicitTarget || selector !== "auto") {
return unavailableFileList({ selector, mode, q, limit, candidate, reason });
}
continue;
@@ -824,11 +889,14 @@ export function workspaceFileResourceService(db: Db) {
async function readContent(issueId: string, input: {
path: string;
workspace?: WorkspaceFileSelector | null;
projectId?: string | null;
workspaceId?: string | null;
}, opts: { issue?: IssueRow } = {}): Promise<WorkspaceFileContent> {
const issue = opts.issue ?? await getIssue(issueId);
const selector = input.workspace ?? "auto";
const explicitTarget = Boolean(input.projectId || input.workspaceId);
const normalized = normalizeWorkspaceRelativePath(input.path);
const candidates = await listCandidates(issue, selector);
const candidates = await listCandidates(issue, selector, input);
if (candidates.length === 0) {
throw unprocessable("No workspace is available for this issue", { code: "no_workspace" });
}
@@ -836,14 +904,22 @@ export function workspaceFileResourceService(db: Db) {
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.remote) {
if (selector !== "auto") throw unprocessable("Remote workspaces cannot be previewed by the server", { code: "remote_workspace" });
if (explicitTarget || selector !== "auto") {
throw unprocessable("Remote workspaces cannot be previewed by the server", { code: "remote_workspace" });
}
continue;
}
let resolved: LocalResolvedFile;
try {
resolved = await statLocalCandidate(candidate, normalized);
} catch (error) {
if (error instanceof Error && "status" in error && (error as { status?: number }).status === 404 && selector === "auto") {
if (
!explicitTarget &&
error instanceof Error &&
"status" in error &&
(error as { status?: number }).status === 404 &&
selector === "auto"
) {
lastNotFound = error;
continue;
}