Replace plugin diff bridge with workspace metadata

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-16 08:41:18 -05:00
1 parent 56eb9a5638
commit 09ea87fb12
16 files changed
+1058 -128

No files matched your search

@@ -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": {
@@ -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<WorkspaceDiffData>("workspace-diff", params);
const files = useMemo(() => toFileViewModels(data), [data]);
@@ -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<string, unknown>): Partial<WorkspaceDiffQueryOptions> {
function readDiffOptions(params: Record<string, unknown>): 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<string, unknown>): Partial<WorkspaceDiff
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
const workspaceDiff = workspaceDiffService();
ctx.data.register("workspace-diff", async (params: Record<string, unknown>) => {
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));
});
},
@@ -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<PluginExecutionWorkspaceMetadata, "id" | "companyId" | "cwd" | "baseRef">;
function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning {
return { code, message, path: filePath };
}
function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record<string, unknown> = {}) {
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<ReturnType<typeof fs.stat>>;
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<string, MutableWorkspaceDiffFile>,
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<string, MutableWorkspaceDiffFile>,
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<WorkspaceDiffFilePatch> {
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<WorkspaceDiffFilePatch> {
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<string, MutableWorkspaceDiffFile>();
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<WorkspaceDiffResponse> {
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),
};
},
};
}
@@ -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 () => {
@@ -181,9 +181,9 @@ export interface HostServices {
resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise<WorkerToHostMethods["projects.managed.reset"][1]>;
};
/** Provides `executionWorkspaces.getDiff`. */
/** Provides `executionWorkspaces.get`. */
executionWorkspaces: {
getDiff(params: WorkerToHostMethods["executionWorkspaces.getDiff"][0]): Promise<WorkerToHostMethods["executionWorkspaces.getDiff"][1]>;
get(params: WorkerToHostMethods["executionWorkspaces.get"][0]): Promise<WorkerToHostMethods["executionWorkspaces.get"][1]>;
};
/** Provides `routines.managed.*`. */
@@ -373,7 +373,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"projects.listWorkspaces": "project.workspaces.read",
"projects.getPrimaryWorkspace": "project.workspaces.read",
"projects.getWorkspaceForIssue": "project.workspaces.read",
"executionWorkspaces.getDiff": "execution.workspaces.read",
"executionWorkspaces.get": "execution.workspaces.read",
"projects.managed.get": "projects.managed",
"projects.managed.reconcile": "projects.managed",
"projects.managed.reset": "projects.managed",
@@ -614,8 +614,8 @@ export function createHostClientHandlers(
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
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);
+5
View File
@@ -245,11 +245,16 @@ export type {
PluginEntityRecord,
PluginEntityQuery,
PluginWorkspace,
PluginExecutionWorkspaceMetadata,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffCaps,
WorkspaceDiffFileStatus,
WorkspaceDiffPatchKind,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
WorkspaceDiffWarningCode,
Company,
Project,
Issue,
+3 -5
View File
@@ -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<WorkspaceDiffQueryOptions>;
},
result: WorkspaceDiffResponse,
result: PluginExecutionWorkspaceMetadata | null,
];
"projects.managed.get": [
params: { projectKey: string; companyId: string },
+7 -32
View File
@@ -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<string, unknown>): void;
/** Dispatch a host or plugin event to registered handlers. */
@@ -438,6 +440,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>();
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
const localFolderFiles = new Map<string, string>();
@@ -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 };
+47 -10
View File
@@ -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<string, unknown> | 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<WorkspaceDiffQueryOptions>,
): Promise<WorkspaceDiffResponse>;
get(workspaceId: string, companyId: string): Promise<PluginExecutionWorkspaceMetadata | null>;
}
/**
@@ -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`. */
+2 -2
View File
@@ -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 });
},
},
@@ -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();
});
});
@@ -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();
@@ -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",
@@ -55,7 +55,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"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"],
+33 -39
View File
@@ -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<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readProviderMetadata = (metadata: Record<string, unknown> | 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<Awaited<ReturnType<typeof executionWorkspaces.getById>>>,
): 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 = <T extends { companyId: string | null | undefined }>(
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;
},
},