Files
PaperClipAI/server/src/services/workspace-file-resources.ts
T
DottaandPaperclip 8087661bb8 fix: bound workspace Git scans (#11572)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Workspaces let users and agents inspect files that belong to an
issue
> - Changed-file views use full-tree Git status scans
> - Many issue views could start those scans at the same time and make
the server unresponsive
> - Route-level limits did not protect the process or coalesce work for
one repository
> - This pull request adds one bounded scheduler for every expensive
workspace Git scan
> - It also starts browser scans only when the file panel is open and
visible
> - The benefit is bounded child-process use and responsive health
checks during request storms

## Linked Issues or Issue Description

**What happened?**

Many changed-file requests could start full `git status --porcelain=v1
-z --untracked-files=all` scans at the same time. One production
incident produced about 270 direct Git child processes. The Node process
stayed alive but stopped answering health requests in time.

**Expected behavior**

Paperclip must bound expensive Git work across all companies, actors,
issues, repositories, and browser tabs. Duplicate requests for one
worktree must share work. Excess requests must fail fast with a
retryable response. Hidden or closed file panels must not start scans.

**Steps to reproduce**

1. Open changed-file views for many issue and actor keys.
2. Send requests for two large workspace roots at the same time.
3. Observe that route-level limiter keys allow many full Git scans to
run together.
4. Observe delayed health responses and accumulated Git children.

**Paperclip version or commit**

Reproduced on master before commit `43ab441f0f`.

**Deployment mode**

Self-hosted server with local workspace repositories.

## What Changed

- Add a process-wide scheduler with configurable concurrency, queue
capacity, timeout, and cache TTL.
- Add fair admission, a bounded queue, canonical worktree keys,
single-flight joins, and bounded result caching.
- Add subprocess timeouts, TERM-to-KILL escalation, bounded output,
waiter cancellation, and slot cleanup.
- Route full-tree status work from file resources, workspace runtime,
execution workspaces, and adapter overlay sync through the scheduler.
- Return stable retryable `503` and `504` error codes for saturation and
timeout.
- Add structured logs with safe workspace hashes, durations, queue
state, cache use, joins, and terminal outcomes.
- Gate UI queries on panel and document visibility. Cancel queries on
close, hide, unmount, and workspace change.
- Disable focus and reconnect bursts. Keep one explicit refresh action
and a retryable unavailable state.
- Document the 10-second default freshness tradeoff and all
configuration variables.
- Add unit, route, UI, adapter, and deterministic 500-request load
coverage.

## Verification

- `pnpm -r typecheck`
- `pnpm build`
- `pnpm check:token-gates`
- `pnpm --filter @paperclipai/server exec vitest run
src/services/workspace-git-operation-scheduler.test.ts
src/__tests__/file-resources-git-scan-load.test.ts --reporter=dot` — 16
tests passed.
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/WorkspaceFileBrowser.test.tsx
src/lib/page-visibility.test.ts --reporter=dot` — 38 tests passed.
- `pnpm --filter @paperclipai/adapter-utils exec vitest run
src/git-workspace-sync.test.ts --reporter=dot` — 16 tests passed.
- Existing file-resource, workspace-runtime, and execution-workspace
regression selections passed.
- Two cleanup safety regressions prove failed scans preserve the
worktree before archive and at the final deletion fence.
- Before: the incident produced about 270 Git children and health
requests timed out.
- After: 500 concurrent requests across 500 issue keys, 73 actors, and
two roots started two underlying scans. Peak scan concurrency was 2. All
500 requests succeeded. Health p99 was 4.94 ms. The harness found zero
unreaped children.
- The full local Vitest run passed 4,267 tests. Ten existing fixed-port
HTTPS exposure tests could not run because this host already owns
Tailnet listeners on ports 42000 and 52000. Clean GitHub CI is the final
full-suite result.
- Latest-head GitHub CI passed all required test, typecheck, build,
canary, e2e, policy, and security gates.
- Greptile completed at 5/5 with zero unresolved comments,
recommendations, or follow-ups.

## Risks

- Changed-file results can be up to 10 seconds old by default. Explicit
refresh remains available.
- A full queue returns a retryable `503` instead of waiting without a
bound.
- A scan that exceeds the default 8-second deadline returns a retryable
`504` and terminates its process group.
- Operators can tune all limits with documented environment variables.
Safe defaults protect local and shared servers.

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

## Model Used

- OpenAI Codex, GPT-5 family. The runtime does not expose the exact
deployment ID or context-window size. High reasoning, tool use, and code
execution were enabled.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-17 22:11:30 -05:00

1742 lines
60 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
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,
WorkspaceFileListResponse,
WorkspaceFilePreviewKind,
WorkspaceFileSelector,
WorkspaceFileWorkspaceKind,
} from "@paperclipai/shared";
import { HttpError, notFound, unprocessable } from "../errors.js";
import {
isWorkspaceGitScanError,
WORKSPACE_GIT_SCAN_ERROR_CODES,
workspaceGitOperationScheduler,
} from "./workspace-git-operation-scheduler.js";
export const WORKSPACE_FILE_TEXT_MAX_BYTES = 512 * 1024;
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;
const GIT_STATUS_MAX_BUFFER_BYTES = 1024 * 1024;
const LOCAL_PROJECT_WORKSPACE_SOURCE_TYPES = new Set(["local_path", "non_git_path", "git_repo", "git_worktree"]);
export interface WorkspaceFileScanContext {
signal?: AbortSignal;
fairnessKeys?: readonly string[];
}
const DENIED_SEGMENTS = new Set([
".git",
".paperclip",
"node_modules",
".pnpm-store",
".yarn",
".cache",
".turbo",
".next",
".vite",
".vercel",
"dist",
"build",
"coverage",
"runtime-services",
".runtime",
]);
const TEXT_EXTENSIONS = new Set([
".c",
".cc",
".conf",
".cpp",
".css",
".csv",
".go",
".h",
".html",
".htm",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mjs",
".py",
".rb",
".rs",
".sh",
".sql",
".svg",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
]);
const IMAGE_CONTENT_TYPES = new Map([
[".gif", "image/gif"],
[".jpeg", "image/jpeg"],
[".jpg", "image/jpeg"],
[".png", "image/png"],
[".webp", "image/webp"],
]);
const VIDEO_CONTENT_TYPES = new Map([
[".m4v", "video/mp4"],
[".mov", "video/quicktime"],
[".mp4", "video/mp4"],
[".webm", "video/webm"],
]);
type IssueRow = typeof issues.$inferSelect;
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
type WorkspaceCandidate = {
workspaceKind: WorkspaceFileWorkspaceKind;
workspaceId: string;
projectId?: string | null;
projectName?: string | null;
provider: string;
label: string;
rootPath: string | null;
remote: boolean;
};
type NormalizedPath = {
relativePath: string;
segments: string[];
};
type LocalResolvedFile = {
resource: ResolvedWorkspaceResource;
realPath: string;
};
type LocalResolvedDirectory = {
resource: ResolvedWorkspaceResource;
realPath: string;
rootReal: string;
};
type AutoDiscovered<T> =
| { state: "none" }
| { state: "one"; value: T }
| { state: "ambiguous"; count: number };
type WorkspaceFileListQueryInput = {
workspace?: WorkspaceFileSelector | null;
projectId?: string | null;
workspaceId?: string | null;
path?: string | null;
mode?: WorkspaceFileListMode | null;
q?: string | null;
limit?: number | null;
offset?: number | null;
};
type WorkspaceTargetInput = {
projectId?: string | null;
workspaceId?: string | null;
};
type WorkspaceFileAvailabilityQueryInput = WorkspaceFileAvailabilityRequestInput["queries"][number];
type PreparedAvailabilityQuery = {
query: NormalizedWorkspaceFileAvailabilityQuery;
normalizedPath: NormalizedPath | null;
directory: boolean;
key: string;
unavailableReason?: string;
};
type PreparedAvailabilityTarget =
| { candidate: WorkspaceCandidate; error?: never }
| { candidate?: never; error: HttpError };
function previewCapForKind(kind: WorkspaceFilePreviewKind) {
return kind === "image" || kind === "video" || kind === "pdf"
? WORKSPACE_FILE_MEDIA_MAX_BYTES
: WORKSPACE_FILE_TEXT_MAX_BYTES;
}
function safeRejectedAvailabilityPath(input: string) {
const trimmed = input.trim();
const slashPath = trimmed.replaceAll("\\", "/");
if (path.posix.isAbsolute(slashPath) || /^[a-zA-Z]:/.test(trimmed) || /^file:\/\//i.test(trimmed)) {
return path.posix.basename(slashPath) || "[invalid path]";
}
return trimmed;
}
function prepareAvailabilityQuery(input: WorkspaceFileAvailabilityQueryInput): PreparedAvailabilityQuery {
const directory = input.path.trim().endsWith("/");
const baseQuery: NormalizedWorkspaceFileAvailabilityQuery = {
path: input.path.trim(),
workspace: input.workspace ?? "auto",
projectId: input.projectId ?? null,
workspaceId: input.workspaceId ?? null,
};
try {
const normalizedPath = normalizeWorkspaceRelativePath(input.path);
const query = {
...baseQuery,
path: `${normalizedPath.relativePath}${directory ? "/" : ""}`,
};
return {
query,
normalizedPath,
directory,
key: JSON.stringify([query.workspace, query.projectId, query.workspaceId, query.path]),
};
} catch (error) {
const unavailableReason = expectedAvailabilityReason(error);
if (!unavailableReason) throw error;
const safePath = safeRejectedAvailabilityPath(input.path);
return {
query: { ...baseQuery, path: safePath },
normalizedPath: null,
directory,
key: JSON.stringify([baseQuery.workspace, baseQuery.projectId, baseQuery.workspaceId, baseQuery.path]),
unavailableReason,
};
}
}
function expectedAvailabilityReason(error: unknown): string | null {
if (!(error instanceof HttpError) || ![403, 404, 409, 422].includes(error.status)) return null;
if (error.details && typeof error.details === "object" && "code" in error.details) {
const code = (error.details as { code?: unknown }).code;
if (typeof code === "string" && code.length > 0) return code;
}
if (error.status === 403) return "forbidden";
if (error.status === 404) return "not_found";
if (error.status === 409) return "conflict";
return "unprocessable";
}
function availabilityResult(
query: NormalizedWorkspaceFileAvailabilityQuery,
resource: ResolvedWorkspaceResource,
): WorkspaceFileAvailabilityResult {
const openable = resource.kind === "file"
? resource.capabilities.preview
: resource.kind === "directory" && resource.capabilities.listChildren;
return {
query,
openable,
...(openable ? {} : { unavailableReason: resource.denialReason ?? "unsupported_resource" }),
resource,
};
}
function unavailableAvailabilityResult(
query: NormalizedWorkspaceFileAvailabilityQuery,
unavailableReason: string,
): WorkspaceFileAvailabilityResult {
return {
query,
openable: false,
unavailableReason,
resource: null,
};
}
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, mapper: (item: T) => Promise<R>) {
const results = new Array<R>(items.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(items[index]!);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
return results;
}
function relativePathFromReal(rootReal: string, targetReal: string) {
return path.relative(rootReal, targetReal).split(path.sep).join(path.posix.sep);
}
function isInsideRoot(rootReal: string, targetReal: string) {
const realRelative = path.relative(rootReal, targetReal);
return realRelative === "" || (!realRelative.startsWith("..") && !path.isAbsolute(realRelative));
}
function normalizeWorkspaceRelativePath(input: string): NormalizedPath {
const trimmed = input.trim();
if (!trimmed) throw unprocessable("Workspace file path is required", { code: "invalid_path" });
if (Buffer.byteLength(trimmed, "utf8") > MAX_RELATIVE_PATH_BYTES) {
throw unprocessable("Workspace file path is too long", { code: "invalid_path" });
}
if (trimmed.includes("\0")) throw unprocessable("Workspace file path contains an invalid character", { code: "invalid_path" });
if (/^file:\/\//i.test(trimmed)) throw unprocessable("File URLs are not supported", { code: "invalid_path" });
if (/^[a-zA-Z]:/.test(trimmed)) throw unprocessable("Windows drive paths are not supported", { code: "invalid_path" });
if (trimmed.startsWith("~")) throw unprocessable("Home-relative paths are not supported", { code: "invalid_path" });
if (trimmed.includes("\\")) throw unprocessable("Workspace file paths must use forward slashes", { code: "invalid_path" });
if (path.posix.isAbsolute(trimmed)) throw unprocessable("Workspace file path must be relative", { code: "invalid_path" });
const normalizedRaw = path.posix.normalize(trimmed);
const normalized = normalizedRaw.endsWith("/") ? normalizedRaw.replace(/\/+$/, "") : normalizedRaw;
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
throw new HttpError(403, "Workspace file path is outside the workspace", { code: "outside_workspace" });
}
return {
relativePath: normalized,
segments: normalized.split("/").filter(Boolean),
};
}
function denyReasonForPathSegments(segments: string[]): string | null {
const lowerSegments = segments.map((segment) => segment.toLowerCase());
if (lowerSegments.some((segment) => DENIED_SEGMENTS.has(segment))) return "denied_path_segment";
const fileName = lowerSegments.at(-1) ?? "";
if (fileName === ".env" || fileName.startsWith(".env.")) return "denied_secret";
if (fileName.endsWith(".pem") || fileName.endsWith(".key") || fileName.endsWith(".p12") || fileName.endsWith(".pfx")) {
return "denied_secret";
}
if (["id_rsa", "id_ed25519", ".npmrc", ".pypirc", ".netrc", "kubeconfig"].includes(fileName)) return "denied_secret";
if (lowerSegments.includes(".aws") || lowerSegments.includes(".ssh")) return "denied_secret";
if (lowerSegments.length >= 2 && lowerSegments.at(-2) === ".docker" && fileName === "config.json") return "denied_secret";
if (lowerSegments.length >= 2 && lowerSegments.at(-2) === ".kube" && fileName === "config") return "denied_secret";
return null;
}
function throwIfDenied(segments: string[]) {
const denialReason = denyReasonForPathSegments(segments);
if (denialReason) {
throw new HttpError(403, "Workspace file path is denied by policy", { code: denialReason });
}
}
function shouldPruneSegments(segments: string[]) {
return denyReasonForPathSegments(segments) != null;
}
function contentTypeForPath(filePath: string): string | null {
const ext = path.extname(filePath).toLowerCase();
if (IMAGE_CONTENT_TYPES.has(ext)) return IMAGE_CONTENT_TYPES.get(ext) ?? null;
if (VIDEO_CONTENT_TYPES.has(ext)) return VIDEO_CONTENT_TYPES.get(ext) ?? null;
if (ext === ".pdf") return "application/pdf";
if (ext === ".svg") return "image/svg+xml";
if (ext === ".html" || ext === ".htm") return "text/html";
if (TEXT_EXTENSIONS.has(ext)) return "text/plain; charset=utf-8";
return null;
}
function previewKindForKnownContentType(contentType: string | null): WorkspaceFilePreviewKind | null {
if (!contentType) return null;
if (contentType.startsWith("image/") && contentType !== "image/svg+xml") return "image";
if (contentType.startsWith("video/")) return "video";
if (contentType === "application/pdf") return "pdf";
if (contentType === "text/html") return "unsupported";
if (contentType === "image/svg+xml" || contentType.startsWith("text/")) return "text";
return "unsupported";
}
function listItemFromStat(input: {
candidate: WorkspaceCandidate;
relativePath: string;
stat: { size: number; mtime: Date };
}): WorkspaceFileListItem | null {
const contentType = contentTypeForPath(input.relativePath);
const previewKind = previewKindForKnownContentType(contentType) ?? "unsupported";
const previewable = previewKind !== "unsupported" && input.stat.size <= previewCapForKind(previewKind);
return {
kind: "file",
provider: input.candidate.provider,
title: path.posix.basename(input.relativePath),
relativePath: input.relativePath,
displayPath: input.candidate.projectName ? `${input.candidate.projectName} / ${input.relativePath}` : input.relativePath,
workspaceLabel: input.candidate.label,
workspaceKind: input.candidate.workspaceKind,
workspaceId: input.candidate.workspaceId,
projectId: input.candidate.projectId ?? null,
projectName: input.candidate.projectName ?? null,
contentType: contentType ?? (previewKind === "text" ? "text/plain; charset=utf-8" : "application/octet-stream"),
byteSize: input.stat.size,
modifiedAt: input.stat.mtime.toISOString(),
previewKind,
capabilities: {
preview: previewable,
download: true,
listChildren: false,
},
};
}
function listItemFromDirectory(input: {
candidate: WorkspaceCandidate;
relativePath: string;
stat?: { mtime: Date };
}): WorkspaceFileListItem {
return {
kind: "directory",
provider: input.candidate.provider,
title: path.posix.basename(input.relativePath),
relativePath: input.relativePath,
displayPath: input.candidate.projectName ? `${input.candidate.projectName} / ${input.relativePath}/` : `${input.relativePath}/`,
workspaceLabel: input.candidate.label,
workspaceKind: input.candidate.workspaceKind,
workspaceId: input.candidate.workspaceId,
projectId: input.candidate.projectId ?? null,
projectName: input.candidate.projectName ?? null,
contentType: null,
byteSize: null,
modifiedAt: input.stat?.mtime.toISOString() ?? null,
previewKind: "unsupported",
capabilities: {
preview: false,
download: false,
listChildren: true,
},
};
}
function looksLikeText(buffer: Buffer) {
if (buffer.length === 0) return true;
let controlBytes = 0;
for (const byte of buffer) {
if (byte === 0) return false;
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) controlBytes += 1;
}
return controlBytes / buffer.length < 0.02;
}
async function sniffUnknownPreviewKind(realPath: string): Promise<WorkspaceFilePreviewKind> {
const handle = await fs.open(realPath, "r");
try {
const buffer = Buffer.alloc(TEXT_SNIFF_BYTES);
const { bytesRead } = await handle.read(buffer, 0, TEXT_SNIFF_BYTES, 0);
return looksLikeText(buffer.subarray(0, bytesRead)) ? "text" : "unsupported";
} finally {
await handle.close();
}
}
function remoteResource(candidate: WorkspaceCandidate, relativePath: string): ResolvedWorkspaceResource {
return {
kind: "remote_resource",
provider: candidate.provider || "remote_managed",
title: path.posix.basename(relativePath),
displayPath: candidate.projectName ? `${candidate.projectName} / ${relativePath}` : relativePath,
workspaceLabel: candidate.label,
workspaceKind: candidate.workspaceKind,
workspaceId: candidate.workspaceId,
projectId: candidate.projectId ?? null,
projectName: candidate.projectName ?? null,
contentType: null,
byteSize: null,
previewKind: "unsupported",
denialReason: "remote_workspace",
capabilities: {
preview: false,
download: false,
listChildren: false,
},
};
}
function candidateFromExecutionWorkspace(row: ExecutionWorkspaceRow): WorkspaceCandidate {
const provider = row.providerType || row.strategyType || "local_fs";
const rootPath = row.cwd || row.providerRef || null;
const remote = !["local_fs", "git_worktree"].includes(provider) || !rootPath || row.status !== "active" || row.closedAt != null;
return {
workspaceKind: "execution_workspace",
workspaceId: row.id,
provider,
label: row.name,
rootPath,
remote,
};
}
function candidateFromProjectWorkspace(
row: ProjectWorkspaceRow,
project?: { id: string; name: string } | null,
): WorkspaceCandidate {
const provider = row.sourceType === "local_path" ? "local_fs" : row.sourceType;
const rootPath = row.cwd ?? null;
const remote = !LOCAL_PROJECT_WORKSPACE_SOURCE_TYPES.has(row.sourceType) || !rootPath;
return {
workspaceKind: "project_workspace",
workspaceId: row.id,
projectId: project?.id ?? row.projectId,
projectName: project?.name ?? null,
provider,
label: row.name,
rootPath,
remote,
};
}
function isHttpStatus(error: unknown, status: number) {
return (
error instanceof Error &&
"status" in error &&
(error as { status?: number }).status === status
);
}
function directoryResource(input: {
candidate: WorkspaceCandidate;
relativePath: string;
}): ResolvedWorkspaceResource {
return {
kind: "directory",
provider: input.candidate.provider,
title: path.posix.basename(input.relativePath),
displayPath: input.candidate.projectName
? `${input.candidate.projectName} / ${input.relativePath}/`
: `${input.relativePath}/`,
workspaceLabel: input.candidate.label,
workspaceKind: input.candidate.workspaceKind,
workspaceId: input.candidate.workspaceId,
projectId: input.candidate.projectId ?? null,
projectName: input.candidate.projectName ?? null,
contentType: null,
byteSize: null,
previewKind: "unsupported",
denialReason: null,
capabilities: {
preview: false,
download: false,
listChildren: true,
},
};
}
async function statLocalCandidate(candidate: WorkspaceCandidate, normalized: NormalizedPath): Promise<LocalResolvedFile> {
if (!candidate.rootPath) {
throw unprocessable("Workspace is not locally readable", { code: "remote_workspace" });
}
throwIfDenied(normalized.segments);
let rootReal: string;
try {
rootReal = await fs.realpath(candidate.rootPath);
} catch {
throw unprocessable("Workspace is not available on this host", { code: "workspace_unavailable" });
}
const targetLexical = path.resolve(rootReal, ...normalized.segments);
let targetReal: string;
try {
targetReal = await fs.realpath(targetLexical);
} catch {
throw notFound("Workspace file not found");
}
const realRelative = path.relative(rootReal, targetReal);
if (realRelative === "" || realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
throw new HttpError(403, "Workspace file path is outside the workspace", { code: "outside_workspace" });
}
throwIfDenied(relativePathFromReal(rootReal, targetReal).split("/").filter(Boolean));
const stat = await fs.stat(targetReal);
if (!stat.isFile()) {
throw unprocessable("Workspace file is not a regular file", { code: "not_regular_file" });
}
const contentType = contentTypeForPath(normalized.relativePath);
let previewKind = previewKindForKnownContentType(contentType);
if (!previewKind) {
previewKind = await sniffUnknownPreviewKind(targetReal);
}
const cap = previewCapForKind(previewKind);
const tooLarge = stat.size > cap;
const unsupported = previewKind === "unsupported";
const denialReason = tooLarge ? "too_large" : unsupported ? "unsupported_content" : null;
return {
realPath: targetReal,
resource: {
kind: "file",
provider: candidate.provider,
title: path.posix.basename(normalized.relativePath),
displayPath: candidate.projectName ? `${candidate.projectName} / ${normalized.relativePath}` : normalized.relativePath,
workspaceLabel: candidate.label,
workspaceKind: candidate.workspaceKind,
workspaceId: candidate.workspaceId,
projectId: candidate.projectId ?? null,
projectName: candidate.projectName ?? null,
contentType: contentType ?? (previewKind === "text" ? "text/plain; charset=utf-8" : "application/octet-stream"),
byteSize: stat.size,
previewKind,
denialReason,
capabilities: {
preview: !tooLarge && !unsupported,
download: true,
listChildren: false,
},
},
};
}
async function statLocalDirectory(candidate: WorkspaceCandidate, normalized: NormalizedPath): Promise<LocalResolvedDirectory> {
if (!candidate.rootPath) {
throw unprocessable("Workspace is not locally readable", { code: "remote_workspace" });
}
throwIfDenied(normalized.segments);
let rootReal: string;
try {
rootReal = await fs.realpath(candidate.rootPath);
} catch {
throw unprocessable("Workspace is not available on this host", { code: "workspace_unavailable" });
}
const targetLexical = path.resolve(rootReal, ...normalized.segments);
let targetReal: string;
try {
targetReal = await fs.realpath(targetLexical);
} catch {
throw notFound("Workspace folder not found");
}
if (!isInsideRoot(rootReal, targetReal)) {
throw new HttpError(403, "Workspace folder path is outside the workspace", { code: "outside_workspace" });
}
throwIfDenied(relativePathFromReal(rootReal, targetReal).split("/").filter(Boolean));
const stat = await fs.stat(targetReal);
if (!stat.isDirectory()) {
throw unprocessable("Workspace path is not a directory", { code: "not_directory" });
}
return {
realPath: targetReal,
rootReal,
resource: directoryResource({ candidate, relativePath: normalized.relativePath }),
};
}
async function readStableFile(realPath: string, maxBytes: number) {
const handle = await fs.open(realPath, "r");
try {
const before = await handle.stat();
if (!before.isFile()) throw unprocessable("Workspace file is not a regular file", { code: "not_regular_file" });
if (before.size > maxBytes) throw unprocessable("Workspace file is too large to preview", { code: "too_large" });
const data = await handle.readFile();
const after = await handle.stat();
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || after.mtimeMs !== before.mtimeMs) {
throw unprocessable("Workspace file changed while being read", { code: "file_changed" });
}
return data;
} finally {
await handle.close();
}
}
function unavailableFileList(input: {
selector: WorkspaceFileSelector;
mode: WorkspaceFileListMode;
path?: string | null;
q: string | null;
limit: number;
offset: number;
candidate?: WorkspaceCandidate | null;
reason: string;
}): WorkspaceFileListResponse {
return {
kind: "workspace_file_list",
state: "unavailable",
unavailableReason: input.reason,
workspace: input.candidate
? {
provider: input.candidate.provider,
workspaceLabel: input.candidate.label,
workspaceKind: input.candidate.workspaceKind,
workspaceId: input.candidate.workspaceId,
projectId: input.candidate.projectId ?? null,
projectName: input.candidate.projectName ?? null,
}
: null,
query: {
workspace: input.selector,
mode: input.mode,
path: input.path ?? null,
q: input.q,
limit: input.limit,
offset: input.offset,
},
items: [],
scannedCount: 0,
truncated: false,
};
}
function availableFileList(input: {
selector: WorkspaceFileSelector;
mode: WorkspaceFileListMode;
path?: string | null;
q: string | null;
limit: number;
offset: number;
candidate: WorkspaceCandidate;
items: WorkspaceFileListItem[];
scannedCount: number;
truncated: boolean;
}): WorkspaceFileListResponse {
return {
kind: "workspace_file_list",
state: "available",
workspace: {
provider: input.candidate.provider,
workspaceLabel: input.candidate.label,
workspaceKind: input.candidate.workspaceKind,
workspaceId: input.candidate.workspaceId,
projectId: input.candidate.projectId ?? null,
projectName: input.candidate.projectName ?? null,
},
query: {
workspace: input.selector,
mode: input.mode,
path: input.path ?? null,
q: input.q,
limit: input.limit,
offset: input.offset,
},
items: input.items,
scannedCount: input.scannedCount,
truncated: input.truncated,
};
}
function matchesSearch(relativePath: string, normalizedQuery: string | null) {
return !normalizedQuery || relativePath.toLowerCase().includes(normalizedQuery);
}
async function listLocalFileCandidate(input: {
candidate: WorkspaceCandidate;
rootReal: string;
relativePath: string;
normalizedQuery: string | null;
}): Promise<WorkspaceFileListItem | null> {
let normalized: NormalizedPath;
try {
normalized = normalizeWorkspaceRelativePath(input.relativePath);
} catch {
return null;
}
if (shouldPruneSegments(normalized.segments)) return null;
if (!matchesSearch(normalized.relativePath, input.normalizedQuery)) return null;
const targetLexical = path.resolve(input.rootReal, ...normalized.segments);
let targetReal: string;
let stat;
try {
stat = await fs.lstat(targetLexical);
if (stat.isSymbolicLink() || !stat.isFile()) return null;
targetReal = await fs.realpath(targetLexical);
if (!isInsideRoot(input.rootReal, targetReal)) return null;
const realRelative = relativePathFromReal(input.rootReal, targetReal);
if (shouldPruneSegments(realRelative.split("/").filter(Boolean))) return null;
} catch {
return null;
}
return listItemFromStat({ candidate: input.candidate, relativePath: normalized.relativePath, stat });
}
async function listLocalDirectoryChildCandidate(input: {
candidate: WorkspaceCandidate;
rootReal: string;
parentReal: string;
relativePath: string;
entry: import("node:fs").Dirent;
normalizedQuery: string | null;
}): Promise<WorkspaceFileListItem | null> {
let normalized: NormalizedPath;
try {
normalized = normalizeWorkspaceRelativePath(input.relativePath);
} catch {
return null;
}
if (shouldPruneSegments(normalized.segments)) return null;
if (!matchesSearch(normalized.relativePath, input.normalizedQuery)) return null;
if (input.entry.isSymbolicLink()) return null;
if (input.entry.isDirectory()) {
const targetLexical = path.join(input.parentReal, input.entry.name);
try {
const stat = await fs.lstat(targetLexical);
const targetReal = await fs.realpath(targetLexical);
if (!isInsideRoot(input.rootReal, targetReal)) return null;
const realRelative = relativePathFromReal(input.rootReal, targetReal);
if (shouldPruneSegments(realRelative.split("/").filter(Boolean))) return null;
return listItemFromDirectory({ candidate: input.candidate, relativePath: normalized.relativePath, stat });
} catch {
return null;
}
}
if (!input.entry.isFile()) return null;
return listLocalFileCandidate({
candidate: input.candidate,
rootReal: input.rootReal,
relativePath: input.relativePath,
normalizedQuery: input.normalizedQuery,
});
}
async function enumerateWorkspaceDirectoryChildren(input: {
candidate: WorkspaceCandidate;
rootReal: string;
startReal?: string;
startRelativePath?: string;
normalizedQuery: string | null;
limit: number;
offset: number;
}) {
let entries;
try {
entries = await fs.readdir(input.startReal ?? input.rootReal, { withFileTypes: true });
} catch {
return { items: [], scannedCount: 0, truncated: false };
}
entries.sort((a, b) => {
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
return a.name.localeCompare(b.name);
});
const items: WorkspaceFileListItem[] = [];
let scannedCount = 0;
let matchedCount = 0;
let truncated = false;
for (const entry of entries) {
scannedCount += 1;
if (scannedCount > WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) {
truncated = true;
break;
}
if (entry.name.includes("\0") || entry.name.includes("/") || entry.name.includes("\\")) continue;
const relativePath = input.startRelativePath ? `${input.startRelativePath}/${entry.name}` : entry.name;
const item = await listLocalDirectoryChildCandidate({
candidate: input.candidate,
rootReal: input.rootReal,
parentReal: input.startReal ?? input.rootReal,
relativePath,
entry,
normalizedQuery: input.normalizedQuery,
});
if (!item) continue;
if (matchedCount < input.offset) {
matchedCount += 1;
continue;
}
if (items.length >= input.limit) {
truncated = true;
break;
}
matchedCount += 1;
items.push(item);
}
return { items, scannedCount, truncated };
}
async function enumerateWorkspaceFiles(input: {
candidate: WorkspaceCandidate;
rootReal: string;
startReal?: string;
startRelativePath?: string;
mode: WorkspaceFileListMode;
normalizedQuery: string | null;
limit: number;
offset: number;
}) {
const dirs: Array<{ realPath: string; relativePath: string; depth: number }> = [
{ realPath: input.startReal ?? input.rootReal, relativePath: input.startRelativePath ?? "", depth: 0 },
];
const items: WorkspaceFileListItem[] = [];
let scannedCount = 0;
let matchedCount = 0;
let truncated = false;
let hitScanCap = false;
while (dirs.length > 0) {
const dir = dirs.shift()!;
let entries;
try {
entries = await fs.readdir(dir.realPath, { withFileTypes: true });
} catch {
continue;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
scannedCount += 1;
if (scannedCount > WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) {
truncated = true;
hitScanCap = true;
break;
}
if (entry.name.includes("\0") || entry.name.includes("/") || entry.name.includes("\\")) continue;
const relativePath = dir.relativePath ? `${dir.relativePath}/${entry.name}` : entry.name;
const segments = relativePath.split("/").filter(Boolean);
if (shouldPruneSegments(segments)) continue;
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
if (dir.depth >= MAX_LIST_DEPTH) {
truncated = true;
continue;
}
dirs.push({
realPath: path.join(dir.realPath, entry.name),
relativePath,
depth: dir.depth + 1,
});
continue;
}
if (!entry.isFile()) continue;
const item = await listLocalFileCandidate({
candidate: input.candidate,
rootReal: input.rootReal,
relativePath,
normalizedQuery: input.normalizedQuery,
});
if (!item) continue;
if (input.mode === "recent") {
items.push(item);
continue;
}
if (matchedCount < input.offset) {
matchedCount += 1;
continue;
}
if (items.length >= input.limit) {
truncated = true;
break;
}
matchedCount += 1;
items.push(item);
}
if (hitScanCap || (truncated && input.mode !== "recent")) break;
}
if (input.mode === "recent") {
items.sort((a, b) => (b.modifiedAt ?? "").localeCompare(a.modifiedAt ?? "") || a.displayPath.localeCompare(b.displayPath));
const end = input.offset + input.limit;
truncated = truncated || items.length > end;
return { items: items.slice(input.offset, end), scannedCount, truncated };
}
return { items, scannedCount, truncated };
}
function parseGitStatusPaths(stdout: string) {
const tokens = stdout.split("\0").filter(Boolean);
const paths: string[] = [];
let hitScanCap = false;
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i]!;
if (token.length < 4) continue;
const status = token.slice(0, 2);
const filePath = token.slice(3);
if (filePath) paths.push(filePath);
if (status.includes("R") || status.includes("C")) i += 1;
if (paths.length >= WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) {
hitScanCap = true;
break;
}
}
return { paths, hitScanCap };
}
async function listChangedWorkspaceFiles(input: {
candidate: WorkspaceCandidate;
rootReal: string;
normalizedQuery: string | null;
limit: number;
offset: number;
scanContext?: WorkspaceFileScanContext;
}) {
let stdout: string;
try {
const result = await workspaceGitOperationScheduler.run({
workspacePath: input.rootReal,
args: ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
operation: "workspace_file_browser.changed_files",
fairnessKeys: input.scanContext?.fairnessKeys,
signal: input.scanContext?.signal,
maxStdoutBytes: GIT_STATUS_MAX_BUFFER_BYTES,
maxStderrBytes: GIT_STATUS_MAX_BUFFER_BYTES,
// The shared scheduler defaults this to ten seconds. This deliberately
// trades a few seconds of freshness for protection from tab/refetch bursts.
});
stdout = result.stdout;
} catch (error) {
if (
isWorkspaceGitScanError(error) &&
error.code !== WORKSPACE_GIT_SCAN_ERROR_CODES.failed
) {
throw error;
}
return { unavailableReason: "changed_unavailable" as const };
}
const { paths, hitScanCap } = parseGitStatusPaths(stdout);
const matchedItems: WorkspaceFileListItem[] = [];
let scannedCount = 0;
for (const filePath of paths) {
scannedCount += 1;
const item = await listLocalFileCandidate({
candidate: input.candidate,
rootReal: input.rootReal,
relativePath: filePath,
normalizedQuery: input.normalizedQuery,
});
if (item) matchedItems.push(item);
}
matchedItems.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
const end = input.offset + input.limit;
return {
items: matchedItems.slice(input.offset, end),
scannedCount,
truncated: hitScanCap || matchedItems.length > end,
};
}
export function workspaceFileResourceService(db: Db) {
async function getIssue(issueId: string): Promise<IssueRow> {
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)).limit(1);
if (!issue) throw notFound("Issue not found");
return issue;
}
async function targetProjectWorkspaceCandidate(
issue: IssueRow,
target: WorkspaceTargetInput,
): Promise<WorkspaceCandidate | null> {
const projectId = target.projectId ?? null;
const workspaceId = target.workspaceId ?? null;
if (!projectId && !workspaceId) return null;
if (!projectId || !workspaceId) {
throw unprocessable("Workspace file target requires both projectId and workspaceId", { code: "invalid_target" });
}
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
const [workspace] = await db.select().from(projectWorkspaces).where(eq(projectWorkspaces.id, workspaceId)).limit(1);
if (!project || !workspace) throw notFound("Project workspace not found");
if (project.companyId !== issue.companyId || workspace.companyId !== issue.companyId) {
throw new HttpError(403, "Project workspace belongs to another company", { code: "cross_company_workspace" });
}
if (workspace.projectId !== project.id) {
throw unprocessable("Workspace does not belong to the selected project", { code: "workspace_project_mismatch" });
}
return candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name });
}
async function listCandidates(
issue: IssueRow,
selector: WorkspaceFileSelector,
target: WorkspaceTargetInput = {},
): Promise<WorkspaceCandidate[]> {
const explicitTarget = await targetProjectWorkspaceCandidate(issue, target);
if (explicitTarget) return [explicitTarget];
const candidates: WorkspaceCandidate[] = [];
if ((selector === "auto" || selector === "execution") && issue.projectId) {
const executionIds = [issue.executionWorkspaceId].filter((id): id is string => Boolean(id));
let executionRows: ExecutionWorkspaceRow[] = [];
if (executionIds.length > 0) {
executionRows = await db.select().from(executionWorkspaces).where(
and(
eq(executionWorkspaces.companyId, issue.companyId),
inArray(executionWorkspaces.id, executionIds),
),
);
}
const sourceIssueIds = [issue.id, issue.parentId].filter((id): id is string => Boolean(id));
if (sourceIssueIds.length > 0) {
const activeRows = await db.select().from(executionWorkspaces).where(
and(
eq(executionWorkspaces.companyId, issue.companyId),
eq(executionWorkspaces.projectId, issue.projectId),
inArray(executionWorkspaces.sourceIssueId, sourceIssueIds),
eq(executionWorkspaces.status, "active"),
isNull(executionWorkspaces.closedAt),
),
).orderBy(desc(executionWorkspaces.lastUsedAt)).limit(2);
executionRows = [...executionRows, ...activeRows];
}
const seen = new Set<string>();
for (const row of executionRows) {
if (seen.has(row.id)) continue;
seen.add(row.id);
candidates.push(candidateFromExecutionWorkspace(row));
}
}
if ((selector === "auto" || selector === "project") && issue.projectId) {
if (issue.projectWorkspaceId) {
const rows = await db.select().from(projectWorkspaces).where(
and(
eq(projectWorkspaces.companyId, issue.companyId),
eq(projectWorkspaces.projectId, issue.projectId),
eq(projectWorkspaces.id, issue.projectWorkspaceId),
),
).limit(1);
if (rows[0]) candidates.push(candidateFromProjectWorkspace(rows[0]));
}
const primaryRows = await db.select().from(projectWorkspaces).where(
and(
eq(projectWorkspaces.companyId, issue.companyId),
eq(projectWorkspaces.projectId, issue.projectId),
eq(projectWorkspaces.isPrimary, true),
),
).limit(1);
if (primaryRows[0] && !candidates.some((candidate) => candidate.workspaceId === primaryRows[0]!.id)) {
candidates.push(candidateFromProjectWorkspace(primaryRows[0]));
}
}
return candidates;
}
async function loadAvailabilityTargets(
issue: IssueRow,
queries: PreparedAvailabilityQuery[],
): Promise<Map<string, PreparedAvailabilityTarget>> {
const targetQueries = queries.filter(
(item) => !item.unavailableReason && item.query.projectId && item.query.workspaceId,
);
const projectIds = [...new Set(targetQueries.map((item) => item.query.projectId!))];
const workspaceIds = [...new Set(targetQueries.map((item) => item.query.workspaceId!))];
const [projectRows, workspaceRows] = await Promise.all([
projectIds.length > 0
? db.select().from(projects).where(inArray(projects.id, projectIds))
: Promise.resolve([]),
workspaceIds.length > 0
? db.select().from(projectWorkspaces).where(inArray(projectWorkspaces.id, workspaceIds))
: Promise.resolve([]),
]);
const projectById = new Map(projectRows.map((row) => [row.id, row]));
const workspaceById = new Map(workspaceRows.map((row) => [row.id, row]));
const targets = new Map<string, PreparedAvailabilityTarget>();
for (const item of targetQueries) {
const targetKey = `${item.query.projectId}:${item.query.workspaceId}`;
if (targets.has(targetKey)) continue;
const project = projectById.get(item.query.projectId!);
const workspace = workspaceById.get(item.query.workspaceId!);
if (!project || !workspace) {
targets.set(targetKey, { error: notFound("Project workspace not found") });
} else if (project.companyId !== issue.companyId || workspace.companyId !== issue.companyId) {
targets.set(targetKey, {
error: new HttpError(403, "Project workspace belongs to another company", { code: "cross_company_workspace" }),
});
} else if (workspace.projectId !== project.id) {
targets.set(targetKey, {
error: unprocessable("Workspace does not belong to the selected project", { code: "workspace_project_mismatch" }),
});
} else {
targets.set(targetKey, {
candidate: candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name }),
});
}
}
return targets;
}
async function sameCompanyProjectWorkspaceCandidates(
issue: IssueRow,
excludedWorkspaceIds: Set<string>,
): Promise<WorkspaceCandidate[]> {
const rows = await db.select({
workspace: projectWorkspaces,
project: {
id: projects.id,
name: projects.name,
},
})
.from(projectWorkspaces)
.innerJoin(projects, eq(projectWorkspaces.projectId, projects.id))
.where(and(
eq(projectWorkspaces.companyId, issue.companyId),
eq(projects.companyId, issue.companyId),
))
.orderBy(desc(projectWorkspaces.isPrimary), desc(projectWorkspaces.updatedAt));
const candidates: WorkspaceCandidate[] = [];
const seen = new Set(excludedWorkspaceIds);
for (const row of rows) {
if (seen.has(row.workspace.id)) continue;
seen.add(row.workspace.id);
candidates.push(candidateFromProjectWorkspace(row.workspace, row.project));
}
return candidates;
}
async function discoverUniqueProjectWorkspaceMatch<T>(
issue: IssueRow,
excludedWorkspaceIds: Set<string>,
check: (candidate: WorkspaceCandidate) => Promise<T>,
): Promise<AutoDiscovered<T>> {
const candidates = await sameCompanyProjectWorkspaceCandidates(issue, excludedWorkspaceIds);
const matches: T[] = [];
for (const candidate of candidates) {
if (candidate.remote) continue;
try {
matches.push(await check(candidate));
} catch (error) {
if (isHttpStatus(error, 404)) continue;
throw error;
}
if (matches.length > 1) return { state: "ambiguous", count: matches.length };
}
if (matches.length === 1) return { state: "one", value: matches[0]! };
return { state: "none" };
}
function throwAmbiguousWorkspacePath(count: number): never {
throw new HttpError(409, "Workspace path matched multiple project workspaces", {
code: "ambiguous_workspace_path",
matchCount: count,
});
}
async function availability(
issueId: string,
input: WorkspaceFileAvailabilityRequestInput,
opts: { issue?: IssueRow } = {},
): Promise<WorkspaceFileAvailabilityResponse> {
const issue = opts.issue ?? await getIssue(issueId);
const uniqueQueries = new Map<string, PreparedAvailabilityQuery>();
for (const inputQuery of input.queries) {
const prepared = prepareAvailabilityQuery(inputQuery);
if (!uniqueQueries.has(prepared.key)) uniqueQueries.set(prepared.key, prepared);
}
const queries = [...uniqueQueries.values()];
if (queries.length === 0) return { kind: "workspace_file_availability", results: [] };
const untargetedQueries = queries.filter(
(item) => !item.unavailableReason && !item.query.projectId && !item.query.workspaceId,
);
const initialCandidates = untargetedQueries.length > 0 ? await listCandidates(issue, "auto") : [];
const needsDiscovery = initialCandidates.some((candidate) => !candidate.remote)
&& untargetedQueries.some((item) => item.query.workspace === "auto");
const discoveryCandidates = needsDiscovery
? await sameCompanyProjectWorkspaceCandidates(issue, new Set(initialCandidates.map((candidate) => candidate.workspaceId)))
: [];
const explicitTargets = await loadAvailabilityTargets(issue, queries);
const results = await mapWithConcurrency(
queries,
WORKSPACE_FILE_AVAILABILITY_CONCURRENCY,
async (item): Promise<WorkspaceFileAvailabilityResult> => {
try {
if (item.unavailableReason || !item.normalizedPath) {
return unavailableAvailabilityResult(item.query, item.unavailableReason ?? "invalid_path");
}
const explicitTarget = item.query.projectId && item.query.workspaceId
? explicitTargets.get(`${item.query.projectId}:${item.query.workspaceId}`)
: null;
if (explicitTarget?.error) throw explicitTarget.error;
const candidates = explicitTarget?.candidate
? [explicitTarget.candidate]
: initialCandidates.filter((candidate) => {
if (item.query.workspace === "execution") return candidate.workspaceKind === "execution_workspace";
if (item.query.workspace === "project") return candidate.workspaceKind === "project_workspace";
return true;
});
if (candidates.length === 0) {
throw unprocessable("No workspace is available for this issue", { code: "no_workspace" });
}
const hasExplicitTarget = Boolean(explicitTarget?.candidate);
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.remote) {
if (hasExplicitTarget || item.query.workspace !== "auto") {
return availabilityResult(item.query, remoteResource(candidate, item.normalizedPath.relativePath));
}
continue;
}
try {
const resource = item.directory
? (await statLocalDirectory(candidate, item.normalizedPath)).resource
: (await statLocalCandidate(candidate, item.normalizedPath)).resource;
return availabilityResult(item.query, resource);
} catch (error) {
if (!hasExplicitTarget && item.query.workspace === "auto" && isHttpStatus(error, 404)) {
lastNotFound = error;
continue;
}
throw error;
}
}
if (lastNotFound && !hasExplicitTarget && item.query.workspace === "auto") {
const matches: ResolvedWorkspaceResource[] = [];
for (const candidate of discoveryCandidates) {
if (candidate.remote) continue;
try {
matches.push(item.directory
? (await statLocalDirectory(candidate, item.normalizedPath)).resource
: (await statLocalCandidate(candidate, item.normalizedPath)).resource);
} catch (error) {
if (isHttpStatus(error, 404)) continue;
throw error;
}
if (matches.length > 1) throwAmbiguousWorkspacePath(matches.length);
}
if (matches[0]) return availabilityResult(item.query, matches[0]);
}
if (lastNotFound) throw lastNotFound;
throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" });
} catch (error) {
const reason = expectedAvailabilityReason(error);
if (!reason) throw error;
return unavailableAvailabilityResult(item.query, reason);
}
},
);
return { kind: "workspace_file_availability", results };
}
async function resolve(issueId: string, input: {
path: string;
workspace?: WorkspaceFileSelector | null;
projectId?: string | null;
workspaceId?: string | null;
}, opts: { issue?: IssueRow } = {}): Promise<ResolvedWorkspaceResource> {
const issue = opts.issue ?? await getIssue(issueId);
const selector = input.workspace ?? "auto";
const explicitTarget = Boolean(input.projectId || input.workspaceId);
const isDirectoryRequest = input.path.trim().endsWith("/");
const normalized = normalizeWorkspaceRelativePath(input.path);
const candidates = await listCandidates(issue, selector, input);
if (candidates.length === 0) {
throw unprocessable("No workspace is available for this issue", { code: "no_workspace" });
}
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.remote) {
if (explicitTarget || selector !== "auto") return remoteResource(candidate, normalized.relativePath);
continue;
}
try {
return isDirectoryRequest
? (await statLocalDirectory(candidate, normalized)).resource
: (await statLocalCandidate(candidate, normalized)).resource;
} catch (error) {
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
lastNotFound = error;
continue;
}
throw error;
}
}
if (lastNotFound && !explicitTarget && selector === "auto") {
const discovered = await discoverUniqueProjectWorkspaceMatch(
issue,
new Set(candidates.map((candidate) => candidate.workspaceId)),
async (candidate) => isDirectoryRequest
? (await statLocalDirectory(candidate, normalized)).resource
: (await statLocalCandidate(candidate, normalized)).resource,
);
if (discovered.state === "one") return discovered.value;
if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count);
}
if (lastNotFound) throw lastNotFound;
throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" });
}
async function list(
issueId: string,
input: WorkspaceFileListQueryInput = {},
opts: { issue?: IssueRow; scanContext?: WorkspaceFileScanContext } = {},
): Promise<WorkspaceFileListResponse> {
const issue = opts.issue ?? await getIssue(issueId);
const selector = input.workspace ?? "auto";
const explicitTarget = Boolean(input.projectId || input.workspaceId);
const normalizedPath = input.path?.trim() ? normalizeWorkspaceRelativePath(input.path) : null;
const mode = normalizedPath ? "all" : input.mode ?? "all";
const limit = Math.min(
WORKSPACE_FILE_LIST_MAX_LIMIT,
Math.max(1, Math.floor(input.limit ?? WORKSPACE_FILE_LIST_DEFAULT_LIMIT)),
);
const offset = Math.max(0, Math.floor(input.offset ?? 0));
const q = input.q?.trim() || null;
const normalizedQuery = q?.toLowerCase() ?? null;
const candidates = await listCandidates(issue, selector, input);
if (candidates.length === 0) {
return unavailableFileList({ selector, mode, path: normalizedPath?.relativePath ?? null, q, limit, offset, reason: "no_workspace" });
}
let firstUnavailable: { candidate: WorkspaceCandidate; reason: string } | null = null;
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.remote) {
firstUnavailable ??= { candidate, reason: "remote_workspace" };
if (explicitTarget || selector !== "auto") {
return unavailableFileList({
selector,
mode,
path: normalizedPath?.relativePath ?? null,
q,
limit,
offset,
candidate,
reason: "remote_workspace",
});
}
continue;
}
let rootReal: string;
let startReal: string | undefined;
let startRelativePath: string | undefined;
try {
rootReal = await fs.realpath(candidate.rootPath!);
if (normalizedPath) {
const resolvedDirectory = await statLocalDirectory(candidate, normalizedPath);
rootReal = resolvedDirectory.rootReal;
startReal = resolvedDirectory.realPath;
startRelativePath = normalizedPath.relativePath;
}
} catch (error) {
if (normalizedPath) {
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
lastNotFound = error;
continue;
}
throw error;
}
firstUnavailable ??= { candidate, reason: "workspace_unavailable" };
if (explicitTarget || selector !== "auto") {
return unavailableFileList({
selector,
mode,
path: null,
q,
limit,
offset,
candidate,
reason: "workspace_unavailable",
});
}
continue;
}
if (mode === "changed") {
const changed = await listChangedWorkspaceFiles({
candidate,
rootReal,
normalizedQuery,
limit,
offset,
scanContext: opts.scanContext,
});
if ("unavailableReason" in changed) {
const reason = changed.unavailableReason ?? "changed_unavailable";
firstUnavailable ??= { candidate, reason };
if (explicitTarget || selector !== "auto") {
return unavailableFileList({
selector,
mode,
path: normalizedPath?.relativePath ?? null,
q,
limit,
offset,
candidate,
reason,
});
}
continue;
}
return availableFileList({
selector,
mode,
path: normalizedPath?.relativePath ?? null,
q,
limit,
offset,
candidate,
items: changed.items,
scannedCount: changed.scannedCount,
truncated: changed.truncated,
});
}
const listed = mode === "all" && !normalizedQuery
? await enumerateWorkspaceDirectoryChildren({
candidate,
rootReal,
startReal,
startRelativePath,
normalizedQuery,
limit,
offset,
})
: await enumerateWorkspaceFiles({
candidate,
rootReal,
startReal,
startRelativePath,
mode,
normalizedQuery,
limit,
offset,
});
return availableFileList({
selector,
mode,
path: normalizedPath?.relativePath ?? null,
q,
limit,
offset,
candidate,
items: listed.items,
scannedCount: listed.scannedCount,
truncated: listed.truncated,
});
}
if (lastNotFound && normalizedPath && !explicitTarget && selector === "auto") {
const discovered = await discoverUniqueProjectWorkspaceMatch(
issue,
new Set(candidates.map((candidate) => candidate.workspaceId)),
async (candidate) => {
const resolvedDirectory = await statLocalDirectory(candidate, normalizedPath);
return {
candidate,
rootReal: resolvedDirectory.rootReal,
startReal: resolvedDirectory.realPath,
startRelativePath: normalizedPath.relativePath,
};
},
);
if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count);
if (discovered.state === "one") {
const listed = mode === "all" && !normalizedQuery
? await enumerateWorkspaceDirectoryChildren({
candidate: discovered.value.candidate,
rootReal: discovered.value.rootReal,
startReal: discovered.value.startReal,
startRelativePath: discovered.value.startRelativePath,
normalizedQuery,
limit,
offset,
})
: await enumerateWorkspaceFiles({
candidate: discovered.value.candidate,
rootReal: discovered.value.rootReal,
startReal: discovered.value.startReal,
startRelativePath: discovered.value.startRelativePath,
mode,
normalizedQuery,
limit,
offset,
});
return availableFileList({
selector,
mode,
path: normalizedPath.relativePath,
q,
limit,
offset,
candidate: discovered.value.candidate,
items: listed.items,
scannedCount: listed.scannedCount,
truncated: listed.truncated,
});
}
}
return unavailableFileList({
selector,
mode,
path: normalizedPath?.relativePath ?? null,
q,
limit,
offset,
candidate: firstUnavailable?.candidate ?? null,
reason: firstUnavailable?.reason ?? "no_local_workspace",
});
}
async function readContent(issueId: string, input: {
path: string;
workspace?: WorkspaceFileSelector | null;
projectId?: string | null;
workspaceId?: string | null;
}, opts: { issue?: IssueRow } = {}): Promise<WorkspaceFileContent> {
const issue = opts.issue ?? await getIssue(issueId);
const selector = input.workspace ?? "auto";
const explicitTarget = Boolean(input.projectId || input.workspaceId);
const normalized = normalizeWorkspaceRelativePath(input.path);
const candidates = await listCandidates(issue, selector, input);
if (candidates.length === 0) {
throw unprocessable("No workspace is available for this issue", { code: "no_workspace" });
}
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.remote) {
if (explicitTarget || selector !== "auto") {
throw unprocessable("Remote workspaces cannot be previewed by the server", { code: "remote_workspace" });
}
continue;
}
let resolved: LocalResolvedFile;
try {
resolved = await statLocalCandidate(candidate, normalized);
} catch (error) {
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
lastNotFound = error;
continue;
}
throw error;
}
if (!resolved.resource.capabilities.preview) {
throw unprocessable("Workspace file cannot be previewed", { code: resolved.resource.denialReason ?? "unsupported_content" });
}
const cap = previewCapForKind(resolved.resource.previewKind);
const data = await readStableFile(resolved.realPath, cap);
if (resolved.resource.previewKind === "text" && !looksLikeText(data.subarray(0, Math.min(data.length, TEXT_SNIFF_BYTES)))) {
throw unprocessable("Workspace file is not a text file", { code: "binary_content" });
}
return {
resource: resolved.resource,
content: {
encoding: resolved.resource.previewKind === "text" ? "utf8" : "base64",
data: resolved.resource.previewKind === "text" ? data.toString("utf8") : data.toString("base64"),
},
};
}
if (lastNotFound && !explicitTarget && selector === "auto") {
const discovered = await discoverUniqueProjectWorkspaceMatch(
issue,
new Set(candidates.map((candidate) => candidate.workspaceId)),
async (candidate) => statLocalCandidate(candidate, normalized),
);
if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count);
if (discovered.state === "one") {
const resolved = discovered.value;
if (!resolved.resource.capabilities.preview) {
throw unprocessable("Workspace file cannot be previewed", { code: resolved.resource.denialReason ?? "unsupported_content" });
}
const cap = previewCapForKind(resolved.resource.previewKind);
const data = await readStableFile(resolved.realPath, cap);
if (resolved.resource.previewKind === "text" && !looksLikeText(data.subarray(0, Math.min(data.length, TEXT_SNIFF_BYTES)))) {
throw unprocessable("Workspace file is not a text file", { code: "binary_content" });
}
return {
resource: resolved.resource,
content: {
encoding: resolved.resource.previewKind === "text" ? "utf8" : "base64",
data: resolved.resource.previewKind === "text" ? data.toString("utf8") : data.toString("base64"),
},
};
}
}
if (lastNotFound) throw lastNotFound;
throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" });
}
async function prepareDownload(issueId: string, input: {
path: string;
workspace?: WorkspaceFileSelector | null;
projectId?: string | null;
workspaceId?: string | null;
}, opts: { issue?: IssueRow } = {}): Promise<LocalResolvedFile> {
const issue = opts.issue ?? await getIssue(issueId);
const selector = input.workspace ?? "auto";
const explicitTarget = Boolean(input.projectId || input.workspaceId);
const normalized = normalizeWorkspaceRelativePath(input.path);
const candidates = await listCandidates(issue, selector, input);
if (candidates.length === 0) {
throw unprocessable("No workspace is available for this issue", { code: "no_workspace" });
}
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.remote) {
if (explicitTarget || selector !== "auto") {
throw unprocessable("Remote workspaces cannot be downloaded by the server", { code: "remote_workspace" });
}
continue;
}
try {
return await statLocalCandidate(candidate, normalized);
} catch (error) {
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
lastNotFound = error;
continue;
}
throw error;
}
}
if (lastNotFound && !explicitTarget && selector === "auto") {
const discovered = await discoverUniqueProjectWorkspaceMatch(
issue,
new Set(candidates.map((candidate) => candidate.workspaceId)),
async (candidate) => statLocalCandidate(candidate, normalized),
);
if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count);
if (discovered.state === "one") return discovered.value;
}
if (lastNotFound) throw lastNotFound;
throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" });
}
return {
getIssue,
availability,
list,
resolve,
readContent,
prepareDownload,
};
}