Files
paperclip/server/src/services/workspace-file-resources.ts
T
Dotta 468edd8b22 Add workspace file viewer and artifact links (#7681)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent work is issue-centered, and reviewers often need to inspect
files, artifacts, and path references produced during that work.
> - Before this branch, workspace-relative paths and artifact file
references were not first-class inspectable objects in the board UI.
> - Safe file viewing needs shared resource contracts, server-side
workspace boundary checks, and UI that opens files without exposing
arbitrary host paths.
> - The workspace file viewer branch needed to stay as one active PR and
be rebased onto current `paperclipai/paperclip:master` for review.
> - This pull request adds the workspace file resource API, issue-page
file viewer and browser, markdown file-reference links, and artifact
file chips.
> - The benefit is that board users can inspect relevant files from
issue context while preserving workspace boundaries and auditability.

## Linked Issues or Issue Description

No public GitHub issue exists for this branch. Internal Paperclip
issues: `PAP-1953`, `PAP-10539`, `PAP-10733`.

Problem / motivation:
- Board users need to open workspace-relative files mentioned by agents
or attached as work-product metadata without switching to a terminal.
- The UI needs to support both direct file-path opening and workspace
browsing/searching from an issue page.
- The server must enforce company access, workspace boundaries, size
limits, rate limits, and safe audit logging.

Related PR:
- Prior closed attempt: #4442
- Single active PR for this branch: #7681

## What Changed

- Added shared workspace file resource types, validators, and
workspace-file `resourceRef` metadata validation for work products.
- Added server routes/services for resolving, listing, and previewing
workspace-relative files with access checks, scan caps, list-specific
limits, and audit logging.
- Added the issue file viewer provider, sheet, workspace browser,
command-palette action, markdown workspace-file autolinks, and artifact
file chips.
- Updated issue workspace UI and stories/tests for file browsing and
workspace file opening.
- Rebased the branch onto current `paperclipai/paperclip:master` and
updated the existing single PR branch.
- Addressed current-head Greptile follow-ups by applying `offset`
consistently across search/recent/changed file listings, restoring
stopped-service port ownership checks before auto-port reuse, and
stabilizing the workspace browser pagination test.

## Verification

Current local verification after rebase to `public/master`:
- `pnpm exec vitest run packages/shared/src/work-product.test.ts
server/src/__tests__/file-resources.test.ts
server/src/__tests__/instance-settings-routes.test.ts
server/src/__tests__/instance-settings-service.test.ts
server/src/__tests__/workspace-runtime.test.ts
ui/src/components/FileViewerSheet.test.tsx
ui/src/components/FileViewerSheet.copy.test.tsx
ui/src/components/WorkspaceFileBrowser.test.tsx
ui/src/components/WorkspaceFileMarkdownBody.test.tsx
ui/src/context/FileViewerContext.test.ts
ui/src/lib/remark-workspace-file-refs.test.ts
ui/src/lib/workspace-file-parser.test.ts
ui/src/components/IssueWorkspaceCard.test.tsx` - 13 files passed, 197
tests passed.
- `pnpm -r --filter @paperclipai/shared --filter @paperclipai/server
--filter @paperclipai/ui typecheck` - passed.
- `pnpm exec vitest run ui/src/components/WorkspaceFileBrowser.test.tsx`
- 1 file passed, 25 tests passed.
- `pnpm exec vitest run server/src/__tests__/file-resources.test.ts
server/src/__tests__/workspace-runtime.test.ts` - 2 files passed, 90
tests passed.
- `pnpm -r --filter @paperclipai/server typecheck` - passed.
- Confirmed branch is `0` behind and `46` ahead of current
`public/master` after rebase and follow-up commits.
- Confirmed the PR diff does not include `pnpm-lock.yaml`.
- Confirmed the PR diff does not include `.github/workflows` changes.
- Searched GitHub for duplicate or related workspace file viewer
PRs/issues; #4442 is the prior closed attempt and this PR is the single
active PR for the branch.
- No screenshots were committed; the task explicitly asked not to add
design screenshots or images unless they were part of the work.

Current remote verification on head
`a698a7bc10137baf7d25bd5722e1d6e0343387c1`:
- Greptile Review - success, 64 files reviewed, 0 comments added, no
unresolved Greptile review threads.
- PR workflow `verify` - success.
- Typecheck + Release Registry, General tests, workspace test shards,
serialized server suites, Build, Canary Dry Run, e2e, Socket, and Snyk -
success.
- `security-review` - neutral, with output saying a draft advisory was
filed for maintainer review and is not a merge block.
- `commitperclip PR Review / review` - cancelled after the security gate
detected flags and timed out while creating/reviewing the advisory. I
reran it once and it cancelled the same way; no actionable code/test
failure was exposed in the job logs.

## Risks

- This is a broad UI/server feature PR, so review needs to pay attention
to route authorization, workspace boundary handling, and markdown
autolink false positives.
- Workspace browsing intentionally caps list results and scan depth;
very large workspaces may require users to refine search terms.
- Remote workspace preview remains unavailable until remote file-access
support is implemented.
- The neutral commitperclip security-review advisory needs maintainer
review, but the check output says it is not a merge block.

> 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 coding agent in a Paperclip/Codex local tool-use
environment, medium reasoning, with shell/GitHub CLI tool use for branch
inspection, verification, rebase, PR update, Greptile review, and CI
inspection.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] 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>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 17:17:43 -05:00

1404 lines
47 KiB
TypeScript

import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
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 {
ResolvedWorkspaceResource,
WorkspaceFileContent,
WorkspaceFileListItem,
WorkspaceFileListMode,
WorkspaceFileListResponse,
WorkspaceFilePreviewKind,
WorkspaceFileSelector,
WorkspaceFileWorkspaceKind,
} from "@paperclipai/shared";
import { HttpError, notFound, unprocessable } from "../errors.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;
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 execFileAsync = promisify(execFile);
const LOCAL_PROJECT_WORKSPACE_SOURCE_TYPES = new Set(["local_path", "non_git_path", "git_repo", "git_worktree"]);
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;
};
function previewCapForKind(kind: WorkspaceFilePreviewKind) {
return kind === "image" || kind === "video" || kind === "pdf"
? WORKSPACE_FILE_MEDIA_MAX_BYTES
: WORKSPACE_FILE_TEXT_MAX_BYTES;
}
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);
if (!previewKind || previewKind === "unsupported") return null;
if (input.stat.size > previewCapForKind(previewKind)) return null;
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: true,
download: false,
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: false,
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;
}) {
let stdout: string;
try {
const result = await execFileAsync(
"git",
["-C", input.rootReal, "status", "--porcelain=v1", "-z", "--untracked-files=all"],
{ maxBuffer: GIT_STATUS_MAX_BUFFER_BYTES },
);
stdout = result.stdout;
} catch {
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 sameCompanyProjectWorkspaceCandidates(
issue: IssueRow,
excludedWorkspaceIds: Set<string>,
): Promise<WorkspaceCandidate[]> {
const rows = await db.select({
workspace: projectWorkspaces,
project: {
id: projects.id,
name: projects.name,
},
})
.from(projectWorkspaces)
.innerJoin(projects, eq(projectWorkspaces.projectId, projects.id))
.where(and(
eq(projectWorkspaces.companyId, issue.companyId),
eq(projects.companyId, issue.companyId),
))
.orderBy(desc(projectWorkspaces.isPrimary), desc(projectWorkspaces.updatedAt));
const candidates: WorkspaceCandidate[] = [];
const seen = new Set(excludedWorkspaceIds);
for (const row of rows) {
if (seen.has(row.workspace.id)) continue;
seen.add(row.workspace.id);
candidates.push(candidateFromProjectWorkspace(row.workspace, row.project));
}
return candidates;
}
async function discoverUniqueProjectWorkspaceMatch<T>(
issue: IssueRow,
excludedWorkspaceIds: Set<string>,
check: (candidate: WorkspaceCandidate) => Promise<T>,
): Promise<AutoDiscovered<T>> {
const candidates = await sameCompanyProjectWorkspaceCandidates(issue, excludedWorkspaceIds);
const matches: T[] = [];
for (const candidate of candidates) {
if (candidate.remote) continue;
try {
matches.push(await check(candidate));
} catch (error) {
if (isHttpStatus(error, 404)) continue;
throw error;
}
if (matches.length > 1) return { state: "ambiguous", count: matches.length };
}
if (matches.length === 1) return { state: "one", value: matches[0]! };
return { state: "none" };
}
function throwAmbiguousWorkspacePath(count: number): never {
throw new HttpError(409, "Workspace path matched multiple project workspaces", {
code: "ambiguous_workspace_path",
matchCount: count,
});
}
async function resolve(issueId: string, input: {
path: string;
workspace?: WorkspaceFileSelector | null;
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 } = {},
): 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 });
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" });
}
return {
getIssue,
list,
resolve,
readContent,
};
}