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 ( +
+
+ + + +
+
+ {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)}` : ""} +
+
+ +
+
+ + +
+ + +
+
+ + {loading ? ( + + ) : error ? ( + + ) : files.length === 0 ? ( + + ) : ( +
+ + +
+ {files.map((file) => ( + expandedFiles.has(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) => ( + + ))} +
    + ); +} + 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} + /> + + + + selected={diffThemeType} + options={[ + { id: "system", label: "Auto" }, + { id: "light", label: "Light" }, + { id: "dark", label: "Dark" }, + ]} + onSelect={setDiffThemeType} + /> + + ) : ( + <> + + Controls + +
    + {(["nice", "raw"] as const).map((mode) => ( + + ))} +
    +
    + {(["comfortable", "compact"] as const).map((nextDensity) => ( + + ))} +
    + + + )}
    -
    - -
    - - Controls - -
    - {(["nice", "raw"] as const).map((mode) => ( - - ))} -
    -
    - {(["comfortable", "compact"] as const).map((nextDensity) => ( - - ))} -
    - + Source run {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)} + + + {runTranscriptFixtureMeta.issueIdentifier} +
    {selectedSurface === "detail" ? ( @@ -323,8 +510,18 @@ export function RunTranscriptUxLab() {
    - ) : ( + ) : selectedSurface === "dashboard" ? ( + ) : ( + )}