mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents deliver reports as task attachments and workspace files. > - The board already renders Markdown, but it shows HTML reports as source or rejects their preview. > - Reports can need inline scripts to build charts and tables. > - Artifact scripts must not read board cookies, storage, or the parent page. > - This pull request adds an opaque-origin HTML preview and view controls beside Download. > - Users can explore a report, inspect its source, and download the original file. ## Linked Issues or Issue Description **What existing behavior does this improve?** The task attachment panel and workspace file viewers. **Subsystem affected** ui/ and the server workspace file preview service. **Current behavior** HTML attachments show source text. Workspace HTML files cannot be previewed. **Proposed behavior** Render self-contained HTML reports in a sandboxed iframe. Place eye and code controls beside Download. Preserve the original file for raw view and download. **Reason and benefit** Users can read and filter agent reports in Paperclip without giving artifact scripts access to the board session. **Breaking changes** HTML previews now open rendered. Scripts and styles must be embedded in the report. Remote resources, API requests, forms, popups, and host navigation are blocked. Workspace HTML content uses the existing bounded UTF-8 JSON response. **Additional context** Related closed proposals: #3293 and #4857. This change uses the current attachment and workspace viewers and adds no report-serving endpoint. The Artifacts and Work Products roadmap item is complete; this improves its existing preview behavior. ## What Changed - Add a shared HTML iframe renderer with `sandbox="allow-scripts"` and no `allow-same-origin`. - Install a restrictive CSP before artifact markup. Keep inline report scripts and styles. - Add shared rendered/raw icon controls beside Download in the attachment panel, workspace panel, and file sheet. - Return workspace HTML as bounded text inside JSON. Keep download and path-access protections. - Add Storybooks for reports, security probes, workspace viewers, a task journey, and mobile layouts. - Document the security boundary and browser test command. ## Verification - `pnpm -r typecheck` and `pnpm build` pass. - Token gates and the static Storybook build pass. - Four browser tests pass. They cover report filters, raw mode, task entry, workspace viewers, cookies, storage, host DOM, resource requests, forms, popups, and host navigation. - The focused UI suite passes 26 tests. The file-resource server suite passes 36 tests. - Local full-stack acceptance passes with an actual 52 KB HTML report. Its chart and filter render. Raw mode preserves the source. Download bytes match the original file. - The complete local UI suite passes: 698 files and 7,754 tests. - `pnpm test:run` was attempted. Its server group recorded two unrelated timeouts and eight connector failures. All failed cases pass in isolated reruns, including the complete 388-test connector suite. The remaining local run was stopped after the full CI test coverage passed, to release test database resources. The full local command did not complete cleanly. - The separate local CLI group passes 511 tests. Three worktree database cases fail to start embedded PostgreSQL because this Mac has exhausted its shared-memory allocation. One isolated rerun fails at the same database startup step. No CLI code was changed. The corresponding CI test jobs pass. - All 56 PR checks are clean on `c771255f1a2e86bd8825f22eafb5d14a5e342cce`. Greptile gives 5/5 with no actionable findings. The security scan passes. The branch has no merge conflict. - Review the **HTML artifacts** section in Storybook. Run `pnpm exec playwright test --config tests/html-preview/playwright.config.ts` for the browser checks. ## Risks - Never add `allow-same-origin` to this iframe. Its opaque origin is the cookie, storage, and host-DOM boundary. - Reports that depend on CDN assets need to embed their dependencies. - The frame can navigate itself. Sandbox restrictions remain after navigation. This is not full network isolation. - Inline scripts can consume browser resources. This change does not isolate CPU or memory use. - No database migrations or API shape changes are required. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository editing, code execution, and browser tool use. The runtime does not expose a more specific model ID or context window size. ## 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 - [ ] I have run tests locally and they pass — feature and UI checks pass; the full local run has the infrastructure limits recorded above. All CI test jobs 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>
1764 lines
62 KiB
TypeScript
1764 lines
62 KiB
TypeScript
import { issueReadSqlCondition, executionWorkspaceReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
|
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 });
|
|
}
|
|
}
|
|
|
|
/** Apply the existing file-path policy to other read-only file surfaces. */
|
|
export function assertWorkspaceFilePathAllowed(relativePath: string): void {
|
|
throwIfDenied(normalizeWorkspaceRelativePath(relativePath).segments);
|
|
}
|
|
|
|
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";
|
|
// HTML is returned as bounded UTF-8 in the JSON content response. The UI
|
|
// renders it only in its opaque-origin sandbox; downloads stay attachments.
|
|
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, actor?: AuthorizationActor) {
|
|
async function visibleCandidates(candidates: WorkspaceCandidate[]) {
|
|
if (!actor) return candidates;
|
|
const visible: WorkspaceCandidate[] = [];
|
|
for (const candidate of candidates) {
|
|
const rows = candidate.workspaceKind === "execution_workspace"
|
|
? await db.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
|
|
.where(and(eq(executionWorkspaces.id, candidate.workspaceId), await executionWorkspaceReadSqlCondition(db, actor)))
|
|
: await db.select({ id: projectWorkspaces.id }).from(projectWorkspaces)
|
|
.innerJoin(projects, eq(projects.id, projectWorkspaces.projectId))
|
|
.where(and(eq(projectWorkspaces.id, candidate.workspaceId), await projectReadSqlCondition(db, actor)));
|
|
if (rows.length) visible.push(candidate);
|
|
}
|
|
return visible;
|
|
}
|
|
async function getIssue(issueId: string): Promise<IssueRow> {
|
|
const [issue] = await db.select().from(issues).where(and(eq(issues.id, issueId), actor ? await issueReadSqlCondition(db, actor) : undefined)).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" });
|
|
}
|
|
|
|
const [candidate] = await visibleCandidates([candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name })]);
|
|
if (!candidate) throw notFound("Project workspace not found");
|
|
return candidate;
|
|
}
|
|
|
|
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 visibleCandidates(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 {
|
|
const [candidate] = await visibleCandidates([candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name })]);
|
|
targets.set(targetKey, candidate ? { candidate } : { error: notFound("Project workspace not found") });
|
|
}
|
|
}
|
|
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 visibleCandidates(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,
|
|
};
|
|
}
|