fix: auto-resolve workspace links across projects

This commit is contained in:
Dotta committed 2026-06-09 21:35:48 +00:00
1 parent 3e4dc94d27
commit ce22ad7632
4 files changed
+274 -15

No files matched your search

+110
View File
@@ -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 });
+159 -15
View File
@@ -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" });
}
+4
View File
@@ -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();
+1
View File
@@ -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",