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:
parent
815e49bb7c
commit
7ea2068ef8
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -384,6 +384,11 @@ export type {
|
|||
WorkspaceOperationStatus,
|
||||
} from "./workspace-operation.js";
|
||||
export type {
|
||||
NormalizedWorkspaceFileAvailabilityQuery,
|
||||
WorkspaceFileAvailabilityQuery,
|
||||
WorkspaceFileAvailabilityRequest,
|
||||
WorkspaceFileAvailabilityResponse,
|
||||
WorkspaceFileAvailabilityResult,
|
||||
WorkspaceFileContent,
|
||||
WorkspaceFileContentEncoding,
|
||||
WorkspaceFileListDirectoryItem,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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) };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue