mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix: auto-resolve workspace links across projects
This commit is contained in:
1 parent
3e4dc94d27
commit
ce22ad7632
4 files changed
+274
-15
No files matched your search
@@ -243,6 +243,35 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
expect(resolved.capabilities.preview).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-discovers unhinted same-company project files when issue workspaces miss", async () => {
|
||||
const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
|
||||
const targetPath = "docs/reference/skills.md";
|
||||
await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true });
|
||||
await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Skills reference\n", "utf8");
|
||||
|
||||
const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, {
|
||||
path: targetPath,
|
||||
workspace: "auto",
|
||||
});
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
workspaceKind: "project_workspace",
|
||||
workspaceId: graph.targetProjectWorkspaceId,
|
||||
projectId: graph.targetProjectId,
|
||||
projectName: "Target project",
|
||||
displayPath: `Target project / ${targetPath}`,
|
||||
previewKind: "text",
|
||||
});
|
||||
|
||||
const content = await workspaceFileResourceService(db).readContent(graph.issueId, {
|
||||
path: targetPath,
|
||||
workspace: "auto",
|
||||
});
|
||||
expect(content.resource.workspaceId).toBe(graph.targetProjectWorkspaceId);
|
||||
expect(content.content.data).toContain("# Skills reference");
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -386,6 +415,87 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
expect(JSON.stringify(listed.body)).not.toContain("outside.txt");
|
||||
});
|
||||
|
||||
it("auto-discovers unhinted same-company project folders when issue workspaces miss", async () => {
|
||||
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
|
||||
const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/";
|
||||
await fs.mkdir(path.join(targetProjectRoot, folderPath), { recursive: true });
|
||||
await fs.writeFile(path.join(targetProjectRoot, folderPath, "README.md"), "# Bundled skills\n", "utf8");
|
||||
await fs.writeFile(path.join(targetProjectRoot, folderPath, "suggestions.md"), "# Suggestions\n", "utf8");
|
||||
|
||||
const app = createApp(db, {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [graph.companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const resolved = await request(app)
|
||||
.get(`/api/issues/${graph.issueId}/file-resources/resolve`)
|
||||
.query({ path: folderPath });
|
||||
expect(resolved.status).toBe(200);
|
||||
expect(resolved.body).toMatchObject({
|
||||
kind: "directory",
|
||||
workspaceId: graph.targetProjectWorkspaceId,
|
||||
projectId: graph.targetProjectId,
|
||||
projectName: "Target project",
|
||||
displayPath: `Target project / ${folderPath}`,
|
||||
});
|
||||
|
||||
const listed = await request(app)
|
||||
.get(`/api/issues/${graph.issueId}/file-resources/list`)
|
||||
.query({ path: folderPath });
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body.state).toBe("available");
|
||||
expect(listed.body.workspace).toMatchObject({
|
||||
workspaceId: graph.targetProjectWorkspaceId,
|
||||
projectId: graph.targetProjectId,
|
||||
projectName: "Target project",
|
||||
});
|
||||
expect(new Set(listed.body.items.map((item: { relativePath: string }) => item.relativePath))).toEqual(new Set([
|
||||
`${folderPath}README.md`,
|
||||
`${folderPath}suggestions.md`,
|
||||
]));
|
||||
expect(JSON.stringify(listed.body)).not.toContain(root);
|
||||
});
|
||||
|
||||
it("rejects ambiguous unhinted same-company project file matches", async () => {
|
||||
const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
|
||||
const duplicateProjectId = crypto.randomUUID();
|
||||
const duplicateWorkspaceId = crypto.randomUUID();
|
||||
const duplicateRoot = path.join(path.dirname(targetProjectRoot), "duplicate-target");
|
||||
const targetPath = "docs/reference/skills.md";
|
||||
await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true });
|
||||
await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Target\n", "utf8");
|
||||
await fs.mkdir(path.join(duplicateRoot, path.dirname(targetPath)), { recursive: true });
|
||||
await fs.writeFile(path.join(duplicateRoot, targetPath), "# Duplicate\n", "utf8");
|
||||
await db.insert(projects).values({
|
||||
id: duplicateProjectId,
|
||||
companyId: graph.companyId,
|
||||
name: "Duplicate target",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: duplicateWorkspaceId,
|
||||
companyId: graph.companyId,
|
||||
projectId: duplicateProjectId,
|
||||
name: "Duplicate workspace",
|
||||
sourceType: "local_path",
|
||||
cwd: duplicateRoot,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
await expect(workspaceFileResourceService(db).resolve(graph.issueId, {
|
||||
path: targetPath,
|
||||
workspace: "auto",
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { code: "ambiguous_workspace_path" },
|
||||
});
|
||||
});
|
||||
|
||||
it("denies explicit cross-company project workspaces", async () => {
|
||||
const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
|
||||
|
||||
@@ -126,6 +126,11 @@ type LocalResolvedDirectory = {
|
||||
rootReal: string;
|
||||
};
|
||||
|
||||
type AutoDiscovered<T> =
|
||||
| { state: "none" }
|
||||
| { state: "one"; value: T }
|
||||
| { state: "ambiguous"; count: number };
|
||||
|
||||
type WorkspaceFileListQueryInput = {
|
||||
workspace?: WorkspaceFileSelector | null;
|
||||
projectId?: string | null;
|
||||
@@ -340,6 +345,14 @@ function candidateFromProjectWorkspace(
|
||||
};
|
||||
}
|
||||
|
||||
function isHttpStatus(error: unknown, status: number) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"status" in error &&
|
||||
(error as { status?: number }).status === status
|
||||
);
|
||||
}
|
||||
|
||||
function directoryResource(input: {
|
||||
candidate: WorkspaceCandidate;
|
||||
relativePath: string;
|
||||
@@ -834,6 +847,63 @@ export function workspaceFileResourceService(db: Db) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function sameCompanyProjectWorkspaceCandidates(
|
||||
issue: IssueRow,
|
||||
excludedWorkspaceIds: Set<string>,
|
||||
): Promise<WorkspaceCandidate[]> {
|
||||
const rows = await db.select({
|
||||
workspace: projectWorkspaces,
|
||||
project: {
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
},
|
||||
})
|
||||
.from(projectWorkspaces)
|
||||
.innerJoin(projects, eq(projectWorkspaces.projectId, projects.id))
|
||||
.where(and(
|
||||
eq(projectWorkspaces.companyId, issue.companyId),
|
||||
eq(projects.companyId, issue.companyId),
|
||||
))
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), desc(projectWorkspaces.updatedAt));
|
||||
|
||||
const candidates: WorkspaceCandidate[] = [];
|
||||
const seen = new Set(excludedWorkspaceIds);
|
||||
for (const row of rows) {
|
||||
if (seen.has(row.workspace.id)) continue;
|
||||
seen.add(row.workspace.id);
|
||||
candidates.push(candidateFromProjectWorkspace(row.workspace, row.project));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function discoverUniqueProjectWorkspaceMatch<T>(
|
||||
issue: IssueRow,
|
||||
excludedWorkspaceIds: Set<string>,
|
||||
check: (candidate: WorkspaceCandidate) => Promise<T>,
|
||||
): Promise<AutoDiscovered<T>> {
|
||||
const candidates = await sameCompanyProjectWorkspaceCandidates(issue, excludedWorkspaceIds);
|
||||
const matches: T[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.remote) continue;
|
||||
try {
|
||||
matches.push(await check(candidate));
|
||||
} catch (error) {
|
||||
if (isHttpStatus(error, 404)) continue;
|
||||
throw error;
|
||||
}
|
||||
if (matches.length > 1) return { state: "ambiguous", count: matches.length };
|
||||
}
|
||||
if (matches.length === 1) return { state: "one", value: matches[0]! };
|
||||
return { state: "none" };
|
||||
}
|
||||
|
||||
function throwAmbiguousWorkspacePath(count: number): never {
|
||||
throw new HttpError(409, "Workspace path matched multiple project workspaces", {
|
||||
code: "ambiguous_workspace_path",
|
||||
matchCount: count,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolve(issueId: string, input: {
|
||||
path: string;
|
||||
workspace?: WorkspaceFileSelector | null;
|
||||
@@ -861,13 +931,7 @@ export function workspaceFileResourceService(db: Db) {
|
||||
? (await statLocalDirectory(candidate, normalized)).resource
|
||||
: (await statLocalCandidate(candidate, normalized)).resource;
|
||||
} catch (error) {
|
||||
if (
|
||||
!explicitTarget &&
|
||||
error instanceof Error &&
|
||||
"status" in error &&
|
||||
(error as { status?: number }).status === 404 &&
|
||||
selector === "auto"
|
||||
) {
|
||||
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
|
||||
lastNotFound = error;
|
||||
continue;
|
||||
}
|
||||
@@ -875,6 +939,18 @@ export function workspaceFileResourceService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
if (lastNotFound && !explicitTarget && selector === "auto") {
|
||||
const discovered = await discoverUniqueProjectWorkspaceMatch(
|
||||
issue,
|
||||
new Set(candidates.map((candidate) => candidate.workspaceId)),
|
||||
async (candidate) => isDirectoryRequest
|
||||
? (await statLocalDirectory(candidate, normalized)).resource
|
||||
: (await statLocalCandidate(candidate, normalized)).resource,
|
||||
);
|
||||
if (discovered.state === "one") return discovered.value;
|
||||
if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count);
|
||||
}
|
||||
|
||||
if (lastNotFound) throw lastNotFound;
|
||||
throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" });
|
||||
}
|
||||
@@ -901,6 +977,7 @@ export function workspaceFileResourceService(db: Db) {
|
||||
}
|
||||
|
||||
let firstUnavailable: { candidate: WorkspaceCandidate; reason: string } | null = null;
|
||||
let lastNotFound: unknown = null;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.remote) {
|
||||
firstUnavailable ??= { candidate, reason: "remote_workspace" };
|
||||
@@ -922,7 +999,13 @@ export function workspaceFileResourceService(db: Db) {
|
||||
startRelativePath = normalizedPath.relativePath;
|
||||
}
|
||||
} catch (error) {
|
||||
if (normalizedPath) throw error;
|
||||
if (normalizedPath) {
|
||||
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
|
||||
lastNotFound = error;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
firstUnavailable ??= { candidate, reason: "workspace_unavailable" };
|
||||
if (explicitTarget || selector !== "auto") {
|
||||
return unavailableFileList({ selector, mode, path: null, q, limit, candidate, reason: "workspace_unavailable" });
|
||||
@@ -975,6 +1058,45 @@ export function workspaceFileResourceService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
if (lastNotFound && normalizedPath && !explicitTarget && selector === "auto") {
|
||||
const discovered = await discoverUniqueProjectWorkspaceMatch(
|
||||
issue,
|
||||
new Set(candidates.map((candidate) => candidate.workspaceId)),
|
||||
async (candidate) => {
|
||||
const resolvedDirectory = await statLocalDirectory(candidate, normalizedPath);
|
||||
return {
|
||||
candidate,
|
||||
rootReal: resolvedDirectory.rootReal,
|
||||
startReal: resolvedDirectory.realPath,
|
||||
startRelativePath: normalizedPath.relativePath,
|
||||
};
|
||||
},
|
||||
);
|
||||
if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count);
|
||||
if (discovered.state === "one") {
|
||||
const listed = await enumerateWorkspaceFiles({
|
||||
candidate: discovered.value.candidate,
|
||||
rootReal: discovered.value.rootReal,
|
||||
startReal: discovered.value.startReal,
|
||||
startRelativePath: discovered.value.startRelativePath,
|
||||
mode,
|
||||
normalizedQuery,
|
||||
limit,
|
||||
});
|
||||
return availableFileList({
|
||||
selector,
|
||||
mode,
|
||||
path: normalizedPath.relativePath,
|
||||
q,
|
||||
limit,
|
||||
candidate: discovered.value.candidate,
|
||||
items: listed.items,
|
||||
scannedCount: listed.scannedCount,
|
||||
truncated: listed.truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return unavailableFileList({
|
||||
selector,
|
||||
mode,
|
||||
@@ -1013,13 +1135,7 @@ export function workspaceFileResourceService(db: Db) {
|
||||
try {
|
||||
resolved = await statLocalCandidate(candidate, normalized);
|
||||
} catch (error) {
|
||||
if (
|
||||
!explicitTarget &&
|
||||
error instanceof Error &&
|
||||
"status" in error &&
|
||||
(error as { status?: number }).status === 404 &&
|
||||
selector === "auto"
|
||||
) {
|
||||
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
|
||||
lastNotFound = error;
|
||||
continue;
|
||||
}
|
||||
@@ -1044,6 +1160,34 @@ export function workspaceFileResourceService(db: Db) {
|
||||
};
|
||||
}
|
||||
|
||||
if (lastNotFound && !explicitTarget && selector === "auto") {
|
||||
const discovered = await discoverUniqueProjectWorkspaceMatch(
|
||||
issue,
|
||||
new Set(candidates.map((candidate) => candidate.workspaceId)),
|
||||
async (candidate) => statLocalCandidate(candidate, normalized),
|
||||
);
|
||||
if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count);
|
||||
if (discovered.state === "one") {
|
||||
const resolved = discovered.value;
|
||||
if (!resolved.resource.capabilities.preview) {
|
||||
throw unprocessable("Workspace file cannot be previewed", { code: resolved.resource.denialReason ?? "unsupported_content" });
|
||||
}
|
||||
const cap = previewCapForKind(resolved.resource.previewKind);
|
||||
const data = await readStableFile(resolved.realPath, cap);
|
||||
if (resolved.resource.previewKind === "text" && !looksLikeText(data.subarray(0, Math.min(data.length, TEXT_SNIFF_BYTES)))) {
|
||||
throw unprocessable("Workspace file is not a text file", { code: "binary_content" });
|
||||
}
|
||||
|
||||
return {
|
||||
resource: resolved.resource,
|
||||
content: {
|
||||
encoding: resolved.resource.previewKind === "text" ? "utf8" : "base64",
|
||||
data: resolved.resource.previewKind === "text" ? data.toString("utf8") : data.toString("base64"),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (lastNotFound) throw lastNotFound;
|
||||
throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" });
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ describe("parseWorkspaceFileRef", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects one-segment directory refs because they are too ambiguous to route", () => {
|
||||
expect(parseWorkspaceFileRef("sources/")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unsafe directory refs", () => {
|
||||
expect(parseWorkspaceFileRef("../secrets/")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("foo/../secrets/")).toBeNull();
|
||||
|
||||
@@ -66,6 +66,7 @@ export function parseWorkspaceFileRef(input: string): ParsedWorkspaceFileRef | n
|
||||
if (directoryMatch) {
|
||||
const [, rawPath] = directoryMatch;
|
||||
if (!rawPath || !looksLikeWorkspacePath(rawPath, { allowTrailingSlash: true })) return null;
|
||||
if (!rawPath.slice(0, -1).includes("/")) return null;
|
||||
return {
|
||||
path: rawPath,
|
||||
resourceKind: "directory",
|
||||
|
||||
Reference in new issue
Block a user