fix(files): only highlight accessible workspace file links (#11090)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task comments can contain references to files in project and
execution workspaces.
> - Paperclip detected path-shaped inline code and showed it as an
actionable file chip.
> - The UI did not first confirm that the current board session could
open the file.
> - Missing, denied, ambiguous, remote, and unsupported files therefore
looked actionable and failed after a click.
> - This pull request adds an issue-scoped availability check and
promotes only confirmed files to chips.
> - The benefit is that the task thread shows a file action only when
that action can succeed.

## Linked Issues or Issue Description

**What happened?**

Task comments promoted path-shaped inline code to file chips before
Paperclip checked the file. A chip could point to a missing, denied,
ambiguous, remote, or non-previewable file. The action then failed after
the user selected it.

**Expected behavior**

Paperclip must show a file chip only after the server confirms that the
current board session can open the exact file reference. All other
path-shaped text must stay ordinary inline code.

**Steps to reproduce**

1. Add a task comment that contains inline code with a missing or
inaccessible workspace path.
2. Open the task thread as a board user.
3. Observe that the path looks like an actionable file chip.
4. Select the chip and observe that the file cannot open.

**Paperclip version or commit**

`19be4cf927` and earlier.

**Deployment mode**

Local dev and self-hosted server.

**Access context**

Board user.

## What Changed

- Added shared request, response, and validation contracts for batched
workspace-file availability checks.
- Added an issue-scoped server endpoint that resolves file references
with company, issue, workspace, and preview-access checks.
- Added bounded batch concurrency and tests for missing, denied,
ambiguous, remote, unsupported, and available files.
- Added an issue-scoped UI availability registry that deduplicates,
batches, caches, and invalidates file checks.
- Changed task-comment markdown rendering so only confirmed files get
chip styling and file-viewer behavior.
- Bound each chip to the exact workspace target that passed the
availability check.

## Verification

- `pnpm exec vitest run
packages/shared/src/workspace-file-resource.test.ts
server/src/__tests__/file-resources.test.ts
ui/src/components/MarkdownBody.test.tsx
ui/src/components/WorkspaceFileMarkdownBody.availability.test.tsx
ui/src/lib/remark-workspace-file-refs.test.ts
ui/src/lib/workspace-file-availability.test.ts` — 93 passed, 35 skipped.
- `pnpm check:token-gates` — clean.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm test:run` — all server and UI groups passed. One unchanged CLI
test saw the run-injected static AWS credentials and expected only its
local `AWS_PROFILE`. The same test passed, 8 of 8, after removing only
`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` from its process
environment.

## Risks

- File chips now appear after an asynchronous availability check, so
path-shaped text can briefly render as inline code.
- Availability results use the existing 30-second file-resource cache
window. File-resource invalidation forces a new check.
- The endpoint limits each request to 100 references and the client
chunks larger sets.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with GPT-5. The service did not expose a more specific
model ID or context-window size. The agent used high-reasoning mode,
repository tools, command execution, and test execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-11 12:11:45 -04:00 committed by GitHub
parent 815e49bb7c
commit 7ea2068ef8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1967 additions and 30 deletions

View File

@ -850,6 +850,11 @@ export type {
WorkspaceOperation,
WorkspaceOperationPhase,
WorkspaceOperationStatus,
NormalizedWorkspaceFileAvailabilityQuery,
WorkspaceFileAvailabilityQuery,
WorkspaceFileAvailabilityRequest,
WorkspaceFileAvailabilityResponse,
WorkspaceFileAvailabilityResult,
WorkspaceFileContent,
WorkspaceFileContentEncoding,
WorkspaceFileListDirectoryItem,
@ -1812,6 +1817,11 @@ export {
type ReconcileExecutionWorkspaceBranch,
type UpdateExecutionWorkspace,
type WorkspaceOverviewQuery,
normalizedWorkspaceFileAvailabilityQuerySchema,
workspaceFileAvailabilityRequestSchema,
workspaceFileAvailabilityResponseSchema,
workspaceFileAvailabilityResultSchema,
type WorkspaceFileAvailabilityRequestInput,
type WorkspaceFileListQuery,
type WorkspaceFileResourceQuery,
type IssueDocumentFormat,

View File

@ -384,6 +384,11 @@ export type {
WorkspaceOperationStatus,
} from "./workspace-operation.js";
export type {
NormalizedWorkspaceFileAvailabilityQuery,
WorkspaceFileAvailabilityQuery,
WorkspaceFileAvailabilityRequest,
WorkspaceFileAvailabilityResponse,
WorkspaceFileAvailabilityResult,
WorkspaceFileContent,
WorkspaceFileContentEncoding,
WorkspaceFileListDirectoryItem,

View File

@ -117,3 +117,33 @@ export interface WorkspaceFileListResponse {
scannedCount: number;
truncated: boolean;
}
export interface WorkspaceFileAvailabilityQuery {
path: string;
workspace?: WorkspaceFileSelector;
projectId?: string;
workspaceId?: string;
}
export interface NormalizedWorkspaceFileAvailabilityQuery {
path: string;
workspace: WorkspaceFileSelector;
projectId: string | null;
workspaceId: string | null;
}
export interface WorkspaceFileAvailabilityRequest {
queries: WorkspaceFileAvailabilityQuery[];
}
export interface WorkspaceFileAvailabilityResult {
query: NormalizedWorkspaceFileAvailabilityQuery;
openable: boolean;
unavailableReason?: string | null;
resource: ResolvedWorkspaceResource | null;
}
export interface WorkspaceFileAvailabilityResponse {
kind: "workspace_file_availability";
results: WorkspaceFileAvailabilityResult[];
}

View File

@ -564,7 +564,11 @@ export {
} from "./execution-workspace.js";
export {
normalizedWorkspaceFileAvailabilityQuerySchema,
resolvedWorkspaceResourceSchema,
workspaceFileAvailabilityRequestSchema,
workspaceFileAvailabilityResponseSchema,
workspaceFileAvailabilityResultSchema,
workspaceFileListModeSchema,
workspaceFileListQuerySchema,
workspaceFileContentSchema,
@ -574,6 +578,7 @@ export {
workspaceFileResourceQuerySchema,
workspaceFileSelectorSchema,
workspaceFileWorkspaceKindSchema,
type WorkspaceFileAvailabilityRequestInput,
type WorkspaceFileListQuery,
type WorkspaceFileResourceQuery,
} from "./workspace-file-resource.js";

View File

@ -42,6 +42,10 @@ export const workspaceFileResourceQuerySchema = z.object({
params: { code: "invalid_target" },
});
export const workspaceFileAvailabilityRequestSchema = z.object({
queries: z.array(workspaceFileResourceQuerySchema).max(100),
});
export const workspaceFileListQuerySchema = z.object({
projectId: z.string().uuid().optional(),
workspaceId: z.string().uuid().optional(),
@ -95,6 +99,25 @@ export const resolvedWorkspaceResourceSchema = z.object({
}),
});
export const normalizedWorkspaceFileAvailabilityQuerySchema = z.object({
projectId: z.string().uuid().nullable(),
workspaceId: z.string().uuid().nullable(),
path: z.string().min(1),
workspace: workspaceFileSelectorSchema,
});
export const workspaceFileAvailabilityResultSchema = z.object({
query: normalizedWorkspaceFileAvailabilityQuerySchema,
openable: z.boolean(),
unavailableReason: z.string().min(1).nullable().optional(),
resource: resolvedWorkspaceResourceSchema.nullable(),
});
export const workspaceFileAvailabilityResponseSchema = z.object({
kind: z.literal("workspace_file_availability"),
results: z.array(workspaceFileAvailabilityResultSchema).max(100),
});
export const workspaceFileContentSchema = z.object({
resource: resolvedWorkspaceResourceSchema,
content: z.object({
@ -105,3 +128,4 @@ export const workspaceFileContentSchema = z.object({
export type WorkspaceFileResourceQuery = z.infer<typeof workspaceFileResourceQuerySchema>;
export type WorkspaceFileListQuery = z.infer<typeof workspaceFileListQuerySchema>;
export type WorkspaceFileAvailabilityRequestInput = z.infer<typeof workspaceFileAvailabilityRequestSchema>;

View File

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
workspaceFileAvailabilityRequestSchema,
workspaceFileAvailabilityResponseSchema,
} from "./validators/workspace-file-resource.js";
const projectId = "11111111-1111-4111-8111-111111111111";
const workspaceId = "22222222-2222-4222-8222-222222222222";
describe("workspace file availability schemas", () => {
it("accepts at most 100 resource queries", () => {
const query = { path: "src/app.ts", workspace: "auto" as const };
expect(workspaceFileAvailabilityRequestSchema.safeParse({ queries: Array.from({ length: 100 }, () => query) }).success).toBe(true);
expect(workspaceFileAvailabilityRequestSchema.safeParse({ queries: Array.from({ length: 101 }, () => query) }).success).toBe(false);
});
it("rejects malformed paths and incomplete targets", () => {
expect(workspaceFileAvailabilityRequestSchema.safeParse({ queries: [{ path: "src/\u0000app.ts" }] }).success).toBe(false);
expect(workspaceFileAvailabilityRequestSchema.safeParse({ queries: [{ path: "src/app.ts", projectId }] }).success).toBe(false);
expect(workspaceFileAvailabilityRequestSchema.safeParse({
queries: [{ path: "src/app.ts", projectId, workspaceId }],
}).success).toBe(true);
});
it("parses normalized openable and unavailable results", () => {
const parsed = workspaceFileAvailabilityResponseSchema.parse({
kind: "workspace_file_availability",
results: [
{
query: { path: "src/app.ts", workspace: "project", projectId: null, workspaceId: null },
openable: true,
resource: {
kind: "file",
provider: "local_fs",
title: "app.ts",
displayPath: "src/app.ts",
workspaceLabel: "Primary workspace",
workspaceKind: "project_workspace",
workspaceId,
projectId,
projectName: "Project",
contentType: "text/plain; charset=utf-8",
byteSize: 12,
previewKind: "text",
capabilities: { preview: true, download: true, listChildren: false },
},
},
{
query: { path: "missing.ts", workspace: "auto", projectId: null, workspaceId: null },
openable: false,
unavailableReason: "not_found",
resource: null,
},
],
});
expect(parsed.results.map((result) => result.openable)).toEqual([true, false]);
});
});

View File

@ -11,6 +11,7 @@ import { activityLog, agents, companies, createDb, executionWorkspaces, goals, i
import { eq } from "drizzle-orm";
import { errorHandler } from "../middleware/index.js";
import {
createFileResourceAvailabilityLimiter,
createFileResourceLimiter,
createFileResourceListLimiter,
fileResourceRoutes,
@ -1160,6 +1161,9 @@ describeEmbeddedPostgres("workspace file resources", () => {
});
const resolveLimitedService: WorkspaceFileResourceService = {
getIssue: vi.fn(async () => ({ companyId: graph.companyId })),
availability: vi.fn(async () => {
throw new Error("not used");
}),
list: vi.fn(async () => {
throw new Error("not used");
}),
@ -1221,6 +1225,9 @@ describeEmbeddedPostgres("workspace file resources", () => {
});
const contentLimitedService: WorkspaceFileResourceService = {
getIssue: vi.fn(async () => ({ companyId: graph.companyId })),
availability: vi.fn(async () => {
throw new Error("not used");
}),
list: vi.fn(async () => {
throw new Error("not used");
}),
@ -1294,6 +1301,9 @@ describeEmbeddedPostgres("workspace file resources", () => {
});
const service: WorkspaceFileResourceService = {
getIssue: vi.fn(async () => ({ companyId: graph.companyId })),
availability: vi.fn(async () => {
throw new Error("not used");
}),
list: vi.fn(async () => {
throw new Error("not used");
}),
@ -1380,6 +1390,9 @@ describeEmbeddedPostgres("workspace file resources", () => {
});
const service: WorkspaceFileResourceService = {
getIssue: vi.fn(async () => ({ companyId: graph.companyId })),
availability: vi.fn(async () => {
throw new Error("not used");
}),
list: vi.fn(async () => {
slowListStarted?.();
await slowList;
@ -1438,6 +1451,189 @@ describeEmbeddedPostgres("workspace file resources", () => {
const third = await request(app).get(`/api/issues/${graph.issueId}/file-resources/list`);
expect(third.status).toBe(429);
});
it("returns mixed deduplicated availability results with one aggregate audit event", async () => {
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, {
projectRoot,
targetProjectRoot,
executionRoot,
targetProjectSourceType: "remote_managed",
});
await fs.mkdir(path.join(projectRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(projectRoot, "README.md"), "# Visible\n", "utf8");
await fs.writeFile(path.join(projectRoot, "docs", "guide.md"), "# Guide\n", "utf8");
await fs.writeFile(path.join(projectRoot, "archive.bin"), Buffer.from([0, 1, 2, 3]));
await fs.writeFile(path.join(projectRoot, "large.txt"), Buffer.alloc(WORKSPACE_FILE_TEXT_MAX_BYTES + 1, "a"));
await fs.writeFile(path.join(projectRoot, ".env"), "TOKEN=secret\n", "utf8");
await fs.writeFile(path.join(root, "outside-secret.txt"), "outside\n", "utf8");
await fs.symlink(path.join(root, "outside-secret.txt"), path.join(projectRoot, "escape.txt"));
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const response = await request(app)
.post(`/api/issues/${graph.issueId}/file-resources/availability`)
.send({
queries: [
{ workspace: "project", path: "README.md" },
{ workspace: "project", path: "./README.md" },
{ workspace: "project", path: "docs/" },
{ workspace: "project", path: "archive.bin" },
{ workspace: "project", path: "large.txt" },
{ workspace: "project", path: ".env" },
{ workspace: "project", path: "../outside-secret.txt" },
{ workspace: "project", path: path.join(root, "host-secret.txt") },
{ workspace: "project", path: "escape.txt" },
{ workspace: "project", path: "missing.ts" },
{
workspace: "project",
projectId: graph.targetProjectId,
workspaceId: graph.targetProjectWorkspaceId,
path: "remote.txt",
},
],
});
expect(response.status).toBe(200);
expect(response.body.kind).toBe("workspace_file_availability");
expect(response.body.results).toHaveLength(10);
const byPath = new Map(response.body.results.map((result: { query: { path: string } }) => [result.query.path, result]));
expect(byPath.get("README.md")).toMatchObject({ openable: true, resource: { kind: "file" } });
expect(byPath.get("docs/")).toMatchObject({ openable: true, resource: { kind: "directory" } });
expect(byPath.get("archive.bin")).toMatchObject({ openable: false, unavailableReason: "unsupported_content" });
expect(byPath.get("large.txt")).toMatchObject({ openable: false, unavailableReason: "too_large" });
expect(byPath.get(".env")).toMatchObject({ openable: false, unavailableReason: "denied_secret", resource: null });
expect(byPath.get("../outside-secret.txt")).toMatchObject({
openable: false,
unavailableReason: "outside_workspace",
resource: null,
});
expect(byPath.get("host-secret.txt")).toMatchObject({
openable: false,
unavailableReason: "invalid_path",
resource: null,
});
expect(byPath.get("escape.txt")).toMatchObject({
openable: false,
unavailableReason: "outside_workspace",
resource: null,
});
expect(byPath.get("missing.ts")).toMatchObject({ openable: false, unavailableReason: "not_found", resource: null });
expect(byPath.get("remote.txt")).toMatchObject({
openable: false,
unavailableReason: "remote_workspace",
resource: { kind: "remote_resource" },
});
expect(JSON.stringify(response.body)).not.toContain(root);
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId));
const availabilityRows = rows.filter((row) => row.action === "issue.file_resource_availability");
expect(availabilityRows).toHaveLength(1);
expect(availabilityRows[0]?.details).toMatchObject({
outcome: "success",
requestedCount: 11,
uniqueCount: 10,
openableCount: 2,
unavailableCount: 8,
});
expect(JSON.stringify(availabilityRows[0]?.details)).not.toContain(root);
});
it("reports ambiguous auto-discovery as one unavailable result", async () => {
const { projectRoot, targetProjectRoot, executionRoot, root } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
const extraRoot = path.join(root, "extra-project");
await fs.mkdir(extraRoot, { recursive: true });
await fs.writeFile(path.join(targetProjectRoot, "shared.md"), "target\n", "utf8");
await fs.writeFile(path.join(extraRoot, "shared.md"), "extra\n", "utf8");
const extraProjectId = crypto.randomUUID();
await db.insert(projects).values({
id: extraProjectId,
companyId: graph.companyId,
name: "Extra project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: crypto.randomUUID(),
companyId: graph.companyId,
projectId: extraProjectId,
name: "Extra workspace",
sourceType: "local_path",
cwd: extraRoot,
isPrimary: true,
});
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const response = await request(app)
.post(`/api/issues/${graph.issueId}/file-resources/availability`)
.send({ queries: [{ path: "shared.md" }] });
expect(response.status).toBe(200);
expect(response.body.results).toEqual([
{
query: { path: "shared.md", workspace: "auto", projectId: null, workspaceId: null },
openable: false,
unavailableReason: "ambiguous_workspace_path",
resource: null,
},
]);
expect(JSON.stringify(response.body)).not.toContain(root);
});
it("enforces board access, company boundaries, and the 100-query cap", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
const agentId = crypto.randomUUID();
await db.insert(agents).values({
id: agentId,
companyId: graph.companyId,
name: "Availability audit agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
});
const agentApp = createApp(db, {
type: "agent",
agentId,
companyId: graph.companyId,
source: "agent_key",
});
const otherCompanyApp = createApp(db, {
type: "board",
userId: "other-board",
companyIds: [graph.otherCompanyId],
source: "session",
isInstanceAdmin: false,
});
const boardApp = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
expect((await request(agentApp)
.post(`/api/issues/${graph.issueId}/file-resources/availability`)
.send({ queries: [{ path: "README.md" }] })).status).toBe(403);
expect((await request(otherCompanyApp)
.post(`/api/issues/${graph.issueId}/file-resources/availability`)
.send({ queries: [{ path: "README.md" }] })).status).toBe(404);
expect((await request(boardApp)
.post(`/api/issues/${graph.issueId}/file-resources/availability`)
.send({ queries: Array.from({ length: 101 }, (_, index) => ({ path: `file-${index}.ts` })) })).status).toBe(400);
});
});
describeEmbeddedPostgres("file resource route guards", () => {
@ -1470,6 +1666,9 @@ describeEmbeddedPostgres("file resource route guards", () => {
});
const service: WorkspaceFileResourceService = {
getIssue: vi.fn(async () => ({ companyId })),
availability: vi.fn(async () => {
throw new Error("not used");
}),
list: vi.fn(async () => {
throw new Error("not used");
}),
@ -1522,4 +1721,65 @@ describeEmbeddedPostgres("file resource route guards", () => {
const third = await request(app).get("/api/issues/issue-1/file-resources/resolve").query({ path: "README.md" });
expect(third.status).toBe(429);
});
it("uses a batch-specific availability limiter", async () => {
const companyId = crypto.randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Availability rate limit company",
issuePrefix: "AVL",
});
let releaseSlowAvailability: (() => void) | null = null;
let slowAvailabilityStarted: (() => void) | null = null;
const slowAvailability = new Promise<void>((resolve) => {
releaseSlowAvailability = resolve;
});
const availabilityStarted = new Promise<void>((resolve) => {
slowAvailabilityStarted = resolve;
});
const service: WorkspaceFileResourceService = {
getIssue: vi.fn(async () => ({ companyId })),
availability: vi.fn(async () => {
slowAvailabilityStarted?.();
await slowAvailability;
return { kind: "workspace_file_availability", results: [] };
}),
list: vi.fn(async () => { throw new Error("not used"); }),
resolve: vi.fn(async () => { throw new Error("not used"); }),
readContent: vi.fn(async () => { throw new Error("not used"); }),
prepareDownload: vi.fn(async () => { throw new Error("not used"); }),
};
const app = createApp(
db,
{
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
},
{
service,
availabilityLimiter: createFileResourceAvailabilityLimiter({
maxConcurrent: 1,
maxRequests: 2,
windowMs: 60_000,
}),
},
);
const firstRequest = request(app)
.post("/api/issues/issue-availability/file-resources/availability")
.send({ queries: [{ path: "README.md" }] });
const firstResponse = firstRequest.then((response) => response);
await availabilityStarted;
expect((await request(app)
.post("/api/issues/issue-availability/file-resources/availability")
.send({ queries: [{ path: "README.md" }] })).status).toBe(429);
releaseSlowAvailability?.();
expect((await firstResponse).status).toBe(200);
expect((await request(app)
.post("/api/issues/issue-availability/file-resources/availability")
.send({ queries: [{ path: "README.md" }] })).status).toBe(429);
});
});

View File

@ -4,19 +4,27 @@ import { Router } from "express";
import { ZodError } from "zod";
import type { Db } from "@paperclipai/db";
import {
workspaceFileAvailabilityRequestSchema,
workspaceFileListQuerySchema,
workspaceFileResourceQuerySchema,
type ResolvedWorkspaceResource,
type WorkspaceFileAvailabilityRequestInput,
type WorkspaceFileAvailabilityResponse,
type WorkspaceFileContent,
type WorkspaceFileListResponse,
} from "@paperclipai/shared";
import { HttpError, notFound, unprocessable } from "../errors.js";
import { badRequest, HttpError, notFound, unprocessable } from "../errors.js";
import { workspaceFileResourceService } from "../services/index.js";
import { assertBoard, getActorInfo, hasCompanyAccess } from "./authz.js";
import { logActivity } from "../services/activity-log.js";
export type WorkspaceFileResourceService = {
getIssue(issueId: string): Promise<{ companyId: string }>;
availability(
issueId: string,
input: WorkspaceFileAvailabilityRequestInput,
opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
): Promise<WorkspaceFileAvailabilityResponse>;
list(issueId: string, input: {
workspace?: "auto" | "execution" | "project" | null;
projectId?: string | null;
@ -107,6 +115,20 @@ export function createFileResourceListLimiter(opts: {
});
}
export function createFileResourceAvailabilityLimiter(opts: {
maxConcurrent?: number;
maxRequests?: number;
windowMs?: number;
} = {}): FileResourceLimiter {
return createFileResourceLimiter({
maxConcurrent: opts.maxConcurrent ?? 2,
maxRequests: opts.maxRequests ?? 60,
windowMs: opts.windowMs,
requestLimitMessage: "Too many workspace file availability requests",
concurrencyLimitMessage: "Too many concurrent workspace file availability requests",
});
}
function limiterKey(companyId: string, actorId: string, issueId: string) {
return `${companyId}:${actorId}:${issueId}`;
}
@ -170,6 +192,20 @@ function readListQuery(query: unknown) {
};
}
function readAvailabilityBody(body: unknown) {
try {
return workspaceFileAvailabilityRequestSchema.parse(body);
} catch (error) {
if (error instanceof ZodError) {
throw badRequest("Workspace file availability request is invalid", {
code: "invalid_availability_request",
issues: error.issues,
});
}
throw error;
}
}
function activityDetails(input: {
outcome: "success" | "denied" | "unavailable";
workspaceKind?: string | null;
@ -222,6 +258,30 @@ function listActivityDetails(input: {
};
}
function availabilityActivityDetails(input: {
outcome: "success" | "denied";
requestedCount: number;
uniqueCount?: number;
openableCount?: number;
unavailableCount?: number;
denialReason?: string | null;
}) {
return {
outcome: input.outcome,
requestedCount: input.requestedCount,
...(typeof input.uniqueCount === "number" ? { uniqueCount: input.uniqueCount } : {}),
...(typeof input.openableCount === "number" ? { openableCount: input.openableCount } : {}),
...(typeof input.unavailableCount === "number" ? { unavailableCount: input.unavailableCount } : {}),
...(input.denialReason ? { denialReason: input.denialReason } : {}),
};
}
function safeAvailabilityRequestCount(body: unknown) {
if (!body || typeof body !== "object") return 0;
const queries = (body as { queries?: unknown }).queries;
return Array.isArray(queries) ? queries.length : 0;
}
function safeListAuditQuery(query: unknown): {
workspace: "auto" | "execution" | "project";
mode: "all" | "recent" | "changed";
@ -268,11 +328,46 @@ export function fileResourceRoutes(db: Db, opts: {
service?: WorkspaceFileResourceService;
limiter?: FileResourceLimiter;
listLimiter?: FileResourceLimiter;
availabilityLimiter?: FileResourceLimiter;
} = {}) {
const router = Router();
const svc = opts.service ?? workspaceFileResourceService(db);
const limiter = opts.limiter ?? createFileResourceLimiter();
const listLimiter = opts.listLimiter ?? createFileResourceListLimiter();
const availabilityLimiter = opts.availabilityLimiter ?? createFileResourceAvailabilityLimiter();
async function logAvailabilityAttempt(input: {
companyId: string;
actor: ReturnType<typeof getActorInfo>;
issueId: string;
outcome: "success" | "denied";
requestedCount: number;
result?: WorkspaceFileAvailabilityResponse;
error?: unknown;
}) {
const openableCount = input.result?.results.filter((result) => result.openable).length;
await logActivity(db, {
companyId: input.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
action: input.outcome === "success"
? "issue.file_resource_availability"
: "issue.file_resource_availability_denied",
entityType: "issue",
entityId: input.issueId,
agentId: input.actor.agentId,
runId: input.actor.runId,
agentApiKeyId: input.actor.agentApiKeyId,
details: availabilityActivityDetails({
outcome: input.outcome,
requestedCount: input.requestedCount,
uniqueCount: input.result?.results.length,
openableCount,
unavailableCount: input.result ? input.result.results.length - (openableCount ?? 0) : undefined,
denialReason: input.error ? denialReasonFromError(input.error) : null,
}),
});
}
async function logDeniedAttempt(input: {
companyId: string;
@ -331,6 +426,82 @@ export function fileResourceRoutes(db: Db, opts: {
});
}
router.post("/issues/:issueId/file-resources/availability", async (req, res) => {
const requestedCount = safeAvailabilityRequestCount(req.body);
try {
assertBoard(req);
} catch (error) {
if (req.actor.type === "agent" && req.actor.companyId) {
await logAvailabilityAttempt({
companyId: req.actor.companyId,
actor: getActorInfo(req),
issueId: req.params.issueId,
outcome: "denied",
requestedCount,
error,
});
}
throw error;
}
const issue = await svc.getIssue(req.params.issueId);
const actor = getActorInfo(req);
if (!hasCompanyAccess(req, issue.companyId)) {
const error = notFound("Issue not found");
await logAvailabilityAttempt({
companyId: issue.companyId,
actor,
issueId: req.params.issueId,
outcome: "denied",
requestedCount,
error,
});
throw error;
}
let body: ReturnType<typeof readAvailabilityBody>;
try {
body = readAvailabilityBody(req.body);
} catch (error) {
await logAvailabilityAttempt({
companyId: issue.companyId,
actor,
issueId: req.params.issueId,
outcome: "denied",
requestedCount,
error,
});
throw error;
}
let release: (() => void) | null = null;
try {
release = availabilityLimiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId));
const result = await svc.availability(req.params.issueId, body, { issue });
await logAvailabilityAttempt({
companyId: issue.companyId,
actor,
issueId: req.params.issueId,
outcome: "success",
requestedCount: body.queries.length,
result,
});
res.json(result);
} catch (error) {
await logAvailabilityAttempt({
companyId: issue.companyId,
actor,
issueId: req.params.issueId,
outcome: "denied",
requestedCount: body.queries.length,
error,
});
throw error;
} finally {
release?.();
}
});
router.get("/issues/:issueId/file-resources/list", async (req, res) => {
const auditQuery = safeListAuditQuery(req.query);
const auditTarget = safeAuditTarget(req.query);

View File

@ -12,7 +12,12 @@ export { agentRoutes } from "./agents.js";
export { projectRoutes } from "./projects.js";
export { issueRoutes } from "./issues.js";
export { issueTreeControlRoutes } from "./issue-tree-control.js";
export { fileResourceRoutes, createFileResourceLimiter } from "./file-resources.js";
export {
fileResourceRoutes,
createFileResourceAvailabilityLimiter,
createFileResourceLimiter,
createFileResourceListLimiter,
} from "./file-resources.js";
export { routineRoutes } from "./routines.js";
export { goalRoutes } from "./goals.js";
export { approvalRoutes } from "./approvals.js";

View File

@ -188,6 +188,8 @@ import {
secretProviderConfigDiscoveryPreviewSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
workspaceFileAvailabilityRequestSchema,
workspaceFileAvailabilityResponseSchema,
workspaceFileListQuerySchema,
workspaceFileResourceQuerySchema,
// Tool access
@ -812,6 +814,7 @@ const BOARD_ONLY_OPERATIONS = new Set([
"GET /api/secrets/{id}/usage",
"GET /api/secrets/{id}/access-events",
"POST /api/health/dev-server/restart",
"POST /api/issues/{issueId}/file-resources/availability",
"GET /api/issues/{issueId}/file-resources/content",
"GET /api/issues/{issueId}/file-resources/list",
"GET /api/issues/{issueId}/file-resources/resolve",
@ -2484,6 +2487,28 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
registry.registerPath({
method: "post",
path: "/api/issues/{issueId}/file-resources/availability",
tags: ["issues"],
summary: "Check whether issue workspace files can be opened",
request: {
params: z.object({ issueId: z.string() }),
body: {
required: true,
content: { "application/json": { schema: workspaceFileAvailabilityRequestSchema } },
},
},
responses: {
200: r.ok(workspaceFileAvailabilityResponseSchema),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
429: r.tooManyRequests,
},
});
registry.registerPath({
method: "get",
path: "/api/issues/{issueId}/file-resources/list",

View File

@ -6,7 +6,11 @@ import { and, desc, eq, inArray, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { executionWorkspaces, issues, projects, projectWorkspaces } from "@paperclipai/db";
import type {
NormalizedWorkspaceFileAvailabilityQuery,
ResolvedWorkspaceResource,
WorkspaceFileAvailabilityRequestInput,
WorkspaceFileAvailabilityResponse,
WorkspaceFileAvailabilityResult,
WorkspaceFileContent,
WorkspaceFileListItem,
WorkspaceFileListMode,
@ -22,6 +26,7 @@ export const WORKSPACE_FILE_MEDIA_MAX_BYTES = 10 * 1024 * 1024;
export const WORKSPACE_FILE_LIST_DEFAULT_LIMIT = 25;
export const WORKSPACE_FILE_LIST_MAX_LIMIT = 100;
export const WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES = 5_000;
export const WORKSPACE_FILE_AVAILABILITY_CONCURRENCY = 8;
const MAX_RELATIVE_PATH_BYTES = 4096;
const TEXT_SNIFF_BYTES = 4096;
const MAX_LIST_DEPTH = 20;
@ -147,12 +152,122 @@ type WorkspaceTargetInput = {
workspaceId?: string | null;
};
type WorkspaceFileAvailabilityQueryInput = WorkspaceFileAvailabilityRequestInput["queries"][number];
type PreparedAvailabilityQuery = {
query: NormalizedWorkspaceFileAvailabilityQuery;
normalizedPath: NormalizedPath | null;
directory: boolean;
key: string;
unavailableReason?: string;
};
type PreparedAvailabilityTarget =
| { candidate: WorkspaceCandidate; error?: never }
| { candidate?: never; error: HttpError };
function previewCapForKind(kind: WorkspaceFilePreviewKind) {
return kind === "image" || kind === "video" || kind === "pdf"
? WORKSPACE_FILE_MEDIA_MAX_BYTES
: WORKSPACE_FILE_TEXT_MAX_BYTES;
}
function safeRejectedAvailabilityPath(input: string) {
const trimmed = input.trim();
const slashPath = trimmed.replaceAll("\\", "/");
if (path.posix.isAbsolute(slashPath) || /^[a-zA-Z]:/.test(trimmed) || /^file:\/\//i.test(trimmed)) {
return path.posix.basename(slashPath) || "[invalid path]";
}
return trimmed;
}
function prepareAvailabilityQuery(input: WorkspaceFileAvailabilityQueryInput): PreparedAvailabilityQuery {
const directory = input.path.trim().endsWith("/");
const baseQuery: NormalizedWorkspaceFileAvailabilityQuery = {
path: input.path.trim(),
workspace: input.workspace ?? "auto",
projectId: input.projectId ?? null,
workspaceId: input.workspaceId ?? null,
};
try {
const normalizedPath = normalizeWorkspaceRelativePath(input.path);
const query = {
...baseQuery,
path: `${normalizedPath.relativePath}${directory ? "/" : ""}`,
};
return {
query,
normalizedPath,
directory,
key: JSON.stringify([query.workspace, query.projectId, query.workspaceId, query.path]),
};
} catch (error) {
const unavailableReason = expectedAvailabilityReason(error);
if (!unavailableReason) throw error;
const safePath = safeRejectedAvailabilityPath(input.path);
return {
query: { ...baseQuery, path: safePath },
normalizedPath: null,
directory,
key: JSON.stringify([baseQuery.workspace, baseQuery.projectId, baseQuery.workspaceId, baseQuery.path]),
unavailableReason,
};
}
}
function expectedAvailabilityReason(error: unknown): string | null {
if (!(error instanceof HttpError) || ![403, 404, 409, 422].includes(error.status)) return null;
if (error.details && typeof error.details === "object" && "code" in error.details) {
const code = (error.details as { code?: unknown }).code;
if (typeof code === "string" && code.length > 0) return code;
}
if (error.status === 403) return "forbidden";
if (error.status === 404) return "not_found";
if (error.status === 409) return "conflict";
return "unprocessable";
}
function availabilityResult(
query: NormalizedWorkspaceFileAvailabilityQuery,
resource: ResolvedWorkspaceResource,
): WorkspaceFileAvailabilityResult {
const openable = resource.kind === "file"
? resource.capabilities.preview
: resource.kind === "directory" && resource.capabilities.listChildren;
return {
query,
openable,
...(openable ? {} : { unavailableReason: resource.denialReason ?? "unsupported_resource" }),
resource,
};
}
function unavailableAvailabilityResult(
query: NormalizedWorkspaceFileAvailabilityQuery,
unavailableReason: string,
): WorkspaceFileAvailabilityResult {
return {
query,
openable: false,
unavailableReason,
resource: null,
};
}
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, mapper: (item: T) => Promise<R>) {
const results = new Array<R>(items.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(items[index]!);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
return results;
}
function relativePathFromReal(rootReal: string, targetReal: string) {
return path.relative(rootReal, targetReal).split(path.sep).join(path.posix.sep);
}
@ -994,6 +1109,51 @@ export function workspaceFileResourceService(db: Db) {
return candidates;
}
async function loadAvailabilityTargets(
issue: IssueRow,
queries: PreparedAvailabilityQuery[],
): Promise<Map<string, PreparedAvailabilityTarget>> {
const targetQueries = queries.filter(
(item) => !item.unavailableReason && item.query.projectId && item.query.workspaceId,
);
const projectIds = [...new Set(targetQueries.map((item) => item.query.projectId!))];
const workspaceIds = [...new Set(targetQueries.map((item) => item.query.workspaceId!))];
const [projectRows, workspaceRows] = await Promise.all([
projectIds.length > 0
? db.select().from(projects).where(inArray(projects.id, projectIds))
: Promise.resolve([]),
workspaceIds.length > 0
? db.select().from(projectWorkspaces).where(inArray(projectWorkspaces.id, workspaceIds))
: Promise.resolve([]),
]);
const projectById = new Map(projectRows.map((row) => [row.id, row]));
const workspaceById = new Map(workspaceRows.map((row) => [row.id, row]));
const targets = new Map<string, PreparedAvailabilityTarget>();
for (const item of targetQueries) {
const targetKey = `${item.query.projectId}:${item.query.workspaceId}`;
if (targets.has(targetKey)) continue;
const project = projectById.get(item.query.projectId!);
const workspace = workspaceById.get(item.query.workspaceId!);
if (!project || !workspace) {
targets.set(targetKey, { error: notFound("Project workspace not found") });
} else if (project.companyId !== issue.companyId || workspace.companyId !== issue.companyId) {
targets.set(targetKey, {
error: new HttpError(403, "Project workspace belongs to another company", { code: "cross_company_workspace" }),
});
} else if (workspace.projectId !== project.id) {
targets.set(targetKey, {
error: unprocessable("Workspace does not belong to the selected project", { code: "workspace_project_mismatch" }),
});
} else {
targets.set(targetKey, {
candidate: candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name }),
});
}
}
return targets;
}
async function sameCompanyProjectWorkspaceCandidates(
issue: IssueRow,
excludedWorkspaceIds: Set<string>,
@ -1051,6 +1211,108 @@ export function workspaceFileResourceService(db: Db) {
});
}
async function availability(
issueId: string,
input: WorkspaceFileAvailabilityRequestInput,
opts: { issue?: IssueRow } = {},
): Promise<WorkspaceFileAvailabilityResponse> {
const issue = opts.issue ?? await getIssue(issueId);
const uniqueQueries = new Map<string, PreparedAvailabilityQuery>();
for (const inputQuery of input.queries) {
const prepared = prepareAvailabilityQuery(inputQuery);
if (!uniqueQueries.has(prepared.key)) uniqueQueries.set(prepared.key, prepared);
}
const queries = [...uniqueQueries.values()];
if (queries.length === 0) return { kind: "workspace_file_availability", results: [] };
const untargetedQueries = queries.filter(
(item) => !item.unavailableReason && !item.query.projectId && !item.query.workspaceId,
);
const initialCandidates = untargetedQueries.length > 0 ? await listCandidates(issue, "auto") : [];
const needsDiscovery = initialCandidates.some((candidate) => !candidate.remote)
&& untargetedQueries.some((item) => item.query.workspace === "auto");
const discoveryCandidates = needsDiscovery
? await sameCompanyProjectWorkspaceCandidates(issue, new Set(initialCandidates.map((candidate) => candidate.workspaceId)))
: [];
const explicitTargets = await loadAvailabilityTargets(issue, queries);
const results = await mapWithConcurrency(
queries,
WORKSPACE_FILE_AVAILABILITY_CONCURRENCY,
async (item): Promise<WorkspaceFileAvailabilityResult> => {
try {
if (item.unavailableReason || !item.normalizedPath) {
return unavailableAvailabilityResult(item.query, item.unavailableReason ?? "invalid_path");
}
const explicitTarget = item.query.projectId && item.query.workspaceId
? explicitTargets.get(`${item.query.projectId}:${item.query.workspaceId}`)
: null;
if (explicitTarget?.error) throw explicitTarget.error;
const candidates = explicitTarget?.candidate
? [explicitTarget.candidate]
: initialCandidates.filter((candidate) => {
if (item.query.workspace === "execution") return candidate.workspaceKind === "execution_workspace";
if (item.query.workspace === "project") return candidate.workspaceKind === "project_workspace";
return true;
});
if (candidates.length === 0) {
throw unprocessable("No workspace is available for this issue", { code: "no_workspace" });
}
const hasExplicitTarget = Boolean(explicitTarget?.candidate);
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.remote) {
if (hasExplicitTarget || item.query.workspace !== "auto") {
return availabilityResult(item.query, remoteResource(candidate, item.normalizedPath.relativePath));
}
continue;
}
try {
const resource = item.directory
? (await statLocalDirectory(candidate, item.normalizedPath)).resource
: (await statLocalCandidate(candidate, item.normalizedPath)).resource;
return availabilityResult(item.query, resource);
} catch (error) {
if (!hasExplicitTarget && item.query.workspace === "auto" && isHttpStatus(error, 404)) {
lastNotFound = error;
continue;
}
throw error;
}
}
if (lastNotFound && !hasExplicitTarget && item.query.workspace === "auto") {
const matches: ResolvedWorkspaceResource[] = [];
for (const candidate of discoveryCandidates) {
if (candidate.remote) continue;
try {
matches.push(item.directory
? (await statLocalDirectory(candidate, item.normalizedPath)).resource
: (await statLocalCandidate(candidate, item.normalizedPath)).resource);
} catch (error) {
if (isHttpStatus(error, 404)) continue;
throw error;
}
if (matches.length > 1) throwAmbiguousWorkspacePath(matches.length);
}
if (matches[0]) return availabilityResult(item.query, matches[0]);
}
if (lastNotFound) throw lastNotFound;
throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" });
} catch (error) {
const reason = expectedAvailabilityReason(error);
if (!reason) throw error;
return unavailableAvailabilityResult(item.query, reason);
}
},
);
return { kind: "workspace_file_availability", results };
}
async function resolve(issueId: string, input: {
path: string;
workspace?: WorkspaceFileSelector | null;
@ -1443,6 +1705,7 @@ export function workspaceFileResourceService(db: Db) {
return {
getIssue,
availability,
list,
resolve,
readContent,

View File

@ -1,5 +1,6 @@
import type {
ResolvedWorkspaceResource,
WorkspaceFileAvailabilityResponse,
WorkspaceFileContent,
WorkspaceFileListMode,
WorkspaceFileListResponse,
@ -57,6 +58,25 @@ export const fileResourcesApi = {
);
},
/**
* Batch preflight for auto-detected workspace file references. Callers must
* deduplicate and chunk to the server's 100-query cap before calling.
*/
availability(issueId: string, queries: FileResourceQuery[]): Promise<WorkspaceFileAvailabilityResponse> {
return api.post<WorkspaceFileAvailabilityResponse>(
`/issues/${encodeURIComponent(issueId)}/file-resources/availability`,
{
queries: queries.map((query) => ({
path: query.path,
...(query.workspace && query.workspace !== "auto" ? { workspace: query.workspace } : {}),
...(query.projectId && query.workspaceId
? { projectId: query.projectId, workspaceId: query.workspaceId }
: {}),
})),
},
);
},
resolve(issueId: string, query: FileResourceQuery): Promise<ResolvedWorkspaceResource> {
return api.get<ResolvedWorkspaceResource>(
`/issues/${encodeURIComponent(issueId)}/file-resources/resolve?${buildQuery(query)}`,

View File

@ -15,6 +15,15 @@ import {
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
import { queryKeys } from "../lib/queryKeys";
import type { WorkspaceFileAvailabilityTarget } from "../lib/workspace-file-availability";
/** Stands in for a server-confirmed openable reference in the issue's workspace. */
const OPENABLE_AUTO_TARGET: WorkspaceFileAvailabilityTarget = {
workspace: "auto",
projectId: null,
workspaceId: null,
projectName: null,
};
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
@ -317,7 +326,7 @@ describe("MarkdownBody", () => {
const html = renderMarkdown(
"- **MP4**: [`videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4`](/PAP/issues/PAP-10306 \"Publish handoff\")",
[{ identifier: "PAP-10306", status: "in_review", title: "Publish handoff" }],
{ linkWorkspaceFileRefs: true },
{ resolveWorkspaceFileRef: () => OPENABLE_AUTO_TARGET },
);
expect(html).toContain('data-workspace-file-link="true"');
@ -328,6 +337,49 @@ describe("MarkdownBody", () => {
expect(html).not.toContain('href="/issues/PAP-10306"');
});
it("renders auto-detected workspace paths as plain code without an availability resolver", () => {
const html = renderMarkdown("Check `ui/src/pages/IssueDetail.tsx:42` please.");
expect(html).not.toContain("data-workspace-file-link");
expect(html).not.toContain("paperclip-workspace-file-link");
expect(html).toContain("ui/src/pages/IssueDetail.tsx:42");
});
it("keeps a non-openable auto-detected path as plain code with no chip affordances", () => {
const html = renderMarkdown(
"Check `ui/src/pages/IssueDetail.tsx:42` please.",
[],
{ resolveWorkspaceFileRef: () => null },
);
expect(html).not.toContain("data-workspace-file-link");
expect(html).not.toContain('role="button"');
expect(html).not.toContain("paperclip-workspace-file-link");
expect(html).toContain("<code");
});
it("keeps an explicit markdown link ordinary when its path is not openable", () => {
const html = renderMarkdown(
"See [`ui/src/a.ts:1`](/PAP/issues/PAP-10306)",
[{ identifier: "PAP-10306", status: "todo" }],
{ resolveWorkspaceFileRef: () => null },
);
expect(html).not.toContain("data-workspace-file-link");
expect(html).toContain('href="/issues/PAP-10306"');
});
it("promotes an openable auto-detected path to a workspace file chip", () => {
const html = renderMarkdown(
"Check `ui/src/pages/IssueDetail.tsx:42` please.",
[],
{ resolveWorkspaceFileRef: () => OPENABLE_AUTO_TARGET },
);
expect(html).toContain('data-workspace-file-link="true"');
expect(html).toContain('data-workspace-file-path="ui/src/pages/IssueDetail.tsx"');
});
it("keeps trailing punctuation outside auto-linked issue references", () => {
const html = renderMarkdown("See PAP-1271: /issues/PAP-1272] and issue://PAP-1273.", [
{ identifier: "PAP-1271", status: "done" },

View File

@ -21,7 +21,12 @@ function caseIdentifierFromHref(href: string | undefined): string | null {
const match = decodeURIComponent(href.trim()).match(CASE_HREF_RE);
return match ? match[1]!.toUpperCase() : null;
}
import { parseWorkspaceFileHref, remarkWorkspaceFileRefs, WORKSPACE_FILE_HREF_PREFIX } from "../lib/remark-workspace-file-refs";
import {
createRemarkWorkspaceFileRefs,
parseWorkspaceFileHref,
WORKSPACE_FILE_HREF_PREFIX,
type WorkspaceFileRefResolver,
} from "../lib/remark-workspace-file-refs";
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
import { StatusIcon } from "./StatusIcon";
import { WorkspaceFileLink } from "./WorkspaceFileLink";
@ -84,8 +89,15 @@ interface MarkdownBodyProps {
resolveImageSrc?: (src: string) => string | null;
/** Called when a user clicks an inline image */
onImageClick?: (src: string) => void;
/** Link inline-code workspace file paths to the issue file viewer. */
linkWorkspaceFileRefs?: boolean;
/**
* Resolver that decides which inline-code workspace file paths may be linked
* to the issue file viewer. Omitting it (or returning null) leaves every
* path-shaped code span as ordinary inline code the fail-closed default.
*
* Its identity must change when previously-pending references become
* openable, so the markdown re-parses with the new answers.
*/
resolveWorkspaceFileRef?: WorkspaceFileRefResolver;
}
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
@ -706,7 +718,7 @@ function MarkdownBodyImpl({
externalReferences,
resolveImageSrc,
onImageClick,
linkWorkspaceFileRefs = false,
resolveWorkspaceFileRef,
}: MarkdownBodyProps) {
const { theme } = useTheme();
// Read company prefixes non-throwingly: MarkdownBody renders in surfaces that
@ -740,8 +752,8 @@ function MarkdownBodyImpl({
if (enableWikiLinks) {
plugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
}
if (linkWorkspaceFileRefs) {
plugins.push(remarkWorkspaceFileRefs);
if (resolveWorkspaceFileRef) {
plugins.push(createRemarkWorkspaceFileRefs(resolveWorkspaceFileRef));
}
if (linkIssueReferences) {
plugins.push([remarkLinkIssueReferences, { knownPrefixes }]);
@ -753,7 +765,7 @@ function MarkdownBodyImpl({
plugins.push(remarkSoftBreaks);
}
return plugins;
}, [enableWikiLinks, wikiLinkRoot, resolveWikiLinkHref, linkWorkspaceFileRefs, linkIssueReferences, linkCaseReferences, knownPrefixes, softBreaks]);
}, [enableWikiLinks, wikiLinkRoot, resolveWikiLinkHref, resolveWorkspaceFileRef, linkIssueReferences, linkCaseReferences, knownPrefixes, softBreaks]);
const components = useMemo<Components>(() => {
const map: Components = {
p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => (

View File

@ -54,7 +54,9 @@ export function WorkspaceFileLink({
path: workspaceFileRef.path,
line: workspaceFileRef.line ?? null,
column: workspaceFileRef.column ?? null,
workspace: "auto",
// Preserve the workspace that passed the availability preflight so the
// click resolves against that target instead of rediscovering one.
workspace: workspaceFileRef.workspace ?? "auto",
projectId: workspaceFileRef.projectId ?? null,
workspaceId: workspaceFileRef.workspaceId ?? null,
});

View File

@ -0,0 +1,365 @@
// @vitest-environment jsdom
import { StrictMode, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
ResolvedWorkspaceResource,
WorkspaceFileAvailabilityResponse,
WorkspaceFileAvailabilityResult,
} from "@paperclipai/shared";
import type { FileResourceQuery } from "@/api/file-resources";
const mockAvailability = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
vi.mock("@/api/file-resources", () => ({
fileResourcesApi: { availability: mockAvailability },
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
useLocation: () => ({ pathname: "/PAP/issues/PAP-1", search: "", hash: "", state: null }),
useNavigate: () => mockNavigate,
useCaseHref: () => (identifier: string) => `/cases/${identifier}`,
}));
vi.mock("../context/CompanyContext", () => ({
useOptionalCompany: () => null,
}));
import { FileViewerProvider } from "@/context/FileViewerContext";
import { ThemeProvider } from "@/context/ThemeContext";
import { WorkspaceFileMarkdownBody } from "./WorkspaceFileMarkdownBody";
const ISSUE_ID = "3fb1a3f4-3f0e-4c58-8d3e-1c2f0f5a9b11";
const PROJECT_ID = "17acae7d-9d0c-46bf-9c82-be9694ac3461";
const WORKSPACE_ID = "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2";
function act(callback: () => void) {
flushSync(callback);
}
async function waitForExpectation(assertion: () => void) {
let lastError: unknown;
for (let attempt = 0; attempt < 30; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
}
throw lastError;
}
function resource(overrides: Partial<ResolvedWorkspaceResource> = {}): ResolvedWorkspaceResource {
return {
kind: "file",
provider: "git_worktree",
title: "a.ts",
displayPath: "ui/src/a.ts",
workspaceLabel: "Execution workspace",
workspaceKind: "execution_workspace",
workspaceId: WORKSPACE_ID,
previewKind: "text",
capabilities: { preview: true, download: true, listChildren: false },
...overrides,
};
}
function openable(path: string, overrides: Partial<ResolvedWorkspaceResource> = {}): WorkspaceFileAvailabilityResult {
return {
query: { path, workspace: "auto", projectId: null, workspaceId: null },
openable: true,
resource: resource({ displayPath: path, ...overrides }),
};
}
function unavailable(path: string, reason: string): WorkspaceFileAvailabilityResult {
return {
query: { path, workspace: "auto", projectId: null, workspaceId: null },
openable: false,
unavailableReason: reason,
resource: null,
};
}
function respondWith(results: WorkspaceFileAvailabilityResult[]): WorkspaceFileAvailabilityResponse {
return { kind: "workspace_file_availability", results };
}
/** Echo every requested path back as openable. */
function echoOpenable(_issueId: string, queries: FileResourceQuery[]) {
return Promise.resolve(respondWith(queries.map((query) => openable(query.path))));
}
let container: HTMLDivElement;
let root: Root;
function render(children: ReactNode) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<FileViewerProvider issueId={ISSUE_ID}>{children}</FileViewerProvider>
</ThemeProvider>
</QueryClientProvider>,
);
});
return queryClient;
}
function chips() {
return [...container.querySelectorAll('[data-workspace-file-link="true"]')];
}
function chipPaths() {
return chips().map((chip) => chip.getAttribute("data-workspace-file-path"));
}
beforeEach(() => {
mockAvailability.mockReset();
mockNavigate.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
describe("WorkspaceFileMarkdownBody availability gating", () => {
it("renders plain inline code until the batch confirms the reference", async () => {
let resolveBatch: ((value: WorkspaceFileAvailabilityResponse) => void) | undefined;
mockAvailability.mockReturnValue(new Promise((resolve) => { resolveBatch = resolve; }));
render(<WorkspaceFileMarkdownBody>{"Check `ui/src/a.ts:42` please."}</WorkspaceFileMarkdownBody>);
// Pending: no chip, no icon, no button role — just code.
expect(chips()).toHaveLength(0);
expect(container.querySelector("code")?.textContent).toBe("ui/src/a.ts:42");
expect(container.querySelector("svg")).toBeNull();
expect(container.querySelector('[role="button"]')).toBeNull();
await waitForExpectation(() => expect(mockAvailability).toHaveBeenCalledTimes(1));
act(() => resolveBatch!(respondWith([openable("ui/src/a.ts")])));
await waitForExpectation(() => expect(chipPaths()).toEqual(["ui/src/a.ts"]));
});
it("keeps unavailable, denied, and unsupported references as plain code", async () => {
mockAvailability.mockResolvedValue(respondWith([
unavailable("ui/src/missing.ts", "not_found"),
unavailable("ui/src/denied.ts", "forbidden"),
unavailable("ui/src/remote.ts", "unsupported_resource"),
]));
render(
<WorkspaceFileMarkdownBody>
{"See `ui/src/missing.ts:1`, `ui/src/denied.ts:2` and `ui/src/remote.ts:3`."}
</WorkspaceFileMarkdownBody>,
);
await waitForExpectation(() => expect(mockAvailability).toHaveBeenCalledTimes(1));
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(chips()).toHaveLength(0);
expect(container.querySelectorAll("code")).toHaveLength(3);
});
it("fails closed when the availability batch errors", async () => {
mockAvailability.mockRejectedValue(new Error("boom"));
render(<WorkspaceFileMarkdownBody>{"Check `ui/src/a.ts:42`."}</WorkspaceFileMarkdownBody>);
await waitForExpectation(() => expect(mockAvailability).toHaveBeenCalledTimes(1));
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(chips()).toHaveLength(0);
expect(container.querySelector("code")?.textContent).toBe("ui/src/a.ts:42");
});
it("checks a reference duplicated across comments exactly once", async () => {
mockAvailability.mockImplementation(echoOpenable);
render(
<>
<WorkspaceFileMarkdownBody>{"First `ui/src/a.ts:1`."}</WorkspaceFileMarkdownBody>
<WorkspaceFileMarkdownBody>{"Second `ui/src/a.ts:1` again."}</WorkspaceFileMarkdownBody>
<WorkspaceFileMarkdownBody>{"Third `ui/src/a.ts:9` at another line."}</WorkspaceFileMarkdownBody>
</>,
);
await waitForExpectation(() => expect(chipPaths()).toHaveLength(3));
// One coalesced request; the line suffix is not part of the lookup, so the
// three references collapse to a single query.
expect(mockAvailability).toHaveBeenCalledTimes(1);
expect(mockAvailability.mock.calls[0]![1]).toEqual([
{ path: "ui/src/a.ts", workspace: "auto", projectId: null, workspaceId: null },
]);
});
it("chunks more than 100 unique references", async () => {
mockAvailability.mockImplementation(echoOpenable);
const paths = Array.from({ length: 150 }, (_, index) => `ui/src/file-${index}.ts`);
render(
<WorkspaceFileMarkdownBody>
{paths.map((path) => `\`${path}\``).join(" ")}
</WorkspaceFileMarkdownBody>,
);
await waitForExpectation(() => expect(mockAvailability).toHaveBeenCalledTimes(2));
const sizes = mockAvailability.mock.calls.map((call) => (call[1] as FileResourceQuery[]).length);
expect(sizes).toEqual([100, 50]);
await waitForExpectation(() => expect(chips()).toHaveLength(150));
});
it("runs at most two availability chunks concurrently", async () => {
let activeRequests = 0;
let maxActiveRequests = 0;
const resolveRequests: Array<() => void> = [];
mockAvailability.mockImplementation((_issueId: string, queries: FileResourceQuery[]) => {
activeRequests += 1;
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
return new Promise<WorkspaceFileAvailabilityResponse>((resolve) => {
resolveRequests.push(() => {
activeRequests -= 1;
resolve(respondWith(queries.map((query) => openable(query.path))));
});
});
});
const paths = Array.from({ length: 250 }, (_, index) => `ui/src/file-${index}.ts`);
render(
<WorkspaceFileMarkdownBody>
{paths.map((path) => `\`${path}\``).join(" ")}
</WorkspaceFileMarkdownBody>,
);
await waitForExpectation(() => expect(mockAvailability).toHaveBeenCalledTimes(2));
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(maxActiveRequests).toBe(2);
act(() => resolveRequests[0]!());
await waitForExpectation(() => expect(mockAvailability).toHaveBeenCalledTimes(3));
expect(activeRequests).toBe(2);
expect(maxActiveRequests).toBe(2);
act(() => {
resolveRequests[1]!();
resolveRequests[2]!();
});
await waitForExpectation(() => expect(chips()).toHaveLength(250));
expect(maxActiveRequests).toBe(2);
});
it("checks only unseen references when a new comment arrives", async () => {
mockAvailability.mockImplementation(echoOpenable);
const first = <WorkspaceFileMarkdownBody>{"First `ui/src/a.ts:1`."}</WorkspaceFileMarkdownBody>;
const queryClient = render(first);
await waitForExpectation(() => expect(chipPaths()).toEqual(["ui/src/a.ts"]));
expect(mockAvailability).toHaveBeenCalledTimes(1);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<FileViewerProvider issueId={ISSUE_ID}>
{first}
<WorkspaceFileMarkdownBody>{"New `ui/src/a.ts:1` and `ui/src/b.ts:2`."}</WorkspaceFileMarkdownBody>
</FileViewerProvider>
</ThemeProvider>
</QueryClientProvider>,
);
});
await waitForExpectation(() => expect(mockAvailability).toHaveBeenCalledTimes(2));
expect(mockAvailability.mock.calls[1]![1]).toEqual([
{ path: "ui/src/b.ts", workspace: "auto", projectId: null, workspaceId: null },
]);
});
it("binds the confirmed project workspace to the chip and opens it on click", async () => {
mockAvailability.mockResolvedValue(respondWith([
{
query: { path: "ui/src/a.ts", workspace: "auto", projectId: null, workspaceId: null },
openable: true,
resource: resource({
workspaceKind: "project_workspace",
workspaceId: WORKSPACE_ID,
projectId: PROJECT_ID,
projectName: "Paperclip App",
}),
},
]));
render(<WorkspaceFileMarkdownBody>{"Check `ui/src/a.ts:42`."}</WorkspaceFileMarkdownBody>);
await waitForExpectation(() => expect(chips()).toHaveLength(1));
const chip = chips()[0] as HTMLAnchorElement;
const search = new URL(chip.href, window.location.href).searchParams;
expect(search.get("file")).toBe("ui/src/a.ts");
expect(search.get("line")).toBe("42");
expect(search.get("workspace")).toBe("project");
expect(search.get("projectId")).toBe(PROJECT_ID);
expect(search.get("workspaceId")).toBe(WORKSPACE_ID);
act(() => {
chip.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
});
expect(mockNavigate).toHaveBeenCalled();
const target = mockNavigate.mock.calls[0]![0] as { search: string };
const opened = new URLSearchParams(target.search);
expect(opened.get("file")).toBe("ui/src/a.ts");
expect(opened.get("workspace")).toBe("project");
expect(opened.get("projectId")).toBe(PROJECT_ID);
expect(opened.get("workspaceId")).toBe(WORKSPACE_ID);
});
it("still batches under StrictMode's remount", async () => {
mockAvailability.mockImplementation(echoOpenable);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
act(() => {
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<FileViewerProvider issueId={ISSUE_ID}>
<WorkspaceFileMarkdownBody>{"Check `ui/src/a.ts:42`."}</WorkspaceFileMarkdownBody>
</FileViewerProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
);
});
await waitForExpectation(() => expect(chipPaths()).toEqual(["ui/src/a.ts"]));
expect(mockAvailability).toHaveBeenCalledTimes(1);
});
it("rechecks after the issue's file resources are invalidated", async () => {
mockAvailability.mockImplementation(echoOpenable);
const queryClient = render(<WorkspaceFileMarkdownBody>{"Check `ui/src/a.ts:1`."}</WorkspaceFileMarkdownBody>);
await waitForExpectation(() => expect(chips()).toHaveLength(1));
expect(mockAvailability).toHaveBeenCalledTimes(1);
mockAvailability.mockResolvedValue(respondWith([unavailable("ui/src/a.ts", "not_found")]));
act(() => {
void queryClient.invalidateQueries({ queryKey: ["issues", "file-resources", ISSUE_ID] });
});
await waitForExpectation(() => expect(mockAvailability).toHaveBeenCalledTimes(2));
await waitForExpectation(() => expect(chips()).toHaveLength(0));
});
});

View File

@ -1,7 +1,8 @@
import type { MouseEvent } from "react";
import { useMemo, type MouseEvent } from "react";
import { readFileViewerStateFromSearch, useFileViewer } from "@/context/FileViewerContext";
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
import { buildWorkspaceFileHref } from "@/lib/remark-workspace-file-refs";
import { buildWorkspaceFileHref, type WorkspaceFileRefResolver } from "@/lib/remark-workspace-file-refs";
import { workspaceFileAvailabilityRef } from "@/lib/workspace-file-availability";
import { MarkdownBody } from "./MarkdownBody";
type MarkdownBodyProps = Parameters<typeof MarkdownBody>[0];
@ -25,6 +26,17 @@ export function WorkspaceFileMarkdownBody({
...props
}: MarkdownBodyProps) {
const viewer = useFileViewer();
const availability = viewer?.availability;
// Identity changes with the registry version, so completed batches re-parse
// the markdown and promote the references that came back openable.
const resolveWorkspaceFileRef = useMemo<WorkspaceFileRefResolver | undefined>(() => {
if (!availability) return undefined;
return (ref) => {
const result = availability.check(workspaceFileAvailabilityRef(ref));
return result.state === "openable" ? result.target : null;
};
}, [availability]);
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
if (!viewer) return;
@ -43,7 +55,7 @@ export function WorkspaceFileMarkdownBody({
return (
<div onClick={handleClick}>
<MarkdownBody {...props} linkWorkspaceFileRefs={!!viewer}>{children}</MarkdownBody>
<MarkdownBody {...props} resolveWorkspaceFileRef={resolveWorkspaceFileRef}>{children}</MarkdownBody>
</div>
);
}

View File

@ -2,6 +2,10 @@ import { createContext, useContext, useCallback, useMemo, type ReactNode } from
import { useLocation, useNavigate, type NavigateOptions } from "@/lib/router";
import type { WorkspaceFileSelector } from "@paperclipai/shared";
import type { ParsedWorkspaceFileRef } from "@/lib/workspace-file-parser";
import {
useWorkspaceFileAvailability,
type WorkspaceFileAvailabilityRegistry,
} from "@/hooks/useWorkspaceFileAvailability";
export interface FileViewerUrlState {
path: string;
@ -14,6 +18,12 @@ export interface FileViewerUrlState {
export interface FileViewerContextValue {
issueId: string;
/**
* Batched preflight registry. Auto-detected markdown file references consult
* it so a chip only renders once this issue's session can actually open the
* resolved target.
*/
availability: WorkspaceFileAvailabilityRegistry;
/** Current viewer state derived from the URL, or null if closed. */
state: FileViewerUrlState | null;
/** True when the sheet is in browse mode (URL carries `browse=1`). */
@ -204,6 +214,7 @@ function EnabledFileViewerProvider({ issueId, children }: Omit<FileViewerProvide
const navigate = useNavigate();
const state = useMemo(() => readFileViewerStateFromSearch(location.search), [location.search]);
const browseState = useMemo(() => readBrowseStateFromSearch(location.search), [location.search]);
const availability = useWorkspaceFileAvailability(issueId);
const navigateSearch = useCallback(
(nextSearch: string, opts?: Partial<NavigateOptions>) => {
@ -305,6 +316,7 @@ function EnabledFileViewerProvider({ issueId, children }: Omit<FileViewerProvide
const value = useMemo<FileViewerContextValue>(
() => ({
issueId,
availability,
state,
browse: browseState !== null,
query: browseState?.q ?? null,
@ -318,7 +330,7 @@ function EnabledFileViewerProvider({ issueId, children }: Omit<FileViewerProvide
backToFiles,
close,
}),
[issueId, state, browseState, open, openBrowse, updateBrowseState, openFolder, backToFiles, close],
[issueId, availability, state, browseState, open, openBrowse, updateBrowseState, openFolder, backToFiles, close],
);
return <FileViewerContext.Provider value={value}>{children}</FileViewerContext.Provider>;

View File

@ -0,0 +1,200 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { fileResourcesApi } from "@/api/file-resources";
import { queryKeys } from "@/lib/queryKeys";
import {
chunkWorkspaceFileAvailabilityRefs,
workspaceFileAvailabilityFromResult,
workspaceFileAvailabilityKey,
WORKSPACE_FILE_AVAILABILITY_PENDING,
WORKSPACE_FILE_AVAILABILITY_STALE_MS,
type WorkspaceFileAvailability,
type WorkspaceFileAvailabilityRef,
} from "@/lib/workspace-file-availability";
type QueuedRef = readonly [key: string, ref: WorkspaceFileAvailabilityRef];
type QueuedBatch = {
chunk: QueuedRef[];
issueId: string;
generation: number;
};
/** Matches the server's per-actor, per-issue availability concurrency limit. */
const WORKSPACE_FILE_AVAILABILITY_MAX_CONCURRENT = 2;
export interface WorkspaceFileAvailabilityRegistry {
/**
* Bumped whenever known results change. Consumers that memoize on the
* registry (remark plugins) must include it so a completed batch re-renders.
*/
version: number;
/**
* Read the availability of a reference, queueing a batched check the first
* time it is seen. Safe to call during render: it only mutates internal
* queues and schedules the request for the next macrotask.
*/
check(ref: WorkspaceFileAvailabilityRef): WorkspaceFileAvailability;
}
function isFileResourceKeyForIssue(queryKey: readonly unknown[], issueId: string) {
return queryKey[0] === "issues" && queryKey[1] === "file-resources" && queryKey[2] === issueId;
}
/**
* Issue-scoped registry of workspace-file availability results.
*
* References discovered while rendering markdown are deduplicated by key,
* coalesced into a single request per event-loop burst, chunked only above the
* server's 100-query cap, and cached on the shared file-resource query key so
* an invalidation of the issue's file resources forces a recheck.
*/
export function useWorkspaceFileAvailability(issueId: string): WorkspaceFileAvailabilityRegistry {
const queryClient = useQueryClient();
const [version, setVersion] = useState(0);
const resultsRef = useRef<Map<string, WorkspaceFileAvailability>>(new Map());
const queueRef = useRef<Map<string, WorkspaceFileAvailabilityRef>>(new Map());
const inFlightRef = useRef<Set<string>>(new Set());
const batchQueueRef = useRef<QueuedBatch[]>([]);
const activeBatchCountRef = useRef(0);
const flushHandleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeRef = useRef(true);
const generationRef = useRef(0);
const clientRef = useRef<QueryClient>(queryClient);
clientRef.current = queryClient;
// Reset during render rather than in an effect: children call `check()` while
// rendering, so an effect-based reset would discard the queue they just
// filled. A new issue scope must not inherit another issue's confirmations.
const issueIdRef = useRef(issueId);
if (issueIdRef.current !== issueId) {
issueIdRef.current = issueId;
generationRef.current += 1;
if (flushHandleRef.current !== null) {
clearTimeout(flushHandleRef.current);
flushHandleRef.current = null;
}
resultsRef.current.clear();
queueRef.current.clear();
inFlightRef.current.clear();
batchQueueRef.current = [];
}
const bump = useCallback(() => {
if (!activeRef.current) return;
setVersion((current) => current + 1);
}, []);
const runBatch = useCallback(
async ({ chunk, issueId: batchIssueId, generation }: QueuedBatch) => {
// Sorted keys give identical batches one cache entry regardless of the
// order the refs happened to be rendered in.
const refKeys = chunk.map(([key]) => key).sort();
try {
const response = await clientRef.current.fetchQuery({
queryKey: queryKeys.issues.fileResourceAvailability(batchIssueId, refKeys),
queryFn: () => fileResourcesApi.availability(batchIssueId, chunk.map(([, ref]) => ref)),
staleTime: WORKSPACE_FILE_AVAILABILITY_STALE_MS,
});
if (generation !== generationRef.current) return;
const byKey = new Map(
response.results.map((result) => [
workspaceFileAvailabilityKey(result.query),
workspaceFileAvailabilityFromResult(result),
]),
);
for (const [key] of chunk) {
// A reference the server did not echo back stays non-openable rather
// than falling through to a provisional link.
resultsRef.current.set(key, byKey.get(key) ?? { state: "unavailable", reason: "unmatched_reference" });
inFlightRef.current.delete(key);
}
bump();
} catch {
if (generation !== generationRef.current) return;
// Fail closed: the refs stay unresolved and render as plain inline code.
// Releasing them from the in-flight set lets a later render burst retry
// without looping, since a failure never triggers a re-render itself.
for (const [key] of chunk) inFlightRef.current.delete(key);
}
},
[bump],
);
const drainBatchQueue = useCallback(() => {
while (
activeBatchCountRef.current < WORKSPACE_FILE_AVAILABILITY_MAX_CONCURRENT
&& batchQueueRef.current.length > 0
) {
const batch = batchQueueRef.current.shift()!;
activeBatchCountRef.current += 1;
void runBatch(batch).finally(() => {
activeBatchCountRef.current -= 1;
drainBatchQueue();
});
}
}, [runBatch]);
const flush = useCallback(() => {
flushHandleRef.current = null;
const queued = [...queueRef.current.entries()] as QueuedRef[];
queueRef.current.clear();
const fresh = queued.filter(([key]) => !inFlightRef.current.has(key) && !resultsRef.current.has(key));
if (fresh.length === 0) return;
for (const [key] of fresh) inFlightRef.current.add(key);
batchQueueRef.current.push(
...chunkWorkspaceFileAvailabilityRefs(fresh).map((chunk) => ({
chunk,
issueId,
generation: generationRef.current,
})),
);
drainBatchQueue();
}, [drainBatchQueue, issueId]);
const check = useCallback<WorkspaceFileAvailabilityRegistry["check"]>(
(ref) => {
const key = workspaceFileAvailabilityKey(ref);
const known = resultsRef.current.get(key);
if (known) return known;
if (!inFlightRef.current.has(key) && !queueRef.current.has(key)) {
queueRef.current.set(key, ref);
if (flushHandleRef.current === null) {
flushHandleRef.current = setTimeout(flush, 0);
}
}
return WORKSPACE_FILE_AVAILABILITY_PENDING;
},
[flush],
);
useEffect(() => {
activeRef.current = true;
// A remount (StrictMode, suspense replay) tears down the timer scheduled by
// the render that filled the queue; reschedule so those refs aren't stranded.
if (queueRef.current.size > 0 && flushHandleRef.current === null) {
flushHandleRef.current = setTimeout(flush, 0);
}
return () => {
activeRef.current = false;
if (flushHandleRef.current !== null) {
clearTimeout(flushHandleRef.current);
flushHandleRef.current = null;
}
};
}, [flush]);
// Recheck when the issue's file resources are invalidated (workspace changed,
// run finished, viewer refreshed). Only `invalidate` actions are handled so
// writing our own batch results cannot loop.
useEffect(() => {
return queryClient.getQueryCache().subscribe((event) => {
if (event.type !== "updated" || event.action.type !== "invalidate") return;
if (!isFileResourceKeyForIssue(event.query.queryKey, issueId)) return;
if (resultsRef.current.size === 0) return;
resultsRef.current.clear();
bump();
});
}, [bump, issueId, queryClient]);
return useMemo(() => ({ version, check }), [version, check]);
}

View File

@ -227,6 +227,13 @@ export const queryKeys = {
query: { path: string; workspace?: string; projectId?: string | null; workspaceId?: string | null },
) =>
["issues", "file-resources", issueId, "content", query] as const,
/**
* Batched availability preflight. `refKeys` are the deduplicated,
* lexicographically sorted reference keys in the request so identical
* batches share one cache entry.
*/
fileResourceAvailability: (issueId: string, refKeys: readonly string[]) =>
["issues", "file-resources", issueId, "availability", refKeys] as const,
},
routines: {
list: (companyId: string, filters?: { projectId?: string | null }) =>

View File

@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
buildWorkspaceFileHref,
createRemarkWorkspaceFileRefs,
parseWorkspaceFileHref,
remarkWorkspaceFileRefs,
type WorkspaceFileRefResolver,
} from "./remark-workspace-file-refs";
import type { WorkspaceFileAvailabilityTarget } from "./workspace-file-availability";
type MarkdownNode = {
type: string;
@ -12,6 +14,19 @@ type MarkdownNode = {
children?: MarkdownNode[];
};
const AUTO_TARGET: WorkspaceFileAvailabilityTarget = {
workspace: "auto",
projectId: null,
workspaceId: null,
projectName: null,
};
/** Resolver standing in for "the server confirmed every reference is openable". */
const resolveAllOpenable: WorkspaceFileRefResolver = () => AUTO_TARGET;
/** Fail-closed resolver: nothing is openable. */
const resolveNoneOpenable: WorkspaceFileRefResolver = () => null;
function textNode(value: string): MarkdownNode {
return { type: "text", value };
}
@ -24,8 +39,8 @@ function paragraph(children: MarkdownNode[]): MarkdownNode {
return { type: "paragraph", children };
}
function runPlugin(tree: MarkdownNode): MarkdownNode {
const transform = remarkWorkspaceFileRefs();
function runPlugin(tree: MarkdownNode, resolve: WorkspaceFileRefResolver = resolveAllOpenable): MarkdownNode {
const transform = createRemarkWorkspaceFileRefs(resolve)();
transform(tree);
return tree;
}
@ -162,4 +177,97 @@ describe("remarkWorkspaceFileRefs", () => {
expect(link.url).toBe("https://example.com");
expect(link.children![1]?.type).toBe("inlineCode");
});
describe("availability gating", () => {
it("leaves unresolved inline code as plain code", () => {
const tree = paragraph([
textNode("Check "),
inlineCode("ui/src/pages/IssueDetail.tsx:42"),
textNode(" please."),
]);
runPlugin(tree, resolveNoneOpenable);
expect(tree.children).toHaveLength(3);
expect(tree.children![1]).toEqual(inlineCode("ui/src/pages/IssueDetail.tsx:42"));
});
it("leaves an ordinary markdown link untouched when the ref is not openable", () => {
const tree: MarkdownNode = {
type: "paragraph",
children: [
{
type: "link",
url: "/PAP/issues/PAP-10306",
children: [inlineCode("ui/src/a.ts:1")],
},
],
};
runPlugin(tree, resolveNoneOpenable);
expect(tree.children![0].url).toBe("/PAP/issues/PAP-10306");
expect(tree.children![0].children![0]?.type).toBe("inlineCode");
});
it("promotes only the references the resolver confirms", () => {
const tree = paragraph([
inlineCode("ui/src/present.ts:1"),
textNode(" and "),
inlineCode("ui/src/missing.ts:2"),
]);
runPlugin(tree, (ref) => (ref.path === "ui/src/present.ts" ? AUTO_TARGET : null));
expect(tree.children![0].type).toBe("link");
expect(tree.children![2].type).toBe("inlineCode");
});
it("binds the resolved workspace target to the generated viewer href", () => {
const tree = paragraph([inlineCode("ui/src/a.ts:7")]);
runPlugin(tree, () => ({
workspace: "project",
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
projectName: "Paperclip Content",
}));
expect(parseWorkspaceFileHref(tree.children![0].url)).toMatchObject({
path: "ui/src/a.ts",
line: 7,
workspace: "project",
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
});
});
it("binds an execution-workspace target by selector without ids", () => {
const tree = paragraph([inlineCode("ui/src/a.ts:7")]);
runPlugin(tree, () => ({
workspace: "execution",
projectId: null,
workspaceId: null,
projectName: null,
}));
const parsed = parseWorkspaceFileHref(tree.children![0].url);
expect(parsed?.workspace).toBe("execution");
expect(parsed?.projectId).toBeNull();
expect(parsed?.workspaceId).toBeNull();
});
it("asks the resolver once per candidate reference and never for non-paths", () => {
const resolve = vi.fn(resolveAllOpenable);
const tree = paragraph([
inlineCode("ui/src/a.ts:1"),
textNode(" then "),
inlineCode("pnpm test"),
]);
runPlugin(tree, resolve);
expect(resolve).toHaveBeenCalledTimes(1);
expect(resolve.mock.calls[0]![0]).toMatchObject({ path: "ui/src/a.ts" });
});
it("does not consult the resolver inside fenced code blocks", () => {
const resolve = vi.fn(resolveAllOpenable);
const tree: MarkdownNode = {
type: "root",
children: [{ type: "code", value: "ui/src/a.ts:1", children: [inlineCode("ui/src/a.ts:1")] }],
};
runPlugin(tree, resolve);
expect(resolve).not.toHaveBeenCalled();
});
});
});

View File

@ -1,7 +1,18 @@
import type { WorkspaceFileSelector } from "@paperclipai/shared";
import { parseWorkspaceFileRef, type ParsedWorkspaceFileRef } from "./workspace-file-parser";
import type { WorkspaceFileAvailabilityTarget } from "./workspace-file-availability";
const WORKSPACE_FILE_HREF_SCHEME = "workspace-file:";
/**
* Decides whether a syntactically path-shaped reference may be promoted to a
* workspace-file link. Returning null keeps the original markdown node, which
* is the fail-closed default for pending, unavailable, and errored references.
*/
export type WorkspaceFileRefResolver = (
ref: ParsedWorkspaceFileRef,
) => WorkspaceFileAvailabilityTarget | null;
type MarkdownNode = {
type: string;
value?: string;
@ -13,6 +24,7 @@ export function buildWorkspaceFileHref(ref: ParsedWorkspaceFileRef): string {
const params = new URLSearchParams();
if (ref.projectId) params.set("projectId", ref.projectId);
if (ref.workspaceId) params.set("workspaceId", ref.workspaceId);
if (ref.workspace && ref.workspace !== "auto") params.set("workspace", ref.workspace);
if (ref.resourceKind === "directory") params.set("kind", "directory");
params.set("path", ref.path);
if (ref.line !== null) params.set("line", String(ref.line));
@ -33,6 +45,10 @@ export function parseWorkspaceFileHref(href: string | null | undefined): ParsedW
const workspaceIdRaw = params.get("workspaceId");
const hasExplicitTarget = Boolean(projectIdRaw && workspaceIdRaw);
const projectName = params.get("projectName");
const workspaceRaw = params.get("workspace");
const workspace: WorkspaceFileSelector = workspaceRaw === "execution" || workspaceRaw === "project"
? workspaceRaw
: "auto";
const kindRaw = params.get("kind");
const lineRaw = params.get("line");
const columnRaw = params.get("column");
@ -46,6 +62,7 @@ export function parseWorkspaceFileHref(href: string | null | undefined): ParsedW
projectId: hasExplicitTarget ? projectIdRaw : null,
workspaceId: hasExplicitTarget ? workspaceIdRaw : null,
projectName: projectName || null,
workspace,
raw: path,
};
}
@ -65,14 +82,42 @@ function parseSingleInlineCodeFileRef(node: MarkdownNode): ParsedWorkspaceFileRe
return parseWorkspaceFileRef(child.value);
}
function rewriteMarkdownTree(node: MarkdownNode) {
/**
* Bind a parsed reference to the workspace that passed preflight so the click
* reuses that exact target instead of re-running auto discovery.
*/
function applyResolvedTarget(
ref: ParsedWorkspaceFileRef,
target: WorkspaceFileAvailabilityTarget,
): ParsedWorkspaceFileRef {
return {
...ref,
workspace: target.workspace,
projectId: target.projectId ?? ref.projectId ?? null,
workspaceId: target.workspaceId ?? ref.workspaceId ?? null,
projectName: ref.projectName ?? null,
};
}
/** Returns the target-bound ref when the resolver confirms it is openable. */
function openableRef(
ref: ParsedWorkspaceFileRef,
resolve: WorkspaceFileRefResolver,
): ParsedWorkspaceFileRef | null {
const target = resolve(ref);
return target ? applyResolvedTarget(ref, target) : null;
}
function rewriteMarkdownTree(node: MarkdownNode, resolve: WorkspaceFileRefResolver) {
if (!Array.isArray(node.children) || node.children.length === 0) return;
// Existing links whose whole label is a workspace-file code span should become
// file-viewer links instead of issue/external links.
// Existing links whose whole label is a workspace-file code span become
// file-viewer links instead of issue/external links — but only when the
// viewer can actually open them. Otherwise the ordinary link is preserved.
if (node.type === "link") {
const ref = parseSingleInlineCodeFileRef(node);
if (ref) {
node.url = buildWorkspaceFileHref(ref);
const resolved = ref ? openableRef(ref, resolve) : null;
if (resolved) {
node.url = buildWorkspaceFileHref(resolved);
}
return;
}
@ -85,20 +130,30 @@ function rewriteMarkdownTree(node: MarkdownNode) {
for (const child of node.children) {
if (child.type === "inlineCode" && typeof child.value === "string") {
const ref = parseWorkspaceFileRef(child.value);
if (ref) {
nextChildren.push(createWorkspaceFileLinkNode(ref));
const resolved = ref ? openableRef(ref, resolve) : null;
if (resolved) {
nextChildren.push(createWorkspaceFileLinkNode(resolved));
continue;
}
}
rewriteMarkdownTree(child);
rewriteMarkdownTree(child, resolve);
nextChildren.push(child);
}
node.children = nextChildren;
}
export function remarkWorkspaceFileRefs() {
return (tree: MarkdownNode) => {
rewriteMarkdownTree(tree);
/**
* Promote path-shaped inline code to workspace-file links, gated on `resolve`.
*
* The resolver doubles as the registration point: it is called exactly once per
* candidate reference per parse, which is the set the availability registry
* needs to check.
*/
export function createRemarkWorkspaceFileRefs(resolve: WorkspaceFileRefResolver) {
return function remarkWorkspaceFileRefs() {
return (tree: MarkdownNode) => {
rewriteMarkdownTree(tree, resolve);
};
};
}

View File

@ -0,0 +1,121 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import type { ResolvedWorkspaceResource } from "@paperclipai/shared";
import {
chunkWorkspaceFileAvailabilityRefs,
workspaceFileAvailabilityFromResult,
workspaceFileAvailabilityKey,
workspaceFileAvailabilityRef,
workspaceFileAvailabilityTarget,
WORKSPACE_FILE_AVAILABILITY_MAX_BATCH,
} from "./workspace-file-availability";
function resource(overrides: Partial<ResolvedWorkspaceResource> = {}): ResolvedWorkspaceResource {
return {
kind: "file",
provider: "git_worktree",
title: "a.ts",
displayPath: "ui/src/a.ts",
workspaceLabel: "Execution workspace",
workspaceKind: "execution_workspace",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
previewKind: "text",
capabilities: { preview: true, download: true, listChildren: false },
...overrides,
};
}
describe("workspaceFileAvailabilityRef", () => {
it("drops line/column so anchors of the same file share one lookup", () => {
const a = workspaceFileAvailabilityRef({ path: "ui/src/a.ts", line: 4, column: 2, raw: "ui/src/a.ts:4:2" });
const b = workspaceFileAvailabilityRef({ path: "ui/src/a.ts", line: 90, column: null, raw: "ui/src/a.ts:90" });
expect(workspaceFileAvailabilityKey(a)).toBe(workspaceFileAvailabilityKey(b));
});
it("defaults an unbound reference to the auto selector", () => {
expect(workspaceFileAvailabilityRef({ path: "a/b.ts", line: null, column: null, raw: "a/b.ts" })).toEqual({
path: "a/b.ts",
workspace: "auto",
projectId: null,
workspaceId: null,
});
});
it("keeps distinct targets on distinct keys", () => {
const auto = workspaceFileAvailabilityKey({ path: "a/b.ts", workspace: "auto", projectId: null, workspaceId: null });
const execution = workspaceFileAvailabilityKey({ path: "a/b.ts", workspace: "execution", projectId: null, workspaceId: null });
const folder = workspaceFileAvailabilityKey({ path: "a/b.ts/", workspace: "auto", projectId: null, workspaceId: null });
expect(new Set([auto, execution, folder]).size).toBe(3);
});
it("matches the server's dedup key ordering", () => {
expect(workspaceFileAvailabilityKey({ path: "a/b.ts", workspace: "auto", projectId: null, workspaceId: null }))
.toBe(JSON.stringify(["auto", null, null, "a/b.ts"]));
});
});
describe("chunkWorkspaceFileAvailabilityRefs", () => {
it("returns nothing for an empty list", () => {
expect(chunkWorkspaceFileAvailabilityRefs([])).toEqual([]);
});
it("keeps a batch at the cap in one request", () => {
const refs = Array.from({ length: WORKSPACE_FILE_AVAILABILITY_MAX_BATCH }, (_, index) => index);
expect(chunkWorkspaceFileAvailabilityRefs(refs)).toHaveLength(1);
});
it("chunks only above the cap", () => {
const refs = Array.from({ length: 250 }, (_, index) => index);
expect(chunkWorkspaceFileAvailabilityRefs(refs).map((chunk) => chunk.length)).toEqual([100, 100, 50]);
});
});
describe("workspaceFileAvailabilityTarget", () => {
it("binds a project workspace by explicit ids", () => {
expect(workspaceFileAvailabilityTarget(resource({
workspaceKind: "project_workspace",
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
projectName: "Paperclip App",
}))).toEqual({
workspace: "project",
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
projectName: "Paperclip App",
});
});
it("binds an execution workspace by selector alone", () => {
expect(workspaceFileAvailabilityTarget(resource())).toMatchObject({
workspace: "execution",
projectId: null,
workspaceId: null,
});
});
});
describe("workspaceFileAvailabilityFromResult", () => {
it("accepts an openable result with a resolved resource", () => {
expect(workspaceFileAvailabilityFromResult({ openable: true, resource: resource() }).state).toBe("openable");
});
it("rejects a non-openable result and keeps the reason", () => {
expect(workspaceFileAvailabilityFromResult({
openable: false,
unavailableReason: "not_found",
resource: null,
})).toEqual({ state: "unavailable", reason: "not_found" });
});
it("fails closed when a result claims openable without a resource", () => {
expect(workspaceFileAvailabilityFromResult({ openable: true, resource: null }).state).toBe("unavailable");
});
it("fails closed for remote resources the viewer cannot preview", () => {
expect(workspaceFileAvailabilityFromResult({
openable: false,
unavailableReason: "remote_workspace",
resource: resource({ kind: "remote_resource", capabilities: { preview: false, download: false, listChildren: false } }),
}).state).toBe("unavailable");
});
});

View File

@ -0,0 +1,107 @@
import type { ResolvedWorkspaceResource, WorkspaceFileSelector } from "@paperclipai/shared";
import type { ParsedWorkspaceFileRef } from "./workspace-file-parser";
/** Server cap on `POST /file-resources/availability` (`queries` max length). */
export const WORKSPACE_FILE_AVAILABILITY_MAX_BATCH = 100;
/** Matches the file viewer's existing resolve/content cache window. */
export const WORKSPACE_FILE_AVAILABILITY_STALE_MS = 30_000;
/** The subset of a parsed ref that identifies an availability lookup. */
export interface WorkspaceFileAvailabilityRef {
path: string;
workspace: WorkspaceFileSelector;
projectId: string | null;
workspaceId: string | null;
}
/**
* The workspace the server confirmed can serve a reference. Chips bind this to
* their viewer URL so the click reuses the target that passed preflight instead
* of repeating an unconstrained auto-discovery pass.
*/
export interface WorkspaceFileAvailabilityTarget {
workspace: WorkspaceFileSelector;
projectId: string | null;
workspaceId: string | null;
projectName: string | null;
}
export type WorkspaceFileAvailability =
/** Not yet requested, in flight, or the batch failed — render as plain code. */
| { state: "pending" }
| { state: "unavailable"; reason: string | null }
| { state: "openable"; target: WorkspaceFileAvailabilityTarget };
export const WORKSPACE_FILE_AVAILABILITY_PENDING: WorkspaceFileAvailability = { state: "pending" };
export function workspaceFileAvailabilityRef(ref: ParsedWorkspaceFileRef): WorkspaceFileAvailabilityRef {
return {
path: ref.path,
workspace: ref.workspace ?? "auto",
projectId: ref.projectId ?? null,
workspaceId: ref.workspaceId ?? null,
};
}
/**
* Stable identity for a reference. Mirrors the server's dedup key
* (`[workspace, projectId, workspaceId, path]`) so responses can be matched back
* to the requests that produced them without relying on array order.
*/
export function workspaceFileAvailabilityKey(ref: WorkspaceFileAvailabilityRef): string {
return JSON.stringify([ref.workspace, ref.projectId, ref.workspaceId, ref.path]);
}
/** Split a deduplicated ref list into request-sized chunks. */
export function chunkWorkspaceFileAvailabilityRefs<T>(
refs: T[],
size: number = WORKSPACE_FILE_AVAILABILITY_MAX_BATCH,
): T[][] {
if (refs.length <= size) return refs.length > 0 ? [refs] : [];
const chunks: T[][] = [];
for (let index = 0; index < refs.length; index += size) {
chunks.push(refs.slice(index, index + size));
}
return chunks;
}
/**
* Derive the viewer target from a resolved resource. Project workspaces carry
* explicit ids; execution workspaces are addressed by selector because they are
* scoped to the issue already.
*/
export function workspaceFileAvailabilityTarget(
resource: ResolvedWorkspaceResource,
): WorkspaceFileAvailabilityTarget {
if (resource.workspaceKind === "project_workspace" && resource.projectId && resource.workspaceId) {
return {
workspace: "project",
projectId: resource.projectId,
workspaceId: resource.workspaceId,
projectName: resource.projectName ?? null,
};
}
return {
workspace: resource.workspaceKind === "project_workspace" ? "project" : "execution",
projectId: null,
workspaceId: null,
projectName: resource.projectName ?? null,
};
}
/**
* Fail closed: only a server result that is explicitly openable and carries a
* resolved resource becomes a chip. Denied, missing, ambiguous, remote,
* unsupported, and unmatched refs stay ordinary inline code.
*/
export function workspaceFileAvailabilityFromResult(result: {
openable: boolean;
unavailableReason?: string | null;
resource: ResolvedWorkspaceResource | null;
}): WorkspaceFileAvailability {
if (!result.openable || !result.resource) {
return { state: "unavailable", reason: result.unavailableReason ?? null };
}
return { state: "openable", target: workspaceFileAvailabilityTarget(result.resource) };
}

View File

@ -1,3 +1,5 @@
import type { WorkspaceFileSelector } from "@paperclipai/shared";
export interface ParsedWorkspaceFileRef {
path: string;
resourceKind?: "file" | "directory";
@ -6,6 +8,11 @@ export interface ParsedWorkspaceFileRef {
projectId?: string | null;
projectName?: string | null;
workspaceId?: string | null;
/**
* Workspace selector the reference is bound to. Set once availability has
* confirmed which workspace serves the path; defaults to `auto` otherwise.
*/
workspace?: WorkspaceFileSelector;
/** The original matched text (useful for rendering) */
raw: string;
}