diff --git a/packages/plugins/plugin-workspace-diff/package.json b/packages/plugins/plugin-workspace-diff/package.json index 880a43e2305..8bdc8bd7a1d 100644 --- a/packages/plugins/plugin-workspace-diff/package.json +++ b/packages/plugins/plugin-workspace-diff/package.json @@ -1,7 +1,7 @@ { "name": "@paperclipai/plugin-workspace-diff", "version": "0.1.0", - "description": "First-party execution workspace Changes tab powered by Paperclip's host diff service", + "description": "First-party execution workspace Changes tab powered by plugin-local workspace metadata", "type": "module", "private": true, "exports": { diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index c4231b9e31c..5b0795afcad 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -295,10 +295,12 @@ export function ChangesTab({ context }: PluginDetailTabProps) { const params = useMemo(() => ({ workspaceId: context.entityId, companyId: context.companyId ?? "", + projectId: context.projectId ?? "", + entityType: context.entityType, view, baseRef: baseRef.trim() || null, includeUntracked, - }), [baseRef, context.companyId, context.entityId, includeUntracked, view]); + }), [baseRef, context.companyId, context.entityId, context.entityType, context.projectId, includeUntracked, view]); const { data, loading, error, refresh } = usePluginData("workspace-diff", params); const files = useMemo(() => toFileViewModels(data), [data]); diff --git a/packages/plugins/plugin-workspace-diff/src/worker.ts b/packages/plugins/plugin-workspace-diff/src/worker.ts index e05774f2e96..ebcc80695aa 100644 --- a/packages/plugins/plugin-workspace-diff/src/worker.ts +++ b/packages/plugins/plugin-workspace-diff/src/worker.ts @@ -1,5 +1,6 @@ import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; import type { WorkspaceDiffQueryOptions } from "@paperclipai/plugin-sdk"; +import { workspaceDiffService } from "./workspace-diff.js"; const PLUGIN_NAME = "workspace-diff"; @@ -16,7 +17,7 @@ function readPaths(value: unknown): string[] { }); } -function readDiffOptions(params: Record): Partial { +function readDiffOptions(params: Record): WorkspaceDiffQueryOptions { const view = params.view === "head" ? "head" : "working-tree"; const baseRef = readString(params.baseRef) || null; const includeUntracked = typeof params.includeUntracked === "boolean" @@ -33,6 +34,7 @@ function readDiffOptions(params: Record): Partial) => { const workspaceId = readString(params.workspaceId); @@ -41,11 +43,29 @@ const plugin = definePlugin({ throw new Error("workspaceId and companyId are required"); } - return ctx.executionWorkspaces.getDiff( - workspaceId, - companyId, - readDiffOptions(params), - ); + if (params.entityType === "project_workspace") { + const projectId = readString(params.projectId); + if (!projectId) { + throw new Error("projectId is required for project workspace diffs"); + } + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + const workspace = workspaces.find((candidate) => candidate.id === workspaceId); + if (!workspace) { + throw new Error("Workspace not found"); + } + return workspaceDiff.getDiff({ + id: workspace.id, + companyId, + cwd: workspace.path, + baseRef: null, + }, readDiffOptions(params)); + } + + const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId); + if (!workspace) { + throw new Error("Workspace not found"); + } + return workspaceDiff.getDiff(workspace, readDiffOptions(params)); }); }, diff --git a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts new file mode 100644 index 00000000000..39ced604301 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts @@ -0,0 +1,691 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { + PluginExecutionWorkspaceMetadata, + WorkspaceDiffCaps, + WorkspaceDiffFile, + WorkspaceDiffFilePatch, + WorkspaceDiffFileStatus, + WorkspaceDiffPatchKind, + WorkspaceDiffQueryOptions, + WorkspaceDiffResponse, + WorkspaceDiffWarning, + WorkspaceDiffWarningCode, +} from "@paperclipai/plugin-sdk"; + +const execFileAsync = promisify(execFile); + +export const WORKSPACE_DIFF_CAPS: WorkspaceDiffCaps = { + maxFiles: 200, + maxFileBytes: 512 * 1024, + maxPatchBytes: 256 * 1024, + maxTotalPatchBytes: 1024 * 1024, +}; + +const GIT_TIMEOUT_MS = 10_000; +const GIT_LIST_MAX_BUFFER = 2 * 1024 * 1024; + +interface GitStatusEntry { + status: WorkspaceDiffFileStatus; + path: string; + oldPath: string | null; +} + +type DiffScope = "staged" | "unstaged" | "head"; + +interface MutableWorkspaceDiffFile extends WorkspaceDiffFile { + patchScopes: DiffScope[]; +} + +interface PatchBudget { + totalPatchBytes: number; +} + +type WorkspaceDiffTarget = Pick; + +function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning { + return { code, message, path: filePath }; +} + +function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record = {}) { + const error = new Error(message); + Object.assign(error, { code, details: { code, ...details } }); + return error; +} + +function toErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + return String(error); +} + +async function runGit(cwd: string, args: string[], maxBuffer = GIT_LIST_MAX_BUFFER) { + try { + return await execFileAsync("git", ["-C", cwd, ...args], { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer, + }); + } catch (error) { + const stderr = typeof (error as { stderr?: unknown }).stderr === "string" + ? String((error as { stderr?: unknown }).stderr).trim() + : ""; + const message = stderr || toErrorMessage(error); + throw workspaceDiffError("git_command_failed", message, { args }); + } +} + +async function realDirectory(value: string, code: WorkspaceDiffWarningCode) { + if (!path.isAbsolute(value)) { + throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value }); + } + let stat: Awaited>; + try { + stat = await fs.stat(value); + } catch { + throw workspaceDiffError(code, "Execution workspace path does not exist", { cwd: value }); + } + if (!stat.isDirectory()) { + throw workspaceDiffError(code, "Execution workspace path is not a directory", { cwd: value }); + } + return await fs.realpath(value); +} + +function isWithinDirectory(childPath: string, parentPath: string) { + const relative = path.relative(parentPath, childPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) { + if (!workspace.cwd?.trim()) { + throw workspaceDiffError( + "missing_cwd", + "Execution workspace needs a local path before Paperclip can inspect diffs", + { workspaceId: workspace.id }, + ); + } + + const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid"); + let repoRoot: string; + try { + repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim(); + } catch { + throw workspaceDiffError( + "non_git_workspace", + "Execution workspace path is not inside a git repository", + { workspaceId: workspace.id, cwd }, + ); + } + + const repoRootReal = await realDirectory(repoRoot, "non_git_workspace"); + if (!isWithinDirectory(cwd, repoRootReal)) { + throw workspaceDiffError( + "workspace_path_invalid", + "Execution workspace path resolved outside its git repository", + { workspaceId: workspace.id, cwd, repoRoot: repoRootReal }, + ); + } + + return { cwd, repoRoot: repoRootReal }; +} + +function normalizePathFilter(rawPath: string) { + const value = rawPath.trim().replaceAll("\\", "/"); + if (!value || value === ".") return null; + if (value.includes("\0") || value.startsWith("/")) { + throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath }); + } + const normalized = path.posix.normalize(value); + if ( + normalized === "." || + normalized === ".." || + normalized.startsWith("../") || + normalized.includes("/../") + ) { + throw workspaceDiffError( + "path_filter_invalid", + "Path filters must not contain traversal segments", + { path: rawPath }, + ); + } + return normalized; +} + +function normalizePathFilters(paths: string[]) { + return Array.from(new Set(paths.map(normalizePathFilter).filter((value): value is string => Boolean(value)))); +} + +function statusFromGitStatus(status: string): WorkspaceDiffFileStatus { + if (status.startsWith("R")) return "renamed"; + if (status.startsWith("C")) return "copied"; + switch (status[0]) { + case "A": + return "added"; + case "D": + return "deleted"; + case "M": + return "modified"; + case "T": + return "type_changed"; + default: + return "unknown"; + } +} + +function parseNameStatus(output: string): GitStatusEntry[] { + const tokens = output.split("\0").filter(Boolean); + const entries: GitStatusEntry[] = []; + let index = 0; + while (index < tokens.length) { + const statusCode = tokens[index++] ?? ""; + if (!statusCode) continue; + if (statusCode.startsWith("R") || statusCode.startsWith("C")) { + const oldPath = tokens[index++] ?? ""; + const newPath = tokens[index++] ?? ""; + if (newPath) { + entries.push({ + status: statusFromGitStatus(statusCode), + path: newPath, + oldPath: oldPath || null, + }); + } + continue; + } + + const filePath = tokens[index++] ?? ""; + if (filePath) { + entries.push({ + status: statusFromGitStatus(statusCode), + path: filePath, + oldPath: null, + }); + } + } + return entries; +} + +async function readDiffNameStatus(cwd: string, scopeArgs: string[], paths: string[]) { + const result = await runGit(cwd, [ + "diff", + "--name-status", + "-z", + "--no-ext-diff", + "--find-renames", + ...scopeArgs, + "--", + ...paths, + ]); + return parseNameStatus(result.stdout); +} + +async function readUntrackedPaths(cwd: string, paths: string[]) { + const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]); + return result.stdout.split("\0").filter(Boolean); +} + +function ensureFile( + files: Map, + filePath: string, + status: WorkspaceDiffFileStatus, + oldPath: string | null, +) { + const existing = files.get(filePath); + if (existing) { + if (existing.status === "unknown" || status === "renamed" || status === "copied") { + existing.status = status; + } + if (!existing.oldPath && oldPath) existing.oldPath = oldPath; + return existing; + } + + const file: MutableWorkspaceDiffFile = { + path: filePath, + oldPath, + status, + staged: false, + unstaged: false, + untracked: false, + binary: false, + oversized: false, + truncated: false, + additions: 0, + deletions: 0, + sizeBytes: null, + patches: [], + warnings: [], + patchScopes: [], + }; + files.set(filePath, file); + return file; +} + +function addStatusEntries( + files: Map, + entries: GitStatusEntry[], + scope: DiffScope, +) { + for (const entry of entries) { + const file = ensureFile(files, entry.path, entry.status, entry.oldPath); + if (scope === "staged") file.staged = true; + else if (scope === "unstaged") file.unstaged = true; + if (!file.patchScopes.includes(scope)) file.patchScopes.push(scope); + } +} + +function parseNumstat(output: string) { + const line = output.split(/\r?\n/).find(Boolean); + if (!line) return { additions: 0, deletions: 0, binary: false }; + const [additionsRaw, deletionsRaw] = line.split(/\t/); + if (additionsRaw === "-" || deletionsRaw === "-") { + return { additions: 0, deletions: 0, binary: true }; + } + return { + additions: Number.parseInt(additionsRaw ?? "0", 10) || 0, + deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0, + binary: false, + }; +} + +async function readNumstat(cwd: string, scopeArgs: string[], filePath: string) { + const result = await runGit(cwd, [ + "diff", + "--numstat", + "--no-ext-diff", + "--find-renames", + ...scopeArgs, + "--", + filePath, + ], 128 * 1024); + return parseNumstat(result.stdout); +} + +async function statWorkspaceFile(repoRoot: string, filePath: string) { + const target = path.resolve(repoRoot, filePath); + if (!isWithinDirectory(target, repoRoot)) return null; + try { + const stat = await fs.stat(target); + return stat.isFile() ? stat.size : null; + } catch { + return null; + } +} + +function isMaxBufferError(error: unknown) { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; +} + +async function readPatchOutput(cwd: string, args: string[]) { + try { + return await execFileAsync("git", ["-C", cwd, ...args], { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024, + }); + } catch (error) { + if (isMaxBufferError(error)) { + return null; + } + const stderr = typeof (error as { stderr?: unknown }).stderr === "string" + ? String((error as { stderr?: unknown }).stderr).trim() + : ""; + throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args }); + } +} + +function reservePatchBytes( + patch: string, + budget: PatchBudget, + filePath: string, + warnings: WorkspaceDiffWarning[], +) { + const patchBytes = Buffer.byteLength(patch, "utf8"); + if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) { + warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath)); + return null; + } + if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) { + warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath)); + return null; + } + budget.totalPatchBytes += patchBytes; + return patch; +} + +async function buildTrackedPatch(input: { + cwd: string; + repoRoot: string; + filePath: string; + kind: WorkspaceDiffPatchKind; + scopeArgs: string[]; + budget: PatchBudget; +}): Promise { + const warnings: WorkspaceDiffWarning[] = []; + const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath); + const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath); + + if (numstat.binary) { + warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath)); + return { + kind: input.kind, + patch: null, + additions: 0, + deletions: 0, + binary: true, + oversized: false, + truncated: false, + warnings, + }; + } + + if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) { + warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath)); + return { + kind: input.kind, + patch: null, + additions: numstat.additions, + deletions: numstat.deletions, + binary: false, + oversized: true, + truncated: false, + warnings, + }; + } + + const patchOutput = await readPatchOutput(input.cwd, [ + "diff", + "--no-ext-diff", + "--find-renames", + "--unified=3", + ...input.scopeArgs, + "--", + input.filePath, + ]); + if (!patchOutput) { + warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath)); + return { + kind: input.kind, + patch: null, + additions: numstat.additions, + deletions: numstat.deletions, + binary: false, + oversized: false, + truncated: true, + warnings, + }; + } + + const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings); + return { + kind: input.kind, + patch, + additions: numstat.additions, + deletions: numstat.deletions, + binary: false, + oversized: false, + truncated: patch === null, + warnings, + }; +} + +function isProbablyBinary(buffer: Buffer) { + return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0); +} + +function countAddedLines(content: string) { + if (content.length === 0) return 0; + return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length; +} + +function buildUntrackedPatch(filePath: string, content: string) { + const lines = content.length === 0 ? [] : content.split("\n"); + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + const lineCount = countAddedLines(content); + const header = [ + `diff --git a/${filePath} b/${filePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${filePath}`, + ]; + if (lineCount === 0) return `${header.join("\n")}\n`; + const hunkLines = lines.map((line) => `+${line}`).join("\n"); + return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n"); +} + +async function buildUntrackedFilePatch(input: { + repoRoot: string; + filePath: string; + budget: PatchBudget; +}): Promise { + const warnings: WorkspaceDiffWarning[] = []; + const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath); + if (sizeBytes === null) { + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) { + warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath)); + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: true, + truncated: false, + warnings, + }; + } + + const buffer = await fs.readFile(path.join(input.repoRoot, input.filePath)); + if (isProbablyBinary(buffer)) { + warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath)); + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: true, + oversized: false, + truncated: false, + warnings, + }; + } + + const content = buffer.toString("utf8"); + const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings); + return { + kind: "untracked", + patch, + additions: countAddedLines(content), + deletions: 0, + binary: false, + oversized: false, + truncated: patch === null, + warnings, + }; +} + +function applyPatchToFile(file: MutableWorkspaceDiffFile, patch: WorkspaceDiffFilePatch, sizeBytes: number | null) { + file.patches.push(patch); + file.additions += patch.additions; + file.deletions += patch.deletions; + file.binary = file.binary || patch.binary; + file.oversized = file.oversized || patch.oversized; + file.truncated = file.truncated || patch.truncated; + file.warnings.push(...patch.warnings); + if (file.sizeBytes === null && sizeBytes !== null) file.sizeBytes = sizeBytes; +} + +function finalizeStats(files: WorkspaceDiffFile[]) { + return { + fileCount: files.length, + stagedFileCount: files.filter((file) => file.staged).length, + unstagedFileCount: files.filter((file) => file.unstaged).length, + untrackedFileCount: files.filter((file) => file.untracked).length, + binaryFileCount: files.filter((file) => file.binary).length, + oversizedFileCount: files.filter((file) => file.oversized).length, + truncatedFileCount: files.filter((file) => file.truncated).length, + additions: files.reduce((sum, file) => sum + file.additions, 0), + deletions: files.reduce((sum, file) => sum + file.deletions, 0), + }; +} + +async function resolveHeadSha(cwd: string) { + try { + return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null; + } catch { + return null; + } +} + +async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) { + const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null; + if (!resolvedBaseRef) { + throw workspaceDiffError( + "base_ref_missing", + "A baseRef query parameter or execution workspace baseRef is required for head diffs", + { workspaceId: workspace.id }, + ); + } + try { + await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: 128 * 1024, + }); + } catch { + throw workspaceDiffError( + "base_ref_invalid", + `Could not resolve baseRef "${resolvedBaseRef}" in this workspace`, + { workspaceId: workspace.id, baseRef: resolvedBaseRef }, + ); + } + return resolvedBaseRef; +} + +async function collectFiles(input: { + cwd: string; + workspace: WorkspaceDiffTarget; + query: WorkspaceDiffQueryOptions; + paths: string[]; +}) { + const files = new Map(); + let baseRef: string | null = null; + + if (input.query.view === "head") { + baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace); + addStatusEntries( + files, + await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths), + "head", + ); + } else { + addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged"); + addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged"); + if (input.query.includeUntracked) { + for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) { + const file = ensureFile(files, untrackedPath, "untracked", null); + file.untracked = true; + if (!file.patchScopes.includes("unstaged")) file.patchScopes.push("unstaged"); + } + } + } + + return { files, baseRef }; +} + +export function workspaceDiffService() { + return { + async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise { + const { cwd, repoRoot } = await resolveWorkspacePaths(workspace); + const paths = normalizePathFilters(query.paths); + const warnings: WorkspaceDiffWarning[] = []; + const { files: filesByPath, baseRef } = await collectFiles({ cwd, workspace, query, paths }); + const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path)); + const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles); + if (allFiles.length > cappedFiles.length) { + warnings.push(warning( + "file_count_truncated", + `Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`, + )); + } + + const patchBudget: PatchBudget = { totalPatchBytes: 0 }; + for (const file of cappedFiles) { + if (query.view === "head") { + const patch = await buildTrackedPatch({ + cwd, + repoRoot, + filePath: file.path, + kind: "head", + scopeArgs: [`${baseRef}...HEAD`], + budget: patchBudget, + }); + applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); + continue; + } + + if (file.staged) { + const patch = await buildTrackedPatch({ + cwd, + repoRoot, + filePath: file.path, + kind: "staged", + scopeArgs: ["--cached"], + budget: patchBudget, + }); + applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); + } + if (file.unstaged) { + const patch = await buildTrackedPatch({ + cwd, + repoRoot, + filePath: file.path, + kind: "unstaged", + scopeArgs: [], + budget: patchBudget, + }); + applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); + } + if (file.untracked) { + const patch = await buildUntrackedFilePatch({ + repoRoot, + filePath: file.path, + budget: patchBudget, + }); + applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); + } + } + + const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file); + const patchWarnings = files.flatMap((file) => file.warnings); + return { + workspaceId: workspace.id, + companyId: workspace.companyId, + view: query.view, + baseRef, + headSha: await resolveHeadSha(cwd), + includeUntracked: query.includeUntracked, + paths, + files, + stats: finalizeStats(files), + warnings: [...warnings, ...patchWarnings], + caps: WORKSPACE_DIFF_CAPS, + truncated: warnings.some((item) => item.code === "file_count_truncated") + || files.some((file) => file.truncated), + }; + }, + }; +} diff --git a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts index 2a1945c4123..9004c557bec 100644 --- a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts +++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts @@ -1,11 +1,40 @@ -import { describe, expect, it, vi } from "vitest"; +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; import manifest from "../src/manifest.js"; import plugin from "../src/worker.js"; -import { diffResponse } from "./fixtures.js"; + +const execFileAsync = promisify(execFile); +const tempRoots: string[] = []; + +async function git(cwd: string, args: string[]) { + return execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function createGitWorkspace() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-plugin-")); + tempRoots.push(root); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await git(root, ["init"]); + await git(root, ["config", "user.email", "paperclip@example.com"]); + await git(root, ["config", "user.name", "Paperclip Test"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n"); + await git(root, ["add", "src/app.ts"]); + await git(root, ["commit", "-m", "initial"]); + return root; +} describe("workspace diff plugin", () => { - it("declares workspace Changes tabs and host diff capability", () => { + afterEach(async () => { + await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))); + tempRoots.length = 0; + }); + + it("declares workspace Changes tabs and workspace read capabilities", () => { expect(manifest.capabilities).toContain("ui.detailTab.register"); expect(manifest.capabilities).toContain("execution.workspaces.read"); expect(manifest.capabilities).toContain("project.workspaces.read"); @@ -16,10 +45,25 @@ describe("workspace diff plugin", () => { })); }); - it("fetches changed workspace diffs through the host bridge client", async () => { + it("fetches changed execution workspace diffs from host metadata", async () => { + const root = await createGitWorkspace(); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 2;\n"); const harness = createTestHarness({ manifest }); - const getDiff = vi.fn().mockResolvedValue(diffResponse()); - harness.ctx.executionWorkspaces.getDiff = getDiff; + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: root, + cwd: root, + repoUrl: null, + baseRef: "HEAD", + branchName: "main", + providerType: "git_worktree", + providerMetadata: null, + }], + }); await plugin.definition.setup(harness.ctx); const result = await harness.getData("workspace-diff", { @@ -30,40 +74,36 @@ describe("workspace diff plugin", () => { paths: ["src/app.ts"], }); - expect(getDiff).toHaveBeenCalledWith("workspace-1", "company-1", { - view: "working-tree", - baseRef: null, - includeUntracked: false, - paths: ["src/app.ts"], - }); expect(result).toMatchObject({ stats: { fileCount: 1 }, files: [expect.objectContaining({ path: "src/app.ts" })], }); }); - it("returns empty and truncated host responses without fabricating filesystem data", async () => { + it("returns an empty diff when the workspace has no changes", async () => { + const root = await createGitWorkspace(); const harness = createTestHarness({ manifest }); - const empty = diffResponse({ files: [] }); - const truncated = diffResponse({ - files: [], - truncated: true, - warnings: [{ code: "file_count_truncated", message: "Too many files.", path: null }], + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: root, + cwd: root, + repoUrl: null, + baseRef: "HEAD", + branchName: "main", + providerType: "git_worktree", + providerMetadata: null, + }], }); - const getDiff = vi.fn() - .mockResolvedValueOnce(empty) - .mockResolvedValueOnce(truncated); - harness.ctx.executionWorkspaces.getDiff = getDiff; await plugin.definition.setup(harness.ctx); await expect(harness.getData("workspace-diff", { workspaceId: "workspace-1", companyId: "company-1", })).resolves.toMatchObject({ files: [], truncated: false }); - await expect(harness.getData("workspace-diff", { - workspaceId: "workspace-1", - companyId: "company-1", - })).resolves.toMatchObject({ files: [], truncated: true }); }); it("returns a clear bridge error when required context is missing", async () => { diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 4d5439bfb14..8b85b0b7e1f 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -181,9 +181,9 @@ export interface HostServices { resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise; }; - /** Provides `executionWorkspaces.getDiff`. */ + /** Provides `executionWorkspaces.get`. */ executionWorkspaces: { - getDiff(params: WorkerToHostMethods["executionWorkspaces.getDiff"][0]): Promise; + get(params: WorkerToHostMethods["executionWorkspaces.get"][0]): Promise; }; /** Provides `routines.managed.*`. */ @@ -373,7 +373,7 @@ const METHOD_CAPABILITY_MAP: Record { return services.projects.getWorkspaceForIssue(params); }), - "executionWorkspaces.getDiff": gated("executionWorkspaces.getDiff", async (params) => { - return services.executionWorkspaces.getDiff(params); + "executionWorkspaces.get": gated("executionWorkspaces.get", async (params) => { + return services.executionWorkspaces.get(params); }), "projects.managed.get": gated("projects.managed.get", async (params) => { return services.projects.getManaged(params); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 74b2bc87600..1e747ec87c7 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -245,11 +245,16 @@ export type { PluginEntityRecord, PluginEntityQuery, PluginWorkspace, + PluginExecutionWorkspaceMetadata, WorkspaceDiffFile, WorkspaceDiffFilePatch, + WorkspaceDiffCaps, + WorkspaceDiffFileStatus, + WorkspaceDiffPatchKind, WorkspaceDiffQueryOptions, WorkspaceDiffResponse, WorkspaceDiffWarning, + WorkspaceDiffWarningCode, Company, Project, Issue, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index daa5cfc0024..24bc871b369 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -39,8 +39,6 @@ import type { Agent, Goal, PluginLocalFolderDeclaration, - WorkspaceDiffQueryOptions, - WorkspaceDiffResponse, } from "@paperclipai/shared"; export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; @@ -53,6 +51,7 @@ import type { PluginIssueWakeupBatchResult, PluginIssueWakeupResult, PluginJobContext, + PluginExecutionWorkspaceMetadata, PluginWorkspace, ToolRunContext, ToolResult, @@ -779,13 +778,12 @@ export interface WorkerToHostMethods { params: { issueId: string; companyId: string }, result: PluginWorkspace | null, ]; - "executionWorkspaces.getDiff": [ + "executionWorkspaces.get": [ params: { workspaceId: string; companyId: string; - options?: Partial; }, - result: WorkspaceDiffResponse, + result: PluginExecutionWorkspaceMetadata | null, ]; "projects.managed.get": [ params: { projectKey: string; companyId: string }, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 07742e708cc..38d879fe7ab 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -33,6 +33,7 @@ import type { ToolResult, ToolRunContext, PluginWorkspace, + PluginExecutionWorkspaceMetadata, AgentSession, AgentSessionEvent, PluginLocalFolderEntry, @@ -80,6 +81,7 @@ export interface TestHarness { issueComments?: IssueComment[]; agents?: Agent[]; goals?: Goal[]; + executionWorkspaces?: PluginExecutionWorkspaceMetadata[]; }): void; setConfig(config: Record): void; /** Dispatch a host or plugin event to registered handlers. */ @@ -438,6 +440,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const agents = new Map(); const goals = new Map(); const projectWorkspaces = new Map(); + const executionWorkspaces = new Map(); const localFolderStatuses = new Map(); const localFolderFiles = new Map(); @@ -976,39 +979,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, }, executionWorkspaces: { - async getDiff(workspaceId, companyId, options) { + async get(workspaceId, companyId) { requireCapability(manifest, capabilitySet, "execution.workspaces.read"); - return { - workspaceId, - companyId, - repoRoot: "/tmp/paperclip-test", - cwd: "/tmp/paperclip-test", - view: options?.view ?? "working-tree", - baseRef: options?.baseRef ?? null, - headSha: null, - includeUntracked: options?.includeUntracked ?? true, - paths: options?.paths ?? [], - files: [], - stats: { - fileCount: 0, - stagedFileCount: 0, - unstagedFileCount: 0, - untrackedFileCount: 0, - binaryFileCount: 0, - oversizedFileCount: 0, - truncatedFileCount: 0, - additions: 0, - deletions: 0, - }, - warnings: [], - caps: { - maxFiles: 0, - maxFileBytes: 0, - maxPatchBytes: 0, - maxTotalPatchBytes: 0, - }, - truncated: false, - }; + const workspace = executionWorkspaces.get(workspaceId); + return workspace?.companyId === companyId ? workspace : null; }, }, routines: { @@ -2084,6 +2058,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { } for (const row of input.agents ?? []) agents.set(row.id, row); for (const row of input.goals ?? []) goals.set(row.id, row); + for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row); }, setConfig(config) { currentConfig = { ...config }; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 3160ad8f6f1..778b866c9dc 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -41,9 +41,13 @@ import type { Goal, WorkspaceDiffFile, WorkspaceDiffFilePatch, + WorkspaceDiffCaps, + WorkspaceDiffFileStatus, + WorkspaceDiffPatchKind, WorkspaceDiffQueryOptions, WorkspaceDiffResponse, WorkspaceDiffWarning, + WorkspaceDiffWarningCode, } from "@paperclipai/shared"; // --------------------------------------------------------------------------- @@ -127,9 +131,13 @@ export type { Goal, WorkspaceDiffFile, WorkspaceDiffFilePatch, + WorkspaceDiffCaps, + WorkspaceDiffFileStatus, + WorkspaceDiffPatchKind, WorkspaceDiffQueryOptions, WorkspaceDiffResponse, WorkspaceDiffWarning, + WorkspaceDiffWarningCode, } from "@paperclipai/shared"; // --------------------------------------------------------------------------- @@ -362,6 +370,40 @@ export interface PluginWorkspace { updatedAt: string; } +// --------------------------------------------------------------------------- +// Execution workspace metadata (read-only via ctx.executionWorkspaces) +// --------------------------------------------------------------------------- + +/** + * Plugin-safe execution workspace metadata provided by the host. This exposes + * the local/repository coordinates plugins need for workspace tooling without + * giving the SDK a host-owned diff engine. + */ +export interface PluginExecutionWorkspaceMetadata { + /** UUID primary key. */ + id: string; + /** UUID of the owning company. */ + companyId: string; + /** UUID of the parent project. */ + projectId: string; + /** UUID of the backing project workspace, when present. */ + projectWorkspaceId: string | null; + /** Absolute filesystem path to the workspace when locally realized. */ + path: string | null; + /** Current working directory for local workspace tooling. */ + cwd: string | null; + /** Repository URL, when known. */ + repoUrl: string | null; + /** Base ref configured for the workspace, when known. */ + baseRef: string | null; + /** Branch name configured for the workspace, when known. */ + branchName: string | null; + /** Host provider type for the realized workspace. */ + providerType: string | null; + /** Provider metadata already safe for plugin consumption. */ + providerMetadata: Record | null; +} + // --------------------------------------------------------------------------- // Host API surfaces exposed via PluginContext // --------------------------------------------------------------------------- @@ -829,21 +871,16 @@ export interface PluginProjectsClient { } /** - * `ctx.executionWorkspaces` — read execution workspace metadata and host-derived - * views such as source-control diffs. + * `ctx.executionWorkspaces` — read execution workspace metadata. * * Requires `execution.workspaces.read`. */ export interface PluginExecutionWorkspacesClient { /** - * Return a host-generated diff for an execution workspace. The host enforces - * company access and reads through Paperclip's workspace diff service. + * Return plugin-safe metadata for an execution workspace. The host enforces + * company access before returning any workspace coordinates. */ - getDiff( - workspaceId: string, - companyId: string, - options?: Partial, - ): Promise; + get(workspaceId: string, companyId: string): Promise; } /** @@ -1670,7 +1707,7 @@ export interface PluginContext { /** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */ projects: PluginProjectsClient; - /** Read execution workspace metadata and diffs. Requires `execution.workspaces.read`. */ + /** Read execution workspace metadata. Requires `execution.workspaces.read`. */ executionWorkspaces: PluginExecutionWorkspacesClient; /** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */ diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index ca27c8556d4..9bfae39e675 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -658,8 +658,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, executionWorkspaces: { - async getDiff(workspaceId: string, companyId: string, options = {}) { - return callHost("executionWorkspaces.getDiff", { workspaceId, companyId, options }); + async get(workspaceId: string, companyId: string) { + return callHost("executionWorkspaces.get", { workspaceId, companyId }); }, }, diff --git a/server/src/__tests__/plugin-execution-workspace-bridge.test.ts b/server/src/__tests__/plugin-execution-workspace-bridge.test.ts new file mode 100644 index 00000000000..6c672f5113a --- /dev/null +++ b/server/src/__tests__/plugin-execution-workspace-bridge.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js"; +import { PLUGIN_RPC_ERROR_CODES } from "../../../packages/plugins/sdk/src/protocol.js"; + +describe("plugin execution workspace bridge", () => { + it("routes metadata reads through the host client when the capability is declared", async () => { + const get = vi.fn().mockResolvedValue({ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: "/tmp/workspace-1", + cwd: "/tmp/workspace-1", + repoUrl: null, + baseRef: "main", + branchName: "feature/workspace-1", + providerType: "git_worktree", + providerMetadata: null, + }); + const handlers = createHostClientHandlers({ + pluginId: "workspace-plugin", + capabilities: ["execution.workspaces.read"], + services: { + executionWorkspaces: { get }, + } as any, + }); + + await expect( + handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }), + ).resolves.toMatchObject({ + id: "workspace-1", + cwd: "/tmp/workspace-1", + }); + expect(get).toHaveBeenCalledWith({ workspaceId: "workspace-1", companyId: "company-1" }); + }); + + it("rejects metadata reads when the plugin lacks execution.workspace read access", async () => { + const get = vi.fn(); + const handlers = createHostClientHandlers({ + pluginId: "workspace-plugin", + capabilities: [], + services: { + executionWorkspaces: { get }, + } as any, + }); + + await expect( + handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }), + ).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED, + }); + expect(get).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index 402419c68b8..de642f30e79 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -11,6 +11,7 @@ import { companies, costEvents, createDb, + executionWorkspaces, heartbeatRuns, issueRelations, issues, @@ -67,6 +68,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { await db.delete(agentWakeupRequests); await db.delete(issueRelations); await db.delete(issues); + await db.delete(executionWorkspaces); await db.delete(pluginManagedResources); await db.delete(projects); await db.delete(plugins); @@ -107,6 +109,61 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { return root; } + it("returns plugin-safe execution workspace metadata scoped to the company", async () => { + const { companyId } = await seedCompanyAndAgent(); + const otherCompanyId = randomUUID(); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + await db.insert(companies).values({ + id: otherCompanyId, + name: "Other", + issuePrefix: issuePrefix(otherCompanyId), + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + }); + await db.insert(executionWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Feature workspace", + status: "active", + cwd: "/tmp/paperclip-feature", + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "feature/workspace", + providerType: "git_worktree", + providerRef: "/tmp/paperclip-feature", + metadata: { + providerMetadata: { sandboxId: "sandbox-1" }, + workspaceRealizationRequest: { hiddenInternal: true }, + }, + }); + + const services = buildHostServices(db, "plugin-record-id", "paperclip.workspace", createEventBusStub()); + + await expect(services.executionWorkspaces.get({ workspaceId, companyId })).resolves.toMatchObject({ + id: workspaceId, + companyId, + projectId, + projectWorkspaceId: null, + path: "/tmp/paperclip-feature", + cwd: "/tmp/paperclip-feature", + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "feature/workspace", + providerType: "git_worktree", + providerMetadata: { sandboxId: "sandbox-1" }, + }); + await expect(services.executionWorkspaces.get({ workspaceId, companyId: otherCompanyId })).resolves.toBeNull(); + }); + it("creates plugin-origin issues with full orchestration fields and audit activity", async () => { const { companyId, agentId } = await seedCompanyAndAgent(); const blockerIssueId = randomUUID(); diff --git a/server/src/__tests__/plugin-sdk-testing.test.ts b/server/src/__tests__/plugin-sdk-testing.test.ts index b3efa7e19cb..2510416179c 100644 --- a/server/src/__tests__/plugin-sdk-testing.test.ts +++ b/server/src/__tests__/plugin-sdk-testing.test.ts @@ -3,6 +3,63 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; describe("plugin SDK test harness", () => { + it("returns scoped execution workspace metadata with the read capability", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-execution-workspace-metadata", + apiVersion: 1, + version: "0.1.0", + displayName: "Execution Workspace Metadata", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["execution.workspaces.read"], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + path: "/tmp/paperclip-test", + cwd: "/tmp/paperclip-test", + repoUrl: "https://example.com/repo.git", + baseRef: "main", + branchName: "feature/test", + providerType: "git_worktree", + providerMetadata: { sandboxId: "sandbox-1" }, + }], + }); + + await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).resolves.toMatchObject({ + id: "workspace-1", + cwd: "/tmp/paperclip-test", + branchName: "feature/test", + providerMetadata: { sandboxId: "sandbox-1" }, + }); + await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-2")).resolves.toBeNull(); + }); + + it("requires execution.workspaces.read before returning workspace metadata", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-missing-execution-workspace-read", + apiVersion: 1, + version: "0.1.0", + displayName: "Missing Workspace Read Capability", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + + await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).rejects.toThrow( + "missing required capability 'execution.workspaces.read'", + ); + }); + it("requires skills.managed capability before resetting a missing declaration", async () => { const manifest: PaperclipPluginManifestV1 = { id: "paperclip.test-missing-managed-skill-capability", diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index 0cf3aaf90c3..e400ab88ca9 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -55,7 +55,7 @@ const OPERATION_CAPABILITIES: Record = { "routines.managed.reset": ["routines.managed"], "project.workspaces.list": ["project.workspaces.read"], "project.workspaces.get": ["project.workspaces.read"], - "execution.workspaces.getDiff": ["execution.workspaces.read"], + "execution.workspaces.get": ["execution.workspaces.read"], "issues.list": ["issues.read"], "issues.get": ["issues.read"], "issues.relations.get": ["issue.relations.read"], diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index ce75af8bfc5..d0faf3b1b35 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -7,7 +7,6 @@ import { heartbeatRuns, issues as issuesTable, pluginLogs, - projectWorkspaces as projectWorkspacesTable, } from "@paperclipai/db"; import { eq, and, like, desc, inArray, sql } from "drizzle-orm"; import type { @@ -21,6 +20,7 @@ import type { IssueComment, PluginIssueAssigneeSummary, PluginIssueOrchestrationSummary, + PluginExecutionWorkspaceMetadata, } from "@paperclipai/plugin-sdk"; import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared"; import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; @@ -71,7 +71,6 @@ import { request as httpsRequest } from "node:https"; import { isIP } from "node:net"; import { logger } from "../middleware/logger.js"; import { getTelemetryClient } from "../telemetry.js"; -import { workspaceDiffService } from "./workspace-diff.js"; // --------------------------------------------------------------------------- // SSRF protection for plugin HTTP fetch @@ -524,7 +523,6 @@ export function buildHostServices( }); const projects = projectService(db); const executionWorkspaces = executionWorkspaceService(db); - const workspaceDiff = workspaceDiffService(); const issues = issueService(db); const documents = documentService(db); const goals = goalService(db); @@ -571,11 +569,6 @@ export function buildHostServices( */ const ensurePluginAvailableForCompany = async (_companyId: string) => {}; - const ensureDeclaredCapability = (capability: import("@paperclipai/shared").PluginCapability) => { - if (!options.manifest || options.manifest.capabilities.includes(capability)) return; - throw new Error(`Missing required capability: ${capability}`); - }; - const getLocalFolderDeclaration = (folderKey: string) => requireLocalFolderDeclaration(options.manifest?.localFolders, folderKey); @@ -598,6 +591,35 @@ export function buildHostServices( companyId: string, ): record is T => Boolean(record && record.companyId === companyId); + const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + + const readProviderMetadata = (metadata: Record | null | undefined) => { + if (!isRecord(metadata)) return null; + if (isRecord(metadata.providerMetadata)) return { ...metadata.providerMetadata }; + const rebuild = metadata.rebuild; + if (!isRecord(rebuild)) return null; + const rebuildMetadata = rebuild.metadata; + if (!isRecord(rebuildMetadata) || !isRecord(rebuildMetadata.providerMetadata)) return null; + return { ...rebuildMetadata.providerMetadata }; + }; + + const toPluginExecutionWorkspaceMetadata = ( + workspace: NonNullable>>, + ): PluginExecutionWorkspaceMetadata => ({ + id: workspace.id, + companyId: workspace.companyId, + projectId: workspace.projectId, + projectWorkspaceId: workspace.projectWorkspaceId, + path: workspace.cwd ?? workspace.providerRef, + cwd: workspace.cwd, + repoUrl: workspace.repoUrl, + baseRef: workspace.baseRef, + branchName: workspace.branchName, + providerType: workspace.providerType, + providerMetadata: readProviderMetadata(workspace.metadata), + }); + const requireInCompany = ( entityName: string, record: T | null | undefined, @@ -1208,42 +1230,14 @@ export function buildHostServices( }, executionWorkspaces: { - async getDiff(params) { + async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const workspace = await executionWorkspaces.getById(params.workspaceId); if (inCompany(workspace, companyId)) { - return workspaceDiff.getDiff(workspace, { - view: params.options?.view ?? "working-tree", - baseRef: params.options?.baseRef ?? null, - includeUntracked: params.options?.includeUntracked ?? true, - paths: params.options?.paths ?? [], - }); + return toPluginExecutionWorkspaceMetadata(workspace); } - - const projectWorkspace = await db - .select() - .from(projectWorkspacesTable) - .where(and( - eq(projectWorkspacesTable.id, params.workspaceId), - eq(projectWorkspacesTable.companyId, companyId), - )) - .then((rows) => rows[0] ?? null); - if (!projectWorkspace) { - throw new Error("Workspace not found"); - } - ensureDeclaredCapability("project.workspaces.read"); - return workspaceDiff.getDiff({ - id: projectWorkspace.id, - companyId: projectWorkspace.companyId, - cwd: projectWorkspace.cwd, - baseRef: projectWorkspace.defaultRef ?? projectWorkspace.repoRef ?? null, - }, { - view: params.options?.view ?? "working-tree", - baseRef: params.options?.baseRef ?? null, - includeUntracked: params.options?.includeUntracked ?? true, - paths: params.options?.paths ?? [], - }); + return null; }, },