diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 675a6f8a96..47f730567a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e23ae3daeb..79a317bb48 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -384,6 +384,11 @@ export type { WorkspaceOperationStatus, } from "./workspace-operation.js"; export type { + NormalizedWorkspaceFileAvailabilityQuery, + WorkspaceFileAvailabilityQuery, + WorkspaceFileAvailabilityRequest, + WorkspaceFileAvailabilityResponse, + WorkspaceFileAvailabilityResult, WorkspaceFileContent, WorkspaceFileContentEncoding, WorkspaceFileListDirectoryItem, diff --git a/packages/shared/src/types/workspace-file-resource.ts b/packages/shared/src/types/workspace-file-resource.ts index 6ca345b247..02f1bd1813 100644 --- a/packages/shared/src/types/workspace-file-resource.ts +++ b/packages/shared/src/types/workspace-file-resource.ts @@ -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[]; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index b0923b6084..5bc25bd431 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -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"; diff --git a/packages/shared/src/validators/workspace-file-resource.ts b/packages/shared/src/validators/workspace-file-resource.ts index 9ec9c6d51b..9efe45b57b 100644 --- a/packages/shared/src/validators/workspace-file-resource.ts +++ b/packages/shared/src/validators/workspace-file-resource.ts @@ -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; export type WorkspaceFileListQuery = z.infer; +export type WorkspaceFileAvailabilityRequestInput = z.infer; diff --git a/packages/shared/src/workspace-file-resource.test.ts b/packages/shared/src/workspace-file-resource.test.ts new file mode 100644 index 0000000000..1a64f06528 --- /dev/null +++ b/packages/shared/src/workspace-file-resource.test.ts @@ -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]); + }); +}); diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index c912109f10..b60ea88fd9 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -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((resolve) => { + releaseSlowAvailability = resolve; + }); + const availabilityStarted = new Promise((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); + }); }); diff --git a/server/src/routes/file-resources.ts b/server/src/routes/file-resources.ts index ee80e755da..0aa8e64220 100644 --- a/server/src/routes/file-resources.ts +++ b/server/src/routes/file-resources.ts @@ -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> }, + ): Promise; 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; + 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; + 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); diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index cf53e1b6e3..14924a9a6c 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -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"; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index e11a2f4edf..9dfa3f2486 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index d7a3965dd1..546d7357c3 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -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(items: T[], concurrency: number, mapper: (item: T) => Promise) { + const results = new Array(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> { + 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(); + + 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, @@ -1051,6 +1211,108 @@ export function workspaceFileResourceService(db: Db) { }); } + async function availability( + issueId: string, + input: WorkspaceFileAvailabilityRequestInput, + opts: { issue?: IssueRow } = {}, + ): Promise { + const issue = opts.issue ?? await getIssue(issueId); + const uniqueQueries = new Map(); + 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 => { + 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, diff --git a/ui/src/api/file-resources.ts b/ui/src/api/file-resources.ts index b2e10dd16b..ba5fabc299 100644 --- a/ui/src/api/file-resources.ts +++ b/ui/src/api/file-resources.ts @@ -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 { + return api.post( + `/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 { return api.get( `/issues/${encodeURIComponent(issueId)}/file-resources/resolve?${buildQuery(query)}`, diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 85a96837f7..3615c501a4 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -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(" { + 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" }, diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 3da2a7f69f..b1f2212685 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -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 | 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(() => { const map: Components = { p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => ( diff --git a/ui/src/components/WorkspaceFileLink.tsx b/ui/src/components/WorkspaceFileLink.tsx index 522b1580bb..a04a0ea9cb 100644 --- a/ui/src/components/WorkspaceFileLink.tsx +++ b/ui/src/components/WorkspaceFileLink.tsx @@ -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, }); diff --git a/ui/src/components/WorkspaceFileMarkdownBody.availability.test.tsx b/ui/src/components/WorkspaceFileMarkdownBody.availability.test.tsx new file mode 100644 index 0000000000..aa62337c5a --- /dev/null +++ b/ui/src/components/WorkspaceFileMarkdownBody.availability.test.tsx @@ -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 }) => ( + {children} + ), + 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 { + 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 = {}): 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( + + + {children} + + , + ); + }); + 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({"Check `ui/src/a.ts:42` please."}); + + // 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( + + {"See `ui/src/missing.ts:1`, `ui/src/denied.ts:2` and `ui/src/remote.ts:3`."} + , + ); + + 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({"Check `ui/src/a.ts:42`."}); + + 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( + <> + {"First `ui/src/a.ts:1`."} + {"Second `ui/src/a.ts:1` again."} + {"Third `ui/src/a.ts:9` at another line."} + , + ); + + 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( + + {paths.map((path) => `\`${path}\``).join(" ")} + , + ); + + 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((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( + + {paths.map((path) => `\`${path}\``).join(" ")} + , + ); + + 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 = {"First `ui/src/a.ts:1`."}; + const queryClient = render(first); + await waitForExpectation(() => expect(chipPaths()).toEqual(["ui/src/a.ts"])); + expect(mockAvailability).toHaveBeenCalledTimes(1); + + act(() => { + root.render( + + + + {first} + {"New `ui/src/a.ts:1` and `ui/src/b.ts:2`."} + + + , + ); + }); + + 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({"Check `ui/src/a.ts:42`."}); + 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( + + + + + {"Check `ui/src/a.ts:42`."} + + + + , + ); + }); + + 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({"Check `ui/src/a.ts:1`."}); + 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)); + }); +}); diff --git a/ui/src/components/WorkspaceFileMarkdownBody.tsx b/ui/src/components/WorkspaceFileMarkdownBody.tsx index 3011d3e2e6..abe159661c 100644 --- a/ui/src/components/WorkspaceFileMarkdownBody.tsx +++ b/ui/src/components/WorkspaceFileMarkdownBody.tsx @@ -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[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(() => { + if (!availability) return undefined; + return (ref) => { + const result = availability.check(workspaceFileAvailabilityRef(ref)); + return result.state === "openable" ? result.target : null; + }; + }, [availability]); const handleClick = (event: MouseEvent) => { if (!viewer) return; @@ -43,7 +55,7 @@ export function WorkspaceFileMarkdownBody({ return (
- {children} + {children}
); } diff --git a/ui/src/context/FileViewerContext.tsx b/ui/src/context/FileViewerContext.tsx index 062b9959b7..22791627f7 100644 --- a/ui/src/context/FileViewerContext.tsx +++ b/ui/src/context/FileViewerContext.tsx @@ -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 readFileViewerStateFromSearch(location.search), [location.search]); const browseState = useMemo(() => readBrowseStateFromSearch(location.search), [location.search]); + const availability = useWorkspaceFileAvailability(issueId); const navigateSearch = useCallback( (nextSearch: string, opts?: Partial) => { @@ -305,6 +316,7 @@ function EnabledFileViewerProvider({ issueId, children }: Omit( () => ({ issueId, + availability, state, browse: browseState !== null, query: browseState?.q ?? null, @@ -318,7 +330,7 @@ function EnabledFileViewerProvider({ issueId, children }: Omit{children}; diff --git a/ui/src/hooks/useWorkspaceFileAvailability.ts b/ui/src/hooks/useWorkspaceFileAvailability.ts new file mode 100644 index 0000000000..73caafa4dc --- /dev/null +++ b/ui/src/hooks/useWorkspaceFileAvailability.ts @@ -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>(new Map()); + const queueRef = useRef>(new Map()); + const inFlightRef = useRef>(new Set()); + const batchQueueRef = useRef([]); + const activeBatchCountRef = useRef(0); + const flushHandleRef = useRef | null>(null); + const activeRef = useRef(true); + const generationRef = useRef(0); + const clientRef = useRef(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( + (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]); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 3b6d80f13d..6e9e067895 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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 }) => diff --git a/ui/src/lib/remark-workspace-file-refs.test.ts b/ui/src/lib/remark-workspace-file-refs.test.ts index d70dd9f1b3..8db74c6602 100644 --- a/ui/src/lib/remark-workspace-file-refs.test.ts +++ b/ui/src/lib/remark-workspace-file-refs.test.ts @@ -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(); + }); + }); }); diff --git a/ui/src/lib/remark-workspace-file-refs.ts b/ui/src/lib/remark-workspace-file-refs.ts index 6d725b4e31..4dfb520e47 100644 --- a/ui/src/lib/remark-workspace-file-refs.ts +++ b/ui/src/lib/remark-workspace-file-refs.ts @@ -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); + }; }; } diff --git a/ui/src/lib/workspace-file-availability.test.ts b/ui/src/lib/workspace-file-availability.test.ts new file mode 100644 index 0000000000..247daa837f --- /dev/null +++ b/ui/src/lib/workspace-file-availability.test.ts @@ -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 { + 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"); + }); +}); diff --git a/ui/src/lib/workspace-file-availability.ts b/ui/src/lib/workspace-file-availability.ts new file mode 100644 index 0000000000..59c1c3231e --- /dev/null +++ b/ui/src/lib/workspace-file-availability.ts @@ -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( + 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) }; +} diff --git a/ui/src/lib/workspace-file-parser.ts b/ui/src/lib/workspace-file-parser.ts index 3472794dfa..4b828c234c 100644 --- a/ui/src/lib/workspace-file-parser.ts +++ b/ui/src/lib/workspace-file-parser.ts @@ -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; }