From 55bffc123ac4f68465efd04b69dca197ec8b4c97 Mon Sep 17 00:00:00 2001
From: Dotta
Date: Thu, 14 May 2026 16:09:10 -0500
Subject: [PATCH] Add workspace changes plugin
---
.../plugin-workspace-diff/package.json | 40 +
.../scripts/build-ui.mjs | 24 +
.../plugin-workspace-diff/src/diff-model.ts | 103 +++
.../plugin-workspace-diff/src/index.ts | 2 +
.../plugin-workspace-diff/src/manifest.ts | 36 +
.../plugin-workspace-diff/src/ui/index.tsx | 321 ++++++++
.../plugin-workspace-diff/src/worker.ts | 58 ++
.../tests/diff-model.spec.ts | 64 ++
.../plugin-workspace-diff/tests/fixtures.ts | 79 ++
.../tests/plugin.spec.ts | 76 ++
.../plugin-workspace-diff/tsconfig.json | 18 +
.../plugin-workspace-diff/vitest.config.ts | 8 +
.../plugins/sdk/src/host-client-factory.ts | 9 +
packages/plugins/sdk/src/index.ts | 6 +
packages/plugins/sdk/src/protocol.ts | 10 +
packages/plugins/sdk/src/testing.ts | 36 +
packages/plugins/sdk/src/types.ts | 31 +
packages/plugins/sdk/src/worker-rpc-host.ts | 6 +
packages/shared/src/constants.ts | 1 +
packages/shared/src/index.ts | 23 +
packages/shared/src/types/index.ts | 13 +
packages/shared/src/types/workspace-diff.ts | 103 +++
packages/shared/src/validators/index.ts | 15 +
.../src/validators/workspace-diff.test.ts | 78 ++
.../shared/src/validators/workspace-diff.ts | 134 ++++
.../execution-workspaces-routes.test.ts | 98 ++-
.../__tests__/workspace-diff-service.test.ts | 184 +++++
server/src/routes/execution-workspaces.ts | 16 +-
server/src/routes/plugins.ts | 10 +-
server/src/services/index.ts | 1 +
.../services/plugin-capability-validator.ts | 1 +
server/src/services/plugin-host-services.ts | 21 +
server/src/services/workspace-diff.ts | 691 ++++++++++++++++++
ui/package.json | 1 +
ui/src/App.tsx | 7 +-
ui/src/api/execution-workspaces.test.ts | 13 +
ui/src/api/execution-workspaces.ts | 15 +
ui/src/api/plugins.ts | 2 +-
ui/src/fixtures/diffRendererFixtures.ts | 276 +++++++
ui/src/pages/PluginManager.tsx | 10 +-
ui/src/pages/RunTranscriptUxLab.tsx | 309 ++++++--
41 files changed, 2881 insertions(+), 68 deletions(-)
create mode 100644 packages/plugins/plugin-workspace-diff/package.json
create mode 100644 packages/plugins/plugin-workspace-diff/scripts/build-ui.mjs
create mode 100644 packages/plugins/plugin-workspace-diff/src/diff-model.ts
create mode 100644 packages/plugins/plugin-workspace-diff/src/index.ts
create mode 100644 packages/plugins/plugin-workspace-diff/src/manifest.ts
create mode 100644 packages/plugins/plugin-workspace-diff/src/ui/index.tsx
create mode 100644 packages/plugins/plugin-workspace-diff/src/worker.ts
create mode 100644 packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts
create mode 100644 packages/plugins/plugin-workspace-diff/tests/fixtures.ts
create mode 100644 packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts
create mode 100644 packages/plugins/plugin-workspace-diff/tsconfig.json
create mode 100644 packages/plugins/plugin-workspace-diff/vitest.config.ts
create mode 100644 packages/shared/src/types/workspace-diff.ts
create mode 100644 packages/shared/src/validators/workspace-diff.test.ts
create mode 100644 packages/shared/src/validators/workspace-diff.ts
create mode 100644 server/src/__tests__/workspace-diff-service.test.ts
create mode 100644 server/src/services/workspace-diff.ts
create mode 100644 ui/src/fixtures/diffRendererFixtures.ts
diff --git a/packages/plugins/plugin-workspace-diff/package.json b/packages/plugins/plugin-workspace-diff/package.json
new file mode 100644
index 00000000000..8d3df85da6a
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@paperclipai/plugin-workspace-diff",
+ "version": "0.1.0",
+ "description": "First-party execution workspace Changes tab powered by Paperclip's host diff service",
+ "type": "module",
+ "private": true,
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "paperclipPlugin": {
+ "manifest": "./dist/manifest.js",
+ "worker": "./dist/worker.js",
+ "ui": "./dist/ui/"
+ },
+ "scripts": {
+ "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
+ "build": "tsc && node ./scripts/build-ui.mjs",
+ "clean": "rm -rf dist",
+ "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@paperclipai/plugin-sdk": "workspace:*",
+ "@pierre/diffs": "^1.1.22"
+ },
+ "devDependencies": {
+ "@types/node": "^24.6.0",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
+ "esbuild": "^0.27.3",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "typescript": "^5.7.3",
+ "vitest": "^3.0.5"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+}
diff --git a/packages/plugins/plugin-workspace-diff/scripts/build-ui.mjs b/packages/plugins/plugin-workspace-diff/scripts/build-ui.mjs
new file mode 100644
index 00000000000..5cd75637cfc
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/scripts/build-ui.mjs
@@ -0,0 +1,24 @@
+import esbuild from "esbuild";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const packageRoot = path.resolve(__dirname, "..");
+
+await esbuild.build({
+ entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
+ outfile: path.join(packageRoot, "dist/ui/index.js"),
+ bundle: true,
+ format: "esm",
+ platform: "browser",
+ target: ["es2022"],
+ sourcemap: true,
+ external: [
+ "react",
+ "react-dom",
+ "react/jsx-runtime",
+ "@paperclipai/plugin-sdk/ui",
+ ],
+ logLevel: "info",
+});
diff --git a/packages/plugins/plugin-workspace-diff/src/diff-model.ts b/packages/plugins/plugin-workspace-diff/src/diff-model.ts
new file mode 100644
index 00000000000..a410494a15b
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/src/diff-model.ts
@@ -0,0 +1,103 @@
+import type {
+ WorkspaceDiffFile,
+ WorkspaceDiffFilePatch,
+ WorkspaceDiffResponse,
+ WorkspaceDiffWarning,
+} from "@paperclipai/plugin-sdk";
+
+export type DiffRenderMode = "unified" | "split";
+
+export interface DiffFileViewModel {
+ path: string;
+ oldPath: string | null;
+ status: WorkspaceDiffFile["status"];
+ additions: number;
+ deletions: number;
+ binary: boolean;
+ oversized: boolean;
+ truncated: boolean;
+ warnings: WorkspaceDiffWarning[];
+ patchKinds: WorkspaceDiffFilePatch["kind"][];
+ patch: string | null;
+}
+
+export interface DiffSummaryViewModel {
+ changedLabel: string;
+ lineLabel: string;
+ warningCount: number;
+ truncated: boolean;
+}
+
+const STATUS_LABELS: Record = {
+ added: "Added",
+ modified: "Modified",
+ deleted: "Deleted",
+ renamed: "Renamed",
+ copied: "Copied",
+ type_changed: "Type changed",
+ untracked: "Untracked",
+ unknown: "Changed",
+};
+
+export function statusLabel(status: WorkspaceDiffFile["status"]) {
+ return STATUS_LABELS[status] ?? "Changed";
+}
+
+export function fileName(filePath: string) {
+ return filePath.split("/").filter(Boolean).pop() ?? filePath;
+}
+
+export function buildFilePatch(file: WorkspaceDiffFile): string | null {
+ const patches = file.patches
+ .map((patch) => patch.patch?.trimEnd() ?? "")
+ .filter(Boolean);
+ if (patches.length === 0) return null;
+ return patches.join("\n");
+}
+
+export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] {
+ return (diff?.files ?? []).map((file) => {
+ const patchWarnings = file.patches.flatMap((patch) => patch.warnings);
+ return {
+ path: file.path,
+ oldPath: file.oldPath,
+ status: file.status,
+ additions: file.additions,
+ deletions: file.deletions,
+ binary: file.binary,
+ oversized: file.oversized,
+ truncated: file.truncated,
+ warnings: [...file.warnings, ...patchWarnings],
+ patchKinds: file.patches.map((patch) => patch.kind),
+ patch: buildFilePatch(file),
+ };
+ });
+}
+
+export function diffSummary(diff: WorkspaceDiffResponse | null | undefined): DiffSummaryViewModel {
+ const stats = diff?.stats;
+ const fileCount = stats?.fileCount ?? 0;
+ const additions = stats?.additions ?? 0;
+ const deletions = stats?.deletions ?? 0;
+ const warningCount = (diff?.warnings.length ?? 0)
+ + (diff?.files ?? []).reduce((count, file) => {
+ return count + file.warnings.length + file.patches.reduce((patchCount, patch) => patchCount + patch.warnings.length, 0);
+ }, 0);
+
+ return {
+ changedLabel: `${fileCount} ${fileCount === 1 ? "file" : "files"}`,
+ lineLabel: `+${additions} / -${deletions}`,
+ warningCount,
+ truncated: Boolean(diff?.truncated),
+ };
+}
+
+export function nextExpandedFileSet(
+ current: ReadonlySet,
+ filePath: string,
+): Set {
+ const next = new Set(current);
+ if (next.has(filePath)) next.delete(filePath);
+ else next.add(filePath);
+ return next;
+}
diff --git a/packages/plugins/plugin-workspace-diff/src/index.ts b/packages/plugins/plugin-workspace-diff/src/index.ts
new file mode 100644
index 00000000000..f301da5d924
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/src/index.ts
@@ -0,0 +1,2 @@
+export { default as manifest } from "./manifest.js";
+export { default as worker } from "./worker.js";
diff --git a/packages/plugins/plugin-workspace-diff/src/manifest.ts b/packages/plugins/plugin-workspace-diff/src/manifest.ts
new file mode 100644
index 00000000000..6ddb1ddb7af
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/src/manifest.ts
@@ -0,0 +1,36 @@
+import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
+
+const PLUGIN_ID = "paperclip.workspace-diff";
+const CHANGES_TAB_SLOT_ID = "workspace-changes-tab";
+
+const manifest: PaperclipPluginManifestV1 = {
+ id: PLUGIN_ID,
+ apiVersion: 1,
+ version: "0.1.0",
+ displayName: "Workspace Changes",
+ description: "Adds a Changes tab to execution workspaces using Paperclip's host diff API and @pierre/diffs.",
+ author: "Paperclip",
+ categories: ["workspace", "ui"],
+ capabilities: [
+ "ui.detailTab.register",
+ "execution.workspaces.read",
+ ],
+ entrypoints: {
+ worker: "./dist/worker.js",
+ ui: "./dist/ui",
+ },
+ ui: {
+ slots: [
+ {
+ type: "detailTab",
+ id: CHANGES_TAB_SLOT_ID,
+ displayName: "Changes",
+ exportName: "ChangesTab",
+ entityTypes: ["execution_workspace"],
+ order: 5,
+ },
+ ],
+ },
+};
+
+export default manifest;
diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx
new file mode 100644
index 00000000000..021c775e920
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx
@@ -0,0 +1,321 @@
+import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
+import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
+import type { WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
+import { PatchDiff } from "@pierre/diffs/react";
+import { useEffect, useMemo, useState } from "react";
+import {
+ diffSummary,
+ fileName,
+ nextExpandedFileSet,
+ statusLabel,
+ toFileViewModels,
+ type DiffFileViewModel,
+ type DiffRenderMode,
+} from "../diff-model.js";
+
+type WorkspaceDiffData = WorkspaceDiffResponse;
+
+function buttonClass(active = false) {
+ return [
+ "inline-flex h-8 items-center justify-center rounded-md border px-2.5 text-xs font-medium transition-colors",
+ active
+ ? "border-foreground/20 bg-foreground text-background"
+ : "border-border bg-background text-muted-foreground hover:text-foreground",
+ ].join(" ");
+}
+
+function iconButtonClass(active = false) {
+ return [
+ "inline-flex h-7 w-7 items-center justify-center rounded-md border text-xs transition-colors",
+ active
+ ? "border-foreground/20 bg-foreground text-background"
+ : "border-border bg-background text-muted-foreground hover:text-foreground",
+ ].join(" ");
+}
+
+function warningText(file: DiffFileViewModel) {
+ if (file.binary) return "Binary file";
+ if (file.oversized) return "Too large to render";
+ if (file.truncated) return "Patch truncated";
+ if (file.warnings.length > 0) return file.warnings[0]?.message ?? "Diff warning";
+ if (!file.patch) return "No text patch";
+ return null;
+}
+
+function FileRow({
+ file,
+ active,
+ expanded,
+ onSelect,
+ onToggle,
+ onCopy,
+}: {
+ file: DiffFileViewModel;
+ active: boolean;
+ expanded: boolean;
+ onSelect: () => void;
+ onToggle: () => void;
+ onCopy: () => void;
+}) {
+ const warning = warningText(file);
+
+ return (
+
+
+
+ {expanded ? "−" : "+"}
+
+
+ {fileName(file.path)}
+ {file.path}
+
+
+ ⧉
+
+
+
+ {statusLabel(file.status)}
+ +{file.additions}
+ -{file.deletions}
+ {warning ? {warning} : null}
+
+
+ );
+}
+
+function EmptyState() {
+ return (
+
+
No workspace changes
+
+ The workspace matches its current comparison target.
+
+
+ );
+}
+
+function LoadingState() {
+ return (
+
+ Loading workspace changes…
+
+ );
+}
+
+function ErrorState({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ );
+}
+
+function FileDiffPanel({
+ file,
+ mode,
+}: {
+ file: DiffFileViewModel;
+ mode: DiffRenderMode;
+}) {
+ const warning = warningText(file);
+ if (warning || !file.patch) {
+ return (
+
+ {warning ?? "No renderable patch is available for this file."}
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export function ChangesTab({ context }: PluginDetailTabProps) {
+ const toast = usePluginToast();
+ const [mode, setMode] = useState("split");
+ const [includeUntracked, setIncludeUntracked] = useState(true);
+ const [expandedFiles, setExpandedFiles] = useState>(() => new Set());
+ const [selectedPath, setSelectedPath] = useState(null);
+
+ const params = useMemo(() => ({
+ workspaceId: context.entityId,
+ companyId: context.companyId ?? "",
+ view: "working-tree",
+ includeUntracked,
+ }), [context.companyId, context.entityId, includeUntracked]);
+
+ const { data, loading, error, refresh } = usePluginData("workspace-diff", params);
+ const files = useMemo(() => toFileViewModels(data), [data]);
+ const summary = useMemo(() => diffSummary(data), [data]);
+ const selectedFile = files.find((file) => file.path === selectedPath) ?? files[0] ?? null;
+
+ useEffect(() => {
+ if (files.length === 0) {
+ setExpandedFiles(new Set());
+ setSelectedPath(null);
+ return;
+ }
+ setExpandedFiles((current) => current.size > 0 ? current : new Set(files.map((file) => file.path)));
+ setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null);
+ }, [files]);
+
+ const copyPath = async (filePath: string) => {
+ try {
+ await navigator.clipboard.writeText(filePath);
+ toast({ title: "Path copied", body: filePath });
+ } catch {
+ toast({ title: "Copy failed", body: filePath, tone: "error" });
+ }
+ };
+
+ return (
+
+
+
+
+ {summary.changedLabel}
+ {summary.lineLabel}
+ {summary.truncated ? (
+ Truncated
+ ) : null}
+ {summary.warningCount > 0 ? (
+ {summary.warningCount} warnings
+ ) : null}
+
+
+ {data?.baseRef ? `base ${data.baseRef}` : "working tree"}{data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}
+
+
+
+
+
+ setMode("split")}>
+ Split
+
+ setMode("unified")}>
+ Unified
+
+
+
setIncludeUntracked((value) => !value)}
+ >
+ Untracked
+
+
refresh()}>
+ Refresh
+
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
+ ) : files.length === 0 ? (
+
+ ) : (
+
+
+
+ Files
+
+
+ {files.map((file) => (
+ setSelectedPath(file.path)}
+ onToggle={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
+ onCopy={() => void copyPath(file.path)}
+ />
+ ))}
+
+
+
+
+ {files.map((file) => (
+ expandedFiles.has(file.path) ? (
+
+
+
setSelectedPath(file.path)}
+ >
+ {file.path}
+ {file.oldPath ? (
+
+ from {file.oldPath}
+
+ ) : null}
+
+
+ void copyPath(file.path)}
+ >
+ ⧉
+
+ setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
+ >
+ −
+
+
+
+
+
+ ) : null
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/packages/plugins/plugin-workspace-diff/src/worker.ts b/packages/plugins/plugin-workspace-diff/src/worker.ts
new file mode 100644
index 00000000000..e05774f2e96
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/src/worker.ts
@@ -0,0 +1,58 @@
+import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
+import type { WorkspaceDiffQueryOptions } from "@paperclipai/plugin-sdk";
+
+const PLUGIN_NAME = "workspace-diff";
+
+function readString(value: unknown): string {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function readPaths(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return value.flatMap((entry) => {
+ if (typeof entry !== "string") return [];
+ const trimmed = entry.trim();
+ return trimmed ? [trimmed] : [];
+ });
+}
+
+function readDiffOptions(params: Record): Partial {
+ const view = params.view === "head" ? "head" : "working-tree";
+ const baseRef = readString(params.baseRef) || null;
+ const includeUntracked = typeof params.includeUntracked === "boolean"
+ ? params.includeUntracked
+ : true;
+ return {
+ view,
+ baseRef,
+ includeUntracked,
+ paths: readPaths(params.paths),
+ };
+}
+
+const plugin = definePlugin({
+ async setup(ctx) {
+ ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
+
+ ctx.data.register("workspace-diff", async (params: Record) => {
+ const workspaceId = readString(params.workspaceId);
+ const companyId = readString(params.companyId);
+ if (!workspaceId || !companyId) {
+ throw new Error("workspaceId and companyId are required");
+ }
+
+ return ctx.executionWorkspaces.getDiff(
+ workspaceId,
+ companyId,
+ readDiffOptions(params),
+ );
+ });
+ },
+
+ async onHealth() {
+ return { status: "ok", message: `${PLUGIN_NAME} ready` };
+ },
+});
+
+export default plugin;
+runWorker(plugin, import.meta.url);
diff --git a/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts
new file mode 100644
index 00000000000..f1d0cef78f2
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildFilePatch,
+ diffSummary,
+ nextExpandedFileSet,
+ statusLabel,
+ toFileViewModels,
+} from "../src/diff-model.js";
+import { changedFile, diffResponse } from "./fixtures.js";
+
+describe("workspace diff UI model", () => {
+ it("summarizes changed files and line counts", () => {
+ const diff = diffResponse();
+
+ expect(diffSummary(diff)).toMatchObject({
+ changedLabel: "1 file",
+ lineLabel: "+1 / -1",
+ warningCount: 0,
+ truncated: false,
+ });
+ expect(toFileViewModels(diff)[0]).toMatchObject({
+ path: "src/app.ts",
+ status: "modified",
+ patchKinds: ["unstaged"],
+ });
+ });
+
+ it("represents empty workspace diffs", () => {
+ const diff = diffResponse({ files: [] });
+
+ expect(toFileViewModels(diff)).toEqual([]);
+ expect(diffSummary(diff).changedLabel).toBe("0 files");
+ });
+
+ it("surfaces truncation and file warnings", () => {
+ const file = changedFile({
+ truncated: true,
+ warnings: [{ code: "patch_truncated", message: "Patch was truncated.", path: "src/app.ts" }],
+ patches: [],
+ });
+ const diff = diffResponse({ files: [file], truncated: true });
+
+ expect(buildFilePatch(file)).toBeNull();
+ expect(diffSummary(diff)).toMatchObject({
+ warningCount: 1,
+ truncated: true,
+ });
+ });
+
+ it("toggles expanded file state without mutating the current set", () => {
+ const current = new Set(["a.ts"]);
+ const collapsed = nextExpandedFileSet(current, "a.ts");
+ const expanded = nextExpandedFileSet(current, "b.ts");
+
+ expect(current.has("a.ts")).toBe(true);
+ expect(collapsed.has("a.ts")).toBe(false);
+ expect(expanded.has("b.ts")).toBe(true);
+ });
+
+ it("labels file statuses for the sidebar", () => {
+ expect(statusLabel("untracked")).toBe("Untracked");
+ expect(statusLabel("type_changed")).toBe("Type changed");
+ });
+});
diff --git a/packages/plugins/plugin-workspace-diff/tests/fixtures.ts b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts
new file mode 100644
index 00000000000..13d0e07103a
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts
@@ -0,0 +1,79 @@
+import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
+
+export function changedFile(overrides: Partial = {}): WorkspaceDiffFile {
+ return {
+ path: "src/app.ts",
+ oldPath: null,
+ status: "modified",
+ staged: false,
+ unstaged: true,
+ untracked: false,
+ binary: false,
+ oversized: false,
+ truncated: false,
+ additions: 1,
+ deletions: 1,
+ sizeBytes: 120,
+ patches: [
+ {
+ kind: "unstaged",
+ patch: [
+ "diff --git a/src/app.ts b/src/app.ts",
+ "index 1111111..2222222 100644",
+ "--- a/src/app.ts",
+ "+++ b/src/app.ts",
+ "@@ -1 +1 @@",
+ "-export const value = 1;",
+ "+export const value = 2;",
+ "",
+ ].join("\n"),
+ additions: 1,
+ deletions: 1,
+ binary: false,
+ oversized: false,
+ truncated: false,
+ warnings: [],
+ },
+ ],
+ warnings: [],
+ ...overrides,
+ };
+}
+
+export function diffResponse(overrides: Partial = {}): WorkspaceDiffResponse {
+ const files = overrides.files ?? [changedFile()];
+ const additions = files.reduce((sum, file) => sum + file.additions, 0);
+ const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
+ return {
+ workspaceId: "11111111-1111-4111-8111-111111111111",
+ companyId: "22222222-2222-4222-8222-222222222222",
+ repoRoot: "/tmp/paperclip",
+ cwd: "/tmp/paperclip",
+ view: "working-tree",
+ baseRef: null,
+ headSha: null,
+ includeUntracked: true,
+ paths: [],
+ files,
+ stats: {
+ 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,
+ deletions,
+ },
+ warnings: [],
+ caps: {
+ maxFiles: 200,
+ maxFileBytes: 524288,
+ maxPatchBytes: 131072,
+ maxTotalPatchBytes: 1048576,
+ },
+ truncated: false,
+ ...overrides,
+ };
+}
diff --git a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts
new file mode 100644
index 00000000000..fb6185f33e4
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it, vi } 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";
+
+describe("workspace diff plugin", () => {
+ it("declares the execution workspace Changes tab and host diff capability", () => {
+ expect(manifest.capabilities).toContain("ui.detailTab.register");
+ expect(manifest.capabilities).toContain("execution.workspaces.read");
+ expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({
+ type: "detailTab",
+ displayName: "Changes",
+ entityTypes: ["execution_workspace"],
+ }));
+ });
+
+ it("fetches changed workspace diffs through the host bridge client", async () => {
+ const harness = createTestHarness({ manifest });
+ const getDiff = vi.fn().mockResolvedValue(diffResponse());
+ harness.ctx.executionWorkspaces.getDiff = getDiff;
+ await plugin.definition.setup(harness.ctx);
+
+ const result = await harness.getData("workspace-diff", {
+ workspaceId: "workspace-1",
+ companyId: "company-1",
+ view: "working-tree",
+ includeUntracked: false,
+ 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 () => {
+ 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 }],
+ });
+ 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 () => {
+ const harness = createTestHarness({ manifest });
+ await plugin.definition.setup(harness.ctx);
+
+ await expect(harness.getData("workspace-diff", {
+ workspaceId: "workspace-1",
+ })).rejects.toThrow("workspaceId and companyId are required");
+ });
+});
diff --git a/packages/plugins/plugin-workspace-diff/tsconfig.json b/packages/plugins/plugin-workspace-diff/tsconfig.json
new file mode 100644
index 00000000000..bcc513d19ce
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022", "DOM"],
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "."
+ },
+ "include": ["src", "tests"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/packages/plugins/plugin-workspace-diff/vitest.config.ts b/packages/plugins/plugin-workspace-diff/vitest.config.ts
new file mode 100644
index 00000000000..649a293ef95
--- /dev/null
+++ b/packages/plugins/plugin-workspace-diff/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.spec.ts"],
+ environment: "node",
+ },
+});
diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts
index 603ecd4fc89..4d5439bfb14 100644
--- a/packages/plugins/sdk/src/host-client-factory.ts
+++ b/packages/plugins/sdk/src/host-client-factory.ts
@@ -181,6 +181,11 @@ export interface HostServices {
resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise;
};
+ /** Provides `executionWorkspaces.getDiff`. */
+ executionWorkspaces: {
+ getDiff(params: WorkerToHostMethods["executionWorkspaces.getDiff"][0]): Promise;
+ };
+
/** Provides `routines.managed.*`. */
routines: {
managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise;
@@ -368,6 +373,7 @@ const METHOD_CAPABILITY_MAP: Record {
return services.projects.getWorkspaceForIssue(params);
}),
+ "executionWorkspaces.getDiff": gated("executionWorkspaces.getDiff", async (params) => {
+ return services.executionWorkspaces.getDiff(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 2753d72c93e..74b2bc87600 100644
--- a/packages/plugins/sdk/src/index.ts
+++ b/packages/plugins/sdk/src/index.ts
@@ -197,6 +197,7 @@ export type {
PluginStateClient,
PluginEntitiesClient,
PluginProjectsClient,
+ PluginExecutionWorkspacesClient,
PluginSkillsClient,
PluginCompaniesClient,
PluginIssuesClient,
@@ -244,6 +245,11 @@ export type {
PluginEntityRecord,
PluginEntityQuery,
PluginWorkspace,
+ WorkspaceDiffFile,
+ WorkspaceDiffFilePatch,
+ WorkspaceDiffQueryOptions,
+ WorkspaceDiffResponse,
+ WorkspaceDiffWarning,
Company,
Project,
Issue,
diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts
index 1c849cac9be..daa5cfc0024 100644
--- a/packages/plugins/sdk/src/protocol.ts
+++ b/packages/plugins/sdk/src/protocol.ts
@@ -39,6 +39,8 @@ import type {
Agent,
Goal,
PluginLocalFolderDeclaration,
+ WorkspaceDiffQueryOptions,
+ WorkspaceDiffResponse,
} from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@@ -777,6 +779,14 @@ export interface WorkerToHostMethods {
params: { issueId: string; companyId: string },
result: PluginWorkspace | null,
];
+ "executionWorkspaces.getDiff": [
+ params: {
+ workspaceId: string;
+ companyId: string;
+ options?: Partial;
+ },
+ result: WorkspaceDiffResponse,
+ ];
"projects.managed.get": [
params: { projectKey: string; companyId: string },
result: PluginManagedProjectResolution,
diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts
index 20f18e3f3b0..07742e708cc 100644
--- a/packages/plugins/sdk/src/testing.ts
+++ b/packages/plugins/sdk/src/testing.ts
@@ -975,6 +975,42 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
},
},
},
+ executionWorkspaces: {
+ async getDiff(workspaceId, companyId, options) {
+ 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,
+ };
+ },
+ },
routines: {
managed: {
async get(routineKey, companyId) {
diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts
index bb9b86d330f..3160ad8f6f1 100644
--- a/packages/plugins/sdk/src/types.ts
+++ b/packages/plugins/sdk/src/types.ts
@@ -39,6 +39,11 @@ import type {
RoutineRun,
Agent,
Goal,
+ WorkspaceDiffFile,
+ WorkspaceDiffFilePatch,
+ WorkspaceDiffQueryOptions,
+ WorkspaceDiffResponse,
+ WorkspaceDiffWarning,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -120,6 +125,11 @@ export type {
IssueSurfaceVisibility,
Agent,
Goal,
+ WorkspaceDiffFile,
+ WorkspaceDiffFilePatch,
+ WorkspaceDiffQueryOptions,
+ WorkspaceDiffResponse,
+ WorkspaceDiffWarning,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -818,6 +828,24 @@ export interface PluginProjectsClient {
};
}
+/**
+ * `ctx.executionWorkspaces` — read execution workspace metadata and host-derived
+ * views such as source-control diffs.
+ *
+ * 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.
+ */
+ getDiff(
+ workspaceId: string,
+ companyId: string,
+ options?: Partial,
+ ): Promise;
+}
+
/**
* `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines.
*
@@ -1642,6 +1670,9 @@ 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`. */
+ executionWorkspaces: PluginExecutionWorkspacesClient;
+
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
routines: PluginRoutinesClient;
diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts
index 643f9b0df43..ca27c8556d4 100644
--- a/packages/plugins/sdk/src/worker-rpc-host.ts
+++ b/packages/plugins/sdk/src/worker-rpc-host.ts
@@ -657,6 +657,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
+ executionWorkspaces: {
+ async getDiff(workspaceId: string, companyId: string, options = {}) {
+ return callHost("executionWorkspaces.getDiff", { workspaceId, companyId, options });
+ },
+ },
+
routines: {
managed: {
async get(routineKey: string, companyId: string) {
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index b7c7917ed94..5309a5cbd58 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -726,6 +726,7 @@ export const PLUGIN_CAPABILITIES = [
"companies.read",
"projects.read",
"project.workspaces.read",
+ "execution.workspaces.read",
"issues.read",
"issue.relations.read",
"issue.subtree.read",
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 8dd130c268e..29f26afb2b2 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -356,6 +356,17 @@ export type {
WorkspaceOperation,
WorkspaceOperationPhase,
WorkspaceOperationStatus,
+ WorkspaceDiffCaps,
+ WorkspaceDiffFile,
+ WorkspaceDiffFilePatch,
+ WorkspaceDiffFileStatus,
+ WorkspaceDiffPatchKind,
+ WorkspaceDiffQueryOptions,
+ WorkspaceDiffResponse,
+ WorkspaceDiffStats,
+ WorkspaceDiffView,
+ WorkspaceDiffWarning,
+ WorkspaceDiffWarningCode,
WorkspaceRuntimeDesiredState,
WorkspaceRealizationRecord,
WorkspaceRealizationRequest,
@@ -825,6 +836,17 @@ export {
executionWorkspaceCloseLinkedIssueSchema,
executionWorkspaceCloseReadinessSchema,
executionWorkspaceCloseReadinessStateSchema,
+ workspaceDiffCapsSchema,
+ workspaceDiffFilePatchSchema,
+ workspaceDiffFileSchema,
+ workspaceDiffFileStatusSchema,
+ workspaceDiffPatchKindSchema,
+ workspaceDiffQuerySchema,
+ workspaceDiffResponseSchema,
+ workspaceDiffStatsSchema,
+ workspaceDiffViewSchema,
+ workspaceDiffWarningCodeSchema,
+ workspaceDiffWarningSchema,
issueDocumentFormatSchema,
issueDocumentKeySchema,
upsertIssueDocumentSchema,
@@ -851,6 +873,7 @@ export {
type CreateIssueWorkProduct,
type UpdateIssueWorkProduct,
type UpdateExecutionWorkspace,
+ type WorkspaceDiffQuery,
type IssueDocumentFormat,
type UpsertIssueDocument,
type RestoreIssueDocumentRevision,
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 9cb46a2c3ca..fc8a578d18b 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -130,6 +130,19 @@ export type {
ProjectExecutionWorkspaceDefaultMode,
IssueExecutionWorkspaceSettings,
} from "./workspace-runtime.js";
+export type {
+ WorkspaceDiffCaps,
+ WorkspaceDiffFile,
+ WorkspaceDiffFilePatch,
+ WorkspaceDiffFileStatus,
+ WorkspaceDiffPatchKind,
+ WorkspaceDiffQueryOptions,
+ WorkspaceDiffResponse,
+ WorkspaceDiffStats,
+ WorkspaceDiffView,
+ WorkspaceDiffWarning,
+ WorkspaceDiffWarningCode,
+} from "./workspace-diff.js";
export type {
WorkspaceOperation,
WorkspaceOperationPhase,
diff --git a/packages/shared/src/types/workspace-diff.ts b/packages/shared/src/types/workspace-diff.ts
new file mode 100644
index 00000000000..cf7b0ed34fe
--- /dev/null
+++ b/packages/shared/src/types/workspace-diff.ts
@@ -0,0 +1,103 @@
+export type WorkspaceDiffView = "working-tree" | "head";
+
+export type WorkspaceDiffFileStatus =
+ | "added"
+ | "modified"
+ | "deleted"
+ | "renamed"
+ | "copied"
+ | "type_changed"
+ | "untracked"
+ | "unknown";
+
+export type WorkspaceDiffPatchKind = "staged" | "unstaged" | "head" | "untracked";
+
+export type WorkspaceDiffWarningCode =
+ | "base_ref_missing"
+ | "base_ref_invalid"
+ | "binary_file"
+ | "file_count_truncated"
+ | "file_oversized"
+ | "git_command_failed"
+ | "missing_cwd"
+ | "non_git_workspace"
+ | "patch_truncated"
+ | "path_filter_invalid"
+ | "workspace_path_invalid";
+
+export interface WorkspaceDiffQueryOptions {
+ view: WorkspaceDiffView;
+ baseRef: string | null;
+ includeUntracked: boolean;
+ paths: string[];
+}
+
+export interface WorkspaceDiffWarning {
+ code: WorkspaceDiffWarningCode;
+ message: string;
+ path: string | null;
+}
+
+export interface WorkspaceDiffCaps {
+ maxFiles: number;
+ maxFileBytes: number;
+ maxPatchBytes: number;
+ maxTotalPatchBytes: number;
+}
+
+export interface WorkspaceDiffFilePatch {
+ kind: WorkspaceDiffPatchKind;
+ patch: string | null;
+ additions: number;
+ deletions: number;
+ binary: boolean;
+ oversized: boolean;
+ truncated: boolean;
+ warnings: WorkspaceDiffWarning[];
+}
+
+export interface WorkspaceDiffFile {
+ path: string;
+ oldPath: string | null;
+ status: WorkspaceDiffFileStatus;
+ staged: boolean;
+ unstaged: boolean;
+ untracked: boolean;
+ binary: boolean;
+ oversized: boolean;
+ truncated: boolean;
+ additions: number;
+ deletions: number;
+ sizeBytes: number | null;
+ patches: WorkspaceDiffFilePatch[];
+ warnings: WorkspaceDiffWarning[];
+}
+
+export interface WorkspaceDiffStats {
+ fileCount: number;
+ stagedFileCount: number;
+ unstagedFileCount: number;
+ untrackedFileCount: number;
+ binaryFileCount: number;
+ oversizedFileCount: number;
+ truncatedFileCount: number;
+ additions: number;
+ deletions: number;
+}
+
+export interface WorkspaceDiffResponse {
+ workspaceId: string;
+ companyId: string;
+ repoRoot: string;
+ cwd: string;
+ view: WorkspaceDiffView;
+ baseRef: string | null;
+ headSha: string | null;
+ includeUntracked: boolean;
+ paths: string[];
+ files: WorkspaceDiffFile[];
+ stats: WorkspaceDiffStats;
+ warnings: WorkspaceDiffWarning[];
+ caps: WorkspaceDiffCaps;
+ truncated: boolean;
+}
diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts
index 3380c5534a3..fb99f06ca28 100644
--- a/packages/shared/src/validators/index.ts
+++ b/packages/shared/src/validators/index.ts
@@ -265,6 +265,21 @@ export {
type UpdateExecutionWorkspace,
} from "./execution-workspace.js";
+export {
+ workspaceDiffCapsSchema,
+ workspaceDiffFilePatchSchema,
+ workspaceDiffFileSchema,
+ workspaceDiffFileStatusSchema,
+ workspaceDiffPatchKindSchema,
+ workspaceDiffQuerySchema,
+ workspaceDiffResponseSchema,
+ workspaceDiffStatsSchema,
+ workspaceDiffViewSchema,
+ workspaceDiffWarningCodeSchema,
+ workspaceDiffWarningSchema,
+ type WorkspaceDiffQuery,
+} from "./workspace-diff.js";
+
export {
createGoalSchema,
updateGoalSchema,
diff --git a/packages/shared/src/validators/workspace-diff.test.ts b/packages/shared/src/validators/workspace-diff.test.ts
new file mode 100644
index 00000000000..91cf3a83b27
--- /dev/null
+++ b/packages/shared/src/validators/workspace-diff.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "./workspace-diff.js";
+
+describe("workspace diff validators", () => {
+ it("normalizes query options from route query strings", () => {
+ expect(workspaceDiffQuerySchema.parse({
+ view: "head",
+ baseRef: " main ",
+ includeUntracked: "false",
+ path: ["server/src/index.ts", "ui/src/App.tsx,packages/shared/src/index.ts"],
+ })).toEqual({
+ view: "head",
+ baseRef: "main",
+ includeUntracked: false,
+ paths: ["server/src/index.ts", "ui/src/App.tsx", "packages/shared/src/index.ts"],
+ });
+ });
+
+ it("validates the response contract", () => {
+ const response = workspaceDiffResponseSchema.parse({
+ workspaceId: "11111111-1111-4111-8111-111111111111",
+ companyId: "22222222-2222-4222-8222-222222222222",
+ repoRoot: "/tmp/repo",
+ cwd: "/tmp/repo",
+ view: "working-tree",
+ baseRef: null,
+ headSha: "abc123",
+ includeUntracked: true,
+ paths: [],
+ files: [{
+ path: "README.md",
+ oldPath: null,
+ status: "modified",
+ staged: true,
+ unstaged: false,
+ untracked: false,
+ binary: false,
+ oversized: false,
+ truncated: false,
+ additions: 1,
+ deletions: 0,
+ sizeBytes: 12,
+ patches: [{
+ kind: "staged",
+ patch: "diff --git a/README.md b/README.md\n",
+ additions: 1,
+ deletions: 0,
+ binary: false,
+ oversized: false,
+ truncated: false,
+ warnings: [],
+ }],
+ warnings: [],
+ }],
+ stats: {
+ fileCount: 1,
+ stagedFileCount: 1,
+ unstagedFileCount: 0,
+ untrackedFileCount: 0,
+ binaryFileCount: 0,
+ oversizedFileCount: 0,
+ truncatedFileCount: 0,
+ additions: 1,
+ deletions: 0,
+ },
+ warnings: [],
+ caps: {
+ maxFiles: 200,
+ maxFileBytes: 524288,
+ maxPatchBytes: 262144,
+ maxTotalPatchBytes: 1048576,
+ },
+ truncated: false,
+ });
+
+ expect(response.files[0]?.patches[0]?.kind).toBe("staged");
+ });
+});
diff --git a/packages/shared/src/validators/workspace-diff.ts b/packages/shared/src/validators/workspace-diff.ts
new file mode 100644
index 00000000000..46f7a92a324
--- /dev/null
+++ b/packages/shared/src/validators/workspace-diff.ts
@@ -0,0 +1,134 @@
+import { z } from "zod";
+
+export const workspaceDiffViewSchema = z.enum(["working-tree", "head"]);
+
+export const workspaceDiffFileStatusSchema = z.enum([
+ "added",
+ "modified",
+ "deleted",
+ "renamed",
+ "copied",
+ "type_changed",
+ "untracked",
+ "unknown",
+]);
+
+export const workspaceDiffPatchKindSchema = z.enum(["staged", "unstaged", "head", "untracked"]);
+
+export const workspaceDiffWarningCodeSchema = z.enum([
+ "base_ref_missing",
+ "base_ref_invalid",
+ "binary_file",
+ "file_count_truncated",
+ "file_oversized",
+ "git_command_failed",
+ "missing_cwd",
+ "non_git_workspace",
+ "patch_truncated",
+ "path_filter_invalid",
+ "workspace_path_invalid",
+]);
+
+const queryBooleanSchema = z
+ .union([z.boolean(), z.enum(["true", "false"])])
+ .transform((value) => value === true || value === "true");
+
+function normalizePathQuery(value: unknown): string[] {
+ if (value == null) return [];
+ const values = Array.isArray(value) ? value : [value];
+ return values.flatMap((entry) => {
+ if (typeof entry !== "string") return [];
+ return entry
+ .split(",")
+ .map((path) => path.trim())
+ .filter(Boolean);
+ });
+}
+
+export const workspaceDiffQuerySchema = z
+ .object({
+ view: workspaceDiffViewSchema.optional().default("working-tree"),
+ baseRef: z.string().trim().min(1).max(240).optional().nullable(),
+ includeUntracked: queryBooleanSchema.optional().default(true),
+ path: z.union([z.string(), z.array(z.string())]).optional(),
+ paths: z.union([z.string(), z.array(z.string())]).optional(),
+ })
+ .passthrough()
+ .transform((value) => ({
+ view: value.view,
+ baseRef: value.baseRef?.trim() || null,
+ includeUntracked: value.includeUntracked,
+ paths: normalizePathQuery(value.paths ?? value.path),
+ }));
+
+export const workspaceDiffWarningSchema = z.object({
+ code: workspaceDiffWarningCodeSchema,
+ message: z.string(),
+ path: z.string().nullable(),
+}).strict();
+
+export const workspaceDiffCapsSchema = z.object({
+ maxFiles: z.number().int().positive(),
+ maxFileBytes: z.number().int().positive(),
+ maxPatchBytes: z.number().int().positive(),
+ maxTotalPatchBytes: z.number().int().positive(),
+}).strict();
+
+export const workspaceDiffFilePatchSchema = z.object({
+ kind: workspaceDiffPatchKindSchema,
+ patch: z.string().nullable(),
+ additions: z.number().int().nonnegative(),
+ deletions: z.number().int().nonnegative(),
+ binary: z.boolean(),
+ oversized: z.boolean(),
+ truncated: z.boolean(),
+ warnings: z.array(workspaceDiffWarningSchema),
+}).strict();
+
+export const workspaceDiffFileSchema = z.object({
+ path: z.string(),
+ oldPath: z.string().nullable(),
+ status: workspaceDiffFileStatusSchema,
+ staged: z.boolean(),
+ unstaged: z.boolean(),
+ untracked: z.boolean(),
+ binary: z.boolean(),
+ oversized: z.boolean(),
+ truncated: z.boolean(),
+ additions: z.number().int().nonnegative(),
+ deletions: z.number().int().nonnegative(),
+ sizeBytes: z.number().int().nonnegative().nullable(),
+ patches: z.array(workspaceDiffFilePatchSchema),
+ warnings: z.array(workspaceDiffWarningSchema),
+}).strict();
+
+export const workspaceDiffStatsSchema = z.object({
+ fileCount: z.number().int().nonnegative(),
+ stagedFileCount: z.number().int().nonnegative(),
+ unstagedFileCount: z.number().int().nonnegative(),
+ untrackedFileCount: z.number().int().nonnegative(),
+ binaryFileCount: z.number().int().nonnegative(),
+ oversizedFileCount: z.number().int().nonnegative(),
+ truncatedFileCount: z.number().int().nonnegative(),
+ additions: z.number().int().nonnegative(),
+ deletions: z.number().int().nonnegative(),
+}).strict();
+
+export const workspaceDiffResponseSchema = z.object({
+ workspaceId: z.string().uuid(),
+ companyId: z.string().uuid(),
+ repoRoot: z.string(),
+ cwd: z.string(),
+ view: workspaceDiffViewSchema,
+ baseRef: z.string().nullable(),
+ headSha: z.string().nullable(),
+ includeUntracked: z.boolean(),
+ paths: z.array(z.string()),
+ files: z.array(workspaceDiffFileSchema),
+ stats: workspaceDiffStatsSchema,
+ warnings: z.array(workspaceDiffWarningSchema),
+ caps: workspaceDiffCapsSchema,
+ truncated: z.boolean(),
+}).strict();
+
+export type WorkspaceDiffQuery = z.infer;
diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts
index bbac9d610aa..a20cbfe6044 100644
--- a/server/src/__tests__/execution-workspaces-routes.test.ts
+++ b/server/src/__tests__/execution-workspaces-routes.test.ts
@@ -12,6 +12,10 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
update: vi.fn(),
}));
+const mockWorkspaceDiffService = vi.hoisted(() => ({
+ getDiff: vi.fn(),
+}));
+
const mockWorkspaceOperationService = vi.hoisted(() => ({
listForExecutionWorkspace: vi.fn(),
createRecorder: vi.fn(),
@@ -22,18 +26,19 @@ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
logActivity: mockLogActivity,
+ workspaceDiffService: () => mockWorkspaceDiffService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
-function createApp() {
+function createApp(companyIds = ["company-1"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
- companyIds: ["company-1"],
- source: "local_implicit",
+ companyIds,
+ source: "session",
isInstanceAdmin: false,
};
next();
@@ -55,6 +60,38 @@ describe.sequential("execution workspace routes", () => {
projectWorkspaceId: null,
},
]);
+ mockExecutionWorkspaceService.getById.mockResolvedValue(null);
+ mockWorkspaceDiffService.getDiff.mockResolvedValue({
+ workspaceId: "11111111-1111-4111-8111-111111111111",
+ companyId: "company-1",
+ repoRoot: "/tmp/repo",
+ cwd: "/tmp/repo",
+ view: "working-tree",
+ baseRef: null,
+ headSha: "abc123",
+ includeUntracked: true,
+ paths: [],
+ files: [],
+ stats: {
+ fileCount: 0,
+ stagedFileCount: 0,
+ unstagedFileCount: 0,
+ untrackedFileCount: 0,
+ binaryFileCount: 0,
+ oversizedFileCount: 0,
+ truncatedFileCount: 0,
+ additions: 0,
+ deletions: 0,
+ },
+ warnings: [],
+ caps: {
+ maxFiles: 200,
+ maxFileBytes: 524288,
+ maxPatchBytes: 262144,
+ maxTotalPatchBytes: 1048576,
+ },
+ truncated: false,
+ });
});
it("uses summary mode for lightweight workspace lookups", async () => {
@@ -79,4 +116,59 @@ describe.sequential("execution workspace routes", () => {
});
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
});
+
+ it("parses diff query options after enforcing workspace company access", async () => {
+ const workspace = {
+ id: "11111111-1111-4111-8111-111111111111",
+ companyId: "company-1",
+ projectId: "project-1",
+ projectWorkspaceId: null,
+ sourceIssueId: null,
+ mode: "isolated_workspace",
+ strategyType: "git_worktree",
+ name: "Feature workspace",
+ status: "active",
+ cwd: "/tmp/repo",
+ repoUrl: null,
+ baseRef: null,
+ branchName: "feature",
+ providerType: "git_worktree",
+ providerRef: "/tmp/repo",
+ derivedFromExecutionWorkspaceId: null,
+ lastUsedAt: new Date(),
+ openedAt: new Date(),
+ closedAt: null,
+ cleanupEligibleAt: null,
+ cleanupReason: null,
+ config: null,
+ metadata: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockExecutionWorkspaceService.getById.mockResolvedValue(workspace);
+
+ const res = await request(createApp())
+ .get("/api/execution-workspaces/11111111-1111-4111-8111-111111111111/diff?view=head&baseRef=main&includeUntracked=false&path=server/src/index.ts&path=ui/src/App.tsx");
+
+ expect(res.status).toBe(200);
+ expect(mockWorkspaceDiffService.getDiff).toHaveBeenCalledWith(workspace, {
+ view: "head",
+ baseRef: "main",
+ includeUntracked: false,
+ paths: ["server/src/index.ts", "ui/src/App.tsx"],
+ });
+ });
+
+ it("denies diff access when the workspace belongs to another company", async () => {
+ mockExecutionWorkspaceService.getById.mockResolvedValue({
+ id: "11111111-1111-4111-8111-111111111111",
+ companyId: "company-2",
+ });
+
+ const res = await request(createApp(["company-1"]))
+ .get("/api/execution-workspaces/11111111-1111-4111-8111-111111111111/diff");
+
+ expect(res.status).toBe(403);
+ expect(mockWorkspaceDiffService.getDiff).not.toHaveBeenCalled();
+ });
});
diff --git a/server/src/__tests__/workspace-diff-service.test.ts b/server/src/__tests__/workspace-diff-service.test.ts
new file mode 100644
index 00000000000..41e0aa80a34
--- /dev/null
+++ b/server/src/__tests__/workspace-diff-service.test.ts
@@ -0,0 +1,184 @@
+import { execFile } from "node:child_process";
+import { randomUUID } from "node:crypto";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it } from "vitest";
+import type { ExecutionWorkspace, WorkspaceDiffQueryOptions } from "@paperclipai/shared";
+import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../services/workspace-diff.js";
+
+const execFileAsync = promisify(execFile);
+const tempDirs = new Set();
+
+async function runGit(cwd: string, args: string[]) {
+ await execFileAsync("git", ["-C", cwd, ...args], { cwd });
+}
+
+async function createTempRepo() {
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-"));
+ tempDirs.add(repoRoot);
+ await runGit(repoRoot, ["init"]);
+ await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
+ await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
+ await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\n", "utf8");
+ await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\n", "utf8");
+ await fs.writeFile(path.join(repoRoot, "delete-me.txt"), "charlie\n", "utf8");
+ await fs.writeFile(path.join(repoRoot, "rename-me.txt"), "delta\n", "utf8");
+ await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3]));
+ await runGit(repoRoot, ["add", "."]);
+ await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
+ await runGit(repoRoot, ["branch", "-M", "main"]);
+ return repoRoot;
+}
+
+function createWorkspace(cwd: string | null, overrides: Partial = {}): ExecutionWorkspace {
+ const now = new Date();
+ return {
+ id: randomUUID(),
+ companyId: randomUUID(),
+ projectId: randomUUID(),
+ projectWorkspaceId: null,
+ sourceIssueId: null,
+ mode: "isolated_workspace",
+ strategyType: "git_worktree",
+ name: "Diff workspace",
+ status: "active",
+ cwd,
+ repoUrl: null,
+ baseRef: null,
+ branchName: "feature",
+ providerType: "git_worktree",
+ providerRef: cwd,
+ derivedFromExecutionWorkspaceId: null,
+ lastUsedAt: now,
+ openedAt: now,
+ closedAt: null,
+ cleanupEligibleAt: null,
+ cleanupReason: null,
+ config: null,
+ metadata: null,
+ createdAt: now,
+ updatedAt: now,
+ ...overrides,
+ };
+}
+
+function workingTreeQuery(overrides: Partial = {}): WorkspaceDiffQueryOptions {
+ return {
+ view: "working-tree",
+ baseRef: null,
+ includeUntracked: true,
+ paths: [],
+ ...overrides,
+ };
+}
+
+afterEach(async () => {
+ for (const dir of tempDirs) {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ tempDirs.clear();
+});
+
+describe("workspaceDiffService", () => {
+ it("returns staged, unstaged, renamed, deleted, untracked, binary, and oversized working-tree changes", async () => {
+ const repoRoot = await createTempRepo();
+ await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\nstaged\n", "utf8");
+ await runGit(repoRoot, ["add", "tracked-staged.txt"]);
+ await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\nunstaged\n", "utf8");
+ await runGit(repoRoot, ["mv", "rename-me.txt", "renamed.txt"]);
+ await fs.rm(path.join(repoRoot, "delete-me.txt"));
+ await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3, 4, 5]));
+ await fs.writeFile(path.join(repoRoot, "untracked.txt"), "brand new\n", "utf8");
+ await fs.writeFile(path.join(repoRoot, "oversized.txt"), "x".repeat(WORKSPACE_DIFF_CAPS.maxFileBytes + 1), "utf8");
+
+ const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
+ const byPath = new Map(diff.files.map((file) => [file.path, file]));
+
+ expect(diff.view).toBe("working-tree");
+ expect(byPath.get("tracked-staged.txt")).toMatchObject({
+ staged: true,
+ unstaged: false,
+ status: "modified",
+ additions: 1,
+ });
+ expect(byPath.get("tracked-staged.txt")?.patches.map((patch) => patch.kind)).toEqual(["staged"]);
+ expect(byPath.get("tracked-unstaged.txt")).toMatchObject({
+ staged: false,
+ unstaged: true,
+ status: "modified",
+ additions: 1,
+ });
+ expect(byPath.get("renamed.txt")).toMatchObject({
+ oldPath: "rename-me.txt",
+ staged: true,
+ status: "renamed",
+ });
+ expect(byPath.get("delete-me.txt")).toMatchObject({
+ unstaged: true,
+ status: "deleted",
+ deletions: 1,
+ });
+ expect(byPath.get("untracked.txt")).toMatchObject({
+ untracked: true,
+ status: "untracked",
+ additions: 1,
+ });
+ expect(byPath.get("untracked.txt")?.patches[0]?.patch).toContain("+brand new");
+ expect(byPath.get("binary.bin")).toMatchObject({
+ binary: true,
+ unstaged: true,
+ });
+ expect(byPath.get("oversized.txt")).toMatchObject({
+ oversized: true,
+ untracked: true,
+ });
+ expect(diff.warnings.map((item) => item.code)).toEqual(expect.arrayContaining([
+ "binary_file",
+ "file_oversized",
+ ]));
+ }, 20_000);
+
+ it("returns head diffs against the requested base ref", async () => {
+ const repoRoot = await createTempRepo();
+ await runGit(repoRoot, ["checkout", "-b", "feature"]);
+ await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\ncommitted\n", "utf8");
+ await runGit(repoRoot, ["add", "tracked-staged.txt"]);
+ await runGit(repoRoot, ["commit", "-m", "Feature change"]);
+
+ const diff = await workspaceDiffService().getDiff(
+ createWorkspace(repoRoot, { baseRef: "main" }),
+ workingTreeQuery({ view: "head", includeUntracked: false }),
+ );
+
+ expect(diff.baseRef).toBe("main");
+ expect(diff.files).toHaveLength(1);
+ expect(diff.files[0]).toMatchObject({
+ path: "tracked-staged.txt",
+ staged: false,
+ unstaged: false,
+ untracked: false,
+ additions: 1,
+ deletions: 0,
+ });
+ expect(diff.files[0]?.patches.map((patch) => patch.kind)).toEqual(["head"]);
+ }, 20_000);
+
+ it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as 422 errors", async () => {
+ const svc = workspaceDiffService();
+ await expect(svc.getDiff(createWorkspace(null), workingTreeQuery()))
+ .rejects.toMatchObject({ status: 422, details: { code: "missing_cwd" } });
+
+ const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-non-git-"));
+ tempDirs.add(nonGitDir);
+ await expect(svc.getDiff(createWorkspace(nonGitDir), workingTreeQuery()))
+ .rejects.toMatchObject({ status: 422, details: { code: "non_git_workspace" } });
+
+ const repoRoot = await createTempRepo();
+ await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ paths: ["../secret"] })))
+ .rejects.toMatchObject({ status: 422, details: { code: "path_filter_invalid" } });
+ await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ view: "head", baseRef: "missing-ref" })))
+ .rejects.toMatchObject({ status: 422, details: { code: "base_ref_invalid" } });
+ }, 20_000);
+});
diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts
index ba2f9180f21..c9f548c4dd0 100644
--- a/server/src/routes/execution-workspaces.ts
+++ b/server/src/routes/execution-workspaces.ts
@@ -6,11 +6,12 @@ import {
findWorkspaceCommandDefinition,
matchWorkspaceRuntimeServiceToCommand,
updateExecutionWorkspaceSchema,
+ workspaceDiffQuerySchema,
workspaceRuntimeControlTargetSchema,
} from "@paperclipai/shared";
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
-import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
+import { executionWorkspaceService, logActivity, workspaceDiffService, workspaceOperationService } from "../services/index.js";
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js";
@@ -36,6 +37,7 @@ const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
export function executionWorkspaceRoutes(db: Db) {
const router = Router();
const svc = executionWorkspaceService(db);
+ const diffSvc = workspaceDiffService();
const workspaceOperationsSvc = workspaceOperationService(db);
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
@@ -65,6 +67,18 @@ export function executionWorkspaceRoutes(db: Db) {
res.json(workspace);
});
+ router.get("/execution-workspaces/:id/diff", async (req, res) => {
+ const id = req.params.id as string;
+ const workspace = await svc.getById(id);
+ if (!workspace) {
+ res.status(404).json({ error: "Execution workspace not found" });
+ return;
+ }
+ assertCompanyAccess(req, workspace.companyId);
+ const query = workspaceDiffQuerySchema.parse(req.query);
+ res.json(await diffSvc.getDiff(workspace, query));
+ });
+
router.get("/execution-workspaces/:id/close-readiness", async (req, res) => {
const id = req.params.id as string;
const workspace = await svc.getById(id);
diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts
index 443685187c4..d133ed5d670 100644
--- a/server/src/routes/plugins.ts
+++ b/server/src/routes/plugins.ts
@@ -120,7 +120,7 @@ interface AvailablePluginExample {
displayName: string;
description: string;
localPath: string;
- tag: "example";
+ tag: "example" | "first-party";
}
/** Response body for GET /api/plugins/:pluginId/health */
@@ -152,6 +152,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "../../..");
const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [
+ {
+ packageName: "@paperclipai/plugin-workspace-diff",
+ pluginKey: "paperclip.workspace-diff",
+ displayName: "Workspace Changes",
+ description: "First-party execution workspace Changes tab backed by Paperclip's host diff API.",
+ localPath: "packages/plugins/plugin-workspace-diff",
+ tag: "first-party",
+ },
{
packageName: "@paperclipai/plugin-hello-world-example",
pluginKey: "paperclip.hello-world-example",
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index 2e7c6bcdaf5..6f857264d1f 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -49,6 +49,7 @@ export { instanceSettingsService } from "./instance-settings.js";
export { companyPortabilityService } from "./company-portability.js";
export { environmentService } from "./environments.js";
export { executionWorkspaceService } from "./execution-workspaces.js";
+export { workspaceDiffService } from "./workspace-diff.js";
export { workspaceOperationService } from "./workspace-operations.js";
export { workProductService } from "./work-products.js";
export { logActivity, type LogActivityInput } from "./activity-log.js";
diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts
index ef54d61d2a6..0cf3aaf90c3 100644
--- a/server/src/services/plugin-capability-validator.ts
+++ b/server/src/services/plugin-capability-validator.ts
@@ -55,6 +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"],
"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 3d4c8947f0a..5df8531907e 100644
--- a/server/src/services/plugin-host-services.ts
+++ b/server/src/services/plugin-host-services.ts
@@ -26,6 +26,7 @@ import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
import { companyService } from "./companies.js";
import { agentService } from "./agents.js";
import { projectService } from "./projects.js";
+import { executionWorkspaceService } from "./execution-workspaces.js";
import { issueService } from "./issues.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import { goalService } from "./goals.js";
@@ -69,6 +70,7 @@ 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
@@ -520,6 +522,8 @@ export function buildHostServices(
pluginWorkerManager: options.pluginWorkerManager,
});
const projects = projectService(db);
+ const executionWorkspaces = executionWorkspaceService(db);
+ const workspaceDiff = workspaceDiffService();
const issues = issueService(db);
const documents = documentService(db);
const goals = goalService(db);
@@ -1197,6 +1201,23 @@ export function buildHostServices(
},
},
+ executionWorkspaces: {
+ async getDiff(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const workspace = await executionWorkspaces.getById(params.workspaceId);
+ if (!inCompany(workspace, companyId)) {
+ throw new Error("Execution workspace not found");
+ }
+ return workspaceDiff.getDiff(workspace, {
+ view: params.options?.view ?? "working-tree",
+ baseRef: params.options?.baseRef ?? null,
+ includeUntracked: params.options?.includeUntracked ?? true,
+ paths: params.options?.paths ?? [],
+ });
+ },
+ },
+
routines: {
async managedGet(params) {
const companyId = ensureCompanyId(params.companyId);
diff --git a/server/src/services/workspace-diff.ts b/server/src/services/workspace-diff.ts
new file mode 100644
index 00000000000..9a4f7c96177
--- /dev/null
+++ b/server/src/services/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 {
+ ExecutionWorkspace,
+ WorkspaceDiffCaps,
+ WorkspaceDiffFile,
+ WorkspaceDiffFilePatch,
+ WorkspaceDiffFileStatus,
+ WorkspaceDiffPatchKind,
+ WorkspaceDiffQueryOptions,
+ WorkspaceDiffResponse,
+ WorkspaceDiffWarning,
+ WorkspaceDiffWarningCode,
+} from "@paperclipai/shared";
+import { unprocessable } from "../errors.js";
+
+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;
+}
+
+function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning {
+ return { code, message, path: filePath };
+}
+
+function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record = {}) {
+ return unprocessable(message, { code, ...details });
+}
+
+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: ExecutionWorkspace) {
+ 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 hunkLines = lines.map((line) => `+${line}`).join("\n");
+ const lineCount = countAddedLines(content);
+ return [
+ `diff --git a/${filePath} b/${filePath}`,
+ "new file mode 100644",
+ "--- /dev/null",
+ `+++ b/${filePath}`,
+ `@@ -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: ExecutionWorkspace) {
+ 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: ExecutionWorkspace;
+ 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: ExecutionWorkspace, 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,
+ repoRoot,
+ cwd,
+ 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/ui/package.json b/ui/package.json
index 7286053f523..9b17bdad581 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -45,6 +45,7 @@
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
+ "@pierre/diffs": "^1.1.22",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 0da91b9a183..52fa6403bd2 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -17,6 +17,7 @@ import { Issues } from "./pages/Issues";
import { Search } from "./pages/Search";
import { IssueDetail } from "./pages/IssueDetail";
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
+import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { Routines } from "./pages/Routines";
import { RoutineDetail } from "./pages/RoutineDetail";
import { UserProfile } from "./pages/UserProfile";
@@ -107,7 +108,10 @@ function boardRoutes() {
} />
} />
{import.meta.env.DEV ? (
- } />
+ <>
+ } />
+ } />
+ >
) : null}
} />
} />
@@ -276,6 +280,7 @@ export function App() {
} />
} />
} />
+ } />
}>
} />
diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts
index b18bd6501f4..25dec2a363e 100644
--- a/ui/src/api/execution-workspaces.test.ts
+++ b/ui/src/api/execution-workspaces.test.ts
@@ -26,4 +26,17 @@ describe("executionWorkspacesApi.listSummaries", () => {
"/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true",
);
});
+
+ it("requests workspace diffs with query options", async () => {
+ await executionWorkspacesApi.getDiff("workspace-1", {
+ view: "head",
+ baseRef: "main",
+ includeUntracked: false,
+ paths: ["server/src/index.ts", "packages/shared/src/index.ts"],
+ });
+
+ expect(mockApi.get).toHaveBeenCalledWith(
+ "/execution-workspaces/workspace-1/diff?view=head&baseRef=main&includeUntracked=false&path=server%2Fsrc%2Findex.ts&path=packages%2Fshared%2Fsrc%2Findex.ts",
+ );
+ });
});
diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts
index cd5b7341752..07669be03c6 100644
--- a/ui/src/api/execution-workspaces.ts
+++ b/ui/src/api/execution-workspaces.ts
@@ -2,6 +2,8 @@ import type {
ExecutionWorkspace,
ExecutionWorkspaceSummary,
ExecutionWorkspaceCloseReadiness,
+ WorkspaceDiffQueryOptions,
+ WorkspaceDiffResponse,
WorkspaceOperation,
WorkspaceRuntimeControlTarget,
} from "@paperclipai/shared";
@@ -51,6 +53,19 @@ export const executionWorkspacesApi = {
return api.get(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
},
get: (id: string) => api.get(`/execution-workspaces/${id}`),
+ getDiff: (id: string, options: Partial = {}) => {
+ const params = new URLSearchParams();
+ if (options.view) params.set("view", options.view);
+ if (options.baseRef) params.set("baseRef", options.baseRef);
+ if (typeof options.includeUntracked === "boolean") {
+ params.set("includeUntracked", String(options.includeUntracked));
+ }
+ for (const filePath of options.paths ?? []) {
+ params.append("path", filePath);
+ }
+ const qs = params.toString();
+ return api.get(`/execution-workspaces/${id}/diff${qs ? `?${qs}` : ""}`);
+ },
getCloseReadiness: (id: string) =>
api.get(`/execution-workspaces/${id}/close-readiness`),
listWorkspaceOperations: (id: string) =>
diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts
index 2e4c981af37..9df0b937563 100644
--- a/ui/src/api/plugins.ts
+++ b/ui/src/api/plugins.ts
@@ -138,7 +138,7 @@ export interface AvailablePluginExample {
displayName: string;
description: string;
localPath: string;
- tag: "example";
+ tag: "example" | "first-party";
}
export interface PluginLocalFolderProblem {
diff --git a/ui/src/fixtures/diffRendererFixtures.ts b/ui/src/fixtures/diffRendererFixtures.ts
new file mode 100644
index 00000000000..7d8e2a15fbc
--- /dev/null
+++ b/ui/src/fixtures/diffRendererFixtures.ts
@@ -0,0 +1,276 @@
+import { parsePatchFiles } from "@pierre/diffs";
+import type { FileDiffMetadata } from "@pierre/diffs";
+
+const smallDiffPatch = `diff --git a/src/pagination.ts b/src/pagination.ts
+index 6c2a1f4..e91d2ac 100644
+--- a/src/pagination.ts
++++ b/src/pagination.ts
+@@ -1,8 +1,12 @@
+ export type PageRequest = {
+ limit: number;
++ cursor?: string;
+ offset: number;
+ };
+
+ export function buildOffset(offset: number, limit: number) {
+- return { offset, limit };
++ return {
++ offset,
++ limit,
++ hasMore: limit > 0,
++ };
+ }
+`;
+
+const largeDiffPatch = `diff --git a/tmp/pierre-large-old.ts b/tmp/pierre-large-new.ts
+index 6ea4ef1..9449233 100644
+--- a/tmp/pierre-large-old.ts
++++ b/tmp/pierre-large-new.ts
+@@ -1,160 +1,163 @@
+ export const value_001 = '1';
+ export const value_002 = '2';
+-export const value_003 = '3';
++export const value_003 = '6';
+ export const value_004 = '4';
+ export const value_005 = '5';
+-export const value_006 = '6';
++export const value_006 = '12';
+ export const value_007 = '7';
+ export const value_008 = '8';
+-export const value_009 = '9';
++export const value_009 = '18';
+ export const value_010 = '10';
+ export const value_011 = '11';
+-export const value_012 = '12';
++export const value_012 = '24';
+ export const value_013 = '13';
+ export const value_014 = '14';
+-export const value_015 = '15';
++export const value_015 = '30';
+ export const value_016 = '16';
+ export const value_017 = '17';
+-export const value_018 = '18';
++export const value_018 = '36';
+ export const value_019 = '19';
+ export const value_020 = '20';
++export function isEven(n: number) {
++ return n % 2 === 0;
++}
+
+-export const value_021 = '21';
++export const value_021 = '42';
+ export const value_022 = '22';
+ export const value_023 = '23';
+-export const value_024 = '24';
++export const value_024 = '48';
+ export const value_025 = '25';
+ export const value_026 = '26';
+-export const value_027 = '27';
++export const value_027 = '54';
+ export const value_028 = '28';
+ export const value_029 = '29';
+-export const value_030 = '30';
++export const value_030 = '60';
+ export const value_031 = '31';
+ export const value_032 = '32';
+-export const value_033 = '33';
++export const value_033 = '66';
+ export const value_034 = '34';
+ export const value_035 = '35';
+-export const value_036 = '36';
++export const value_036 = '72';
+ export const value_037 = '37';
+ export const value_038 = '38';
+-export const value_039 = '39';
++export const value_039 = '78';
+ export const value_040 = '40';
+ export const value_041 = '41';
+-export const value_042 = '42';
++export const value_042 = '84';
+ export const value_043 = '43';
+ export const value_044 = '44';
+-export const value_045 = '45';
++export const value_045 = '90';
+ export const value_046 = '46';
+ export const value_047 = '47';
+-export const value_048 = '48';
++export const value_048 = '96';
+ export const value_049 = '49';
+ export const value_050 = '50';
+-export const value_051 = '51';
++export const value_051 = '102';
+ export const value_052 = '52';
+ export const value_053 = '53';
+-export const value_054 = '54';
++export const value_054 = '108';
+ export const value_055 = '55';
+ export const value_056 = '56';
+-export const value_057 = '57';
++export const value_057 = '114';
+ export const value_058 = '58';
+ export const value_059 = '59';
+-export const value_060 = '60';
++export const value_060 = '120';
+ export const value_061 = '61';
+ export const value_062 = '62';
+-export const value_063 = '63';
++export const value_063 = '126';
+ export const value_064 = '64';
+ export const value_065 = '65';
+-export const value_066 = '66';
++export const value_066 = '132';
+ export const value_067 = '67';
+ export const value_068 = '68';
+-export const value_069 = '69';
++export const value_069 = '138';
+ export const value_070 = '70';
+ export const value_071 = '71';
+-export const value_072 = '72';
++export const value_072 = '144';
+ export const value_073 = '73';
+ export const value_074 = '74';
+-export const value_075 = '75';
++export const value_075 = '150';
+ export const value_076 = '76';
+ export const value_077 = '77';
+-export const value_078 = '78';
++export const value_078 = '156';
+ export const value_079 = '79';
+ export const value_080 = '80';
+-export const value_081 = '81';
++export const value_081 = '162';
+ export const value_082 = '82';
+ export const value_083 = '83';
+-export const value_084 = '84';
++export const value_084 = '168';
+ export const value_085 = '85';
+ export const value_086 = '86';
+-export const value_087 = '87';
++export const value_087 = '174';
+ export const value_088 = '88';
+ export const value_089 = '89';
+-export const value_090 = '90';
++export const value_090 = '180';
+ export const value_091 = '91';
+ export const value_092 = '92';
+-export const value_093 = '93';
++export const value_093 = '186';
+ export const value_094 = '94';
+ export const value_095 = '95';
+-export const value_096 = '96';
++export const value_096 = '192';
+ export const value_097 = '97';
+ export const value_098 = '98';
+-export const value_099 = '99';
++export const value_099 = '198';
+ export const value_100 = '100';
+ export const value_101 = '101';
+-export const value_102 = '102';
++export const value_102 = '204';
+ export const value_103 = '103';
+ export const value_104 = '104';
+-export const value_105 = '105';
++export const value_105 = '210';
+ export const value_106 = '106';
+ export const value_107 = '107';
+-export const value_108 = '108';
++export const value_108 = '216';
+ export const value_109 = '109';
+ export const value_110 = '110';
+-export const value_111 = '111';
++export const value_111 = '222';
+ export const value_112 = '112';
+ export const value_113 = '113';
+-export const value_114 = '114';
++export const value_114 = '228';
+ export const value_115 = '115';
+ export const value_116 = '116';
+-export const value_117 = '117';
++export const value_117 = '234';
+ export const value_118 = '118';
+ export const value_119 = '119';
+-export const value_120 = '120';
++export const value_120 = '240';
+ export const value_121 = '121';
+ export const value_122 = '122';
+-export const value_123 = '123';
++export const value_123 = '246';
+ export const value_124 = '124';
+ export const value_125 = '125';
+-export const value_126 = '126';
++export const value_126 = '252';
+ export const value_127 = '127';
+ export const value_128 = '128';
+-export const value_129 = '129';
++export const value_129 = '258';
+ export const value_130 = '130';
+ export const value_131 = '131';
+-export const value_132 = '132';
++export const value_132 = '264';
+ export const value_133 = '133';
+ export const value_134 = '134';
+-export const value_135 = '135';
++export const value_135 = '270';
+ export const value_136 = '136';
+ export const value_137 = '137';
+-export const value_138 = '138';
++export const value_138 = '276';
+ export const value_139 = '139';
+ export const value_140 = '140';
+-export const value_141 = '141';
++export const value_141 = '282';
+ export const value_142 = '142';
+ export const value_143 = '143';
+-export const value_144 = '144';
++export const value_144 = '288';
+ export const value_145 = '145';
+ export const value_146 = '146';
+-export const value_147 = '147';
++export const value_147 = '294';
+ export const value_148 = '148';
+ export const value_149 = '149';
+-export const value_150 = '150';
++export const value_150 = '300';
+ export const value_151 = '151';
+ export const value_152 = '152';
+-export const value_153 = '153';
++export const value_153 = '306';
+ export const value_154 = '154';
+ export const value_155 = '155';
+-export const value_156 = '156';
++export const value_156 = '312';
+ export const value_157 = '157';
+ export const value_158 = '158';
+-export const value_159 = '159';
++export const value_159 = '318';
+ export const value_160 = '160';
+`;
+
+function parsePatchFilesOrEmpty(patch: string): FileDiffMetadata[] {
+ return parsePatchFiles(patch).flatMap((entry) => entry.files);
+}
+
+export type DiffRendererFixtureId = "small" | "large";
+
+export interface DiffRendererFixture {
+ id: DiffRendererFixtureId;
+ label: string;
+ patch: string;
+ fileDiffs: FileDiffMetadata[];
+}
+
+export const DIFF_RENDERER_FIXTURES: DiffRendererFixture[] = [
+ {
+ id: "small",
+ label: "Small mixed edit fixture",
+ patch: smallDiffPatch,
+ fileDiffs: parsePatchFilesOrEmpty(smallDiffPatch),
+ },
+ {
+ id: "large",
+ label: "Large 160-line patch fixture",
+ patch: largeDiffPatch,
+ fileDiffs: parsePatchFilesOrEmpty(largeDiffPatch),
+ },
+];
+
diff --git a/ui/src/pages/PluginManager.tsx b/ui/src/pages/PluginManager.tsx
index dd951185301..ce73e25ca84 100644
--- a/ui/src/pages/PluginManager.tsx
+++ b/ui/src/pages/PluginManager.tsx
@@ -220,16 +220,16 @@ export function PluginManager() {
Available Plugins
- Examples
+ Bundled
{examplesQuery.isLoading ? (
- Loading bundled examples...
+ Loading bundled plugins...
) : examplesQuery.error ? (
- Failed to load bundled examples.
+ Failed to load bundled plugins.
) : examples.length === 0 ? (
- No bundled example plugins were found in this checkout.
+ No bundled plugins were found in this checkout.
) : (
@@ -246,7 +246,7 @@ export function PluginManager() {
{example.displayName}
-
Example
+
{example.tag === "first-party" ? "First-party" : "Example"}
{installedPlugin ? (
= [
+};
+
+const surfaceOptions: SurfaceOption[] = [
{
id: "detail",
label: "Run Detail",
@@ -38,6 +46,13 @@ const surfaceOptions: Array<{
description: "The active-agents dashboard card, tuned for compact scanning while keeping the same transcript language.",
icon: PanelsTopLeft,
},
+ {
+ id: "diffs",
+ label: "Diff Renderer",
+ eyebrow: "@pierre/diffs",
+ description: "A spike harness for `PatchDiff` and `FileDiff` with realistic fixtures and rendering options.",
+ icon: GitCompare,
+ },
];
function previewEntries(surface: SurfaceId) {
@@ -160,7 +175,9 @@ function DashboardPreview({
@@ -190,12 +207,111 @@ function DashboardPreview({
);
}
+function DiffRendererPreview({
+ fixtureId,
+ componentMode,
+ diffStyle,
+ overflow,
+ showLineNumbers,
+ disableBackground,
+ themeType,
+}: {
+ fixtureId: DiffRendererFixtureId;
+ componentMode: DiffComponentMode;
+ diffStyle: DiffStyle;
+ overflow: DiffOverflow;
+ showLineNumbers: boolean;
+ disableBackground: boolean;
+ themeType: DiffThemeType;
+}) {
+ const fixture = DIFF_RENDERER_FIXTURES.find((item) => item.id === fixtureId) ?? DIFF_RENDERER_FIXTURES[0];
+ const firstFileDiff = fixture.fileDiffs[0];
+
+ const options = {
+ diffStyle,
+ overflow,
+ disableLineNumbers: !showLineNumbers,
+ disableBackground,
+ themeType,
+ };
+
+ return (
+
+
+
+
+ Diff Render
+
+
+ {fixture.label}
+
+
+
+ {componentMode === "patch" ? "PatchDiff" : "FileDiff"} • {diffStyle.toUpperCase()} • {fixture.fileDiffs.length} file(s)
+
+
+
+
+ {componentMode === "file" && !firstFileDiff ? (
+
+ Parsed fixture did not produce a file diff payload for `FileDiff`.
+
+ ) : componentMode === "patch" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function ToggleButtonGroup({
+ selected,
+ options,
+ onSelect,
+}: {
+ selected: T;
+ options: Array<{
+ id: T;
+ label: string;
+ }>;
+ onSelect: (next: T) => void;
+}) {
+ return (
+
+ {options.map((option) => (
+ onSelect(option.id)}
+ >
+ {option.label}
+
+ ))}
+
+ );
+}
+
export function RunTranscriptUxLab() {
const [selectedSurface, setSelectedSurface] = useState("detail");
const [detailMode, setDetailMode] = useState("nice");
const [streaming, setStreaming] = useState(true);
const [density, setDensity] = useState("comfortable");
+ const [diffComponentMode, setDiffComponentMode] = useState("file");
+ const [diffFixture, setDiffFixture] = useState(DIFF_RENDERER_FIXTURES[0]?.id ?? "small");
+ const [diffStyle, setDiffStyle] = useState("unified");
+ const [diffOverflow, setDiffOverflow] = useState("scroll");
+ const [diffShowLineNumbers, setDiffShowLineNumbers] = useState(true);
+ const [diffDisableBackground, setDiffDisableBackground] = useState(false);
+ const [diffThemeType, setDiffThemeType] = useState("system");
+
const selected = surfaceOptions.find((option) => option.id === selectedSurface) ?? surfaceOptions[0];
return (
@@ -210,7 +326,7 @@ export function RunTranscriptUxLab() {
Run Transcript Fixtures
- Built from a real Paperclip development run, then sanitized so no secrets, local paths, or environment details survive into the fixture.
+ Built from real Paperclip development data, with a focused spike surface for diff rendering controls.
@@ -261,58 +377,129 @@ export function RunTranscriptUxLab() {
-
-
- Source run {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)}
-
-
- {runTranscriptFixtureMeta.issueIdentifier}
-
+
+ {selectedSurface === "diffs" ? (
+ <>
+
+ Controls
+
+
+ selected={diffComponentMode}
+ options={[
+ { id: "patch", label: "PatchDiff" },
+ { id: "file", label: "FileDiff" },
+ ]}
+ onSelect={setDiffComponentMode}
+ />
+
+ selected={diffFixture}
+ options={DIFF_RENDERER_FIXTURES.map((fixture) => ({
+ id: fixture.id,
+ label: fixture.label,
+ }))}
+ onSelect={setDiffFixture}
+ />
+
+ selected={diffStyle}
+ options={[
+ { id: "unified", label: "Unified" },
+ { id: "split", label: "Split" },
+ ]}
+ onSelect={setDiffStyle}
+ />
+
+ selected={diffOverflow}
+ options={[{ id: "scroll", label: "Scroll" }, { id: "wrap", label: "Wrap" }]}
+ onSelect={setDiffOverflow}
+ />
+ setDiffShowLineNumbers((value) => !value)}
+ >
+ {diffShowLineNumbers ? "Hide" : "Show"} line numbers
+
+ setDiffDisableBackground((value) => !value)}
+ >
+ {diffDisableBackground ? "Enable" : "Disable"} background
+
+
+ selected={diffThemeType}
+ options={[
+ { id: "system", label: "Auto" },
+ { id: "light", label: "Light" },
+ { id: "dark", label: "Dark" },
+ ]}
+ onSelect={setDiffThemeType}
+ />
+ >
+ ) : (
+ <>
+
+ Controls
+
+
+ {(["nice", "raw"] as const).map((mode) => (
+ setDetailMode(mode)}
+ >
+ {mode}
+
+ ))}
+
+
+ {(["comfortable", "compact"] as const).map((nextDensity) => (
+ setDensity(nextDensity)}
+ >
+ {nextDensity}
+
+ ))}
+
+ setStreaming((value) => !value)}
+ >
+ {streaming ? "Show settled state" : "Show streaming state"}
+
+ >
+ )}
-
-
-
-
- Controls
-
-
- {(["nice", "raw"] as const).map((mode) => (
- setDetailMode(mode)}
- >
- {mode}
-
- ))}
-
-
- {(["comfortable", "compact"] as const).map((nextDensity) => (
- setDensity(nextDensity)}
- >
- {nextDensity}
-
- ))}
-
-
setStreaming((value) => !value)}
+ className="mb-0.5 flex rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]"
>
- {streaming ? "Show settled state" : "Show streaming state"}
-
+ Source run {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)}
+
+
+ {runTranscriptFixtureMeta.issueIdentifier}
+
{selectedSurface === "detail" ? (
@@ -323,8 +510,18 @@ export function RunTranscriptUxLab() {
- ) : (
+ ) : selectedSurface === "dashboard" ? (
+ ) : (
+
)}