diff --git a/packages/shared/src/validators/workspace-diff.ts b/packages/plugins/plugin-workspace-diff/src/contracts.ts similarity index 79% rename from packages/shared/src/validators/workspace-diff.ts rename to packages/plugins/plugin-workspace-diff/src/contracts.ts index 2d7c9eacc37..9e90fe0f783 100644 --- a/packages/shared/src/validators/workspace-diff.ts +++ b/packages/plugins/plugin-workspace-diff/src/contracts.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "@paperclipai/plugin-sdk"; export const workspaceDiffViewSchema = z.enum(["working-tree", "head"]); @@ -40,7 +40,7 @@ function normalizePathQuery(value: unknown): string[] { if (typeof entry !== "string") return []; return entry .split(",") - .map((path) => path.trim()) + .map((filePath) => filePath.trim()) .filter(Boolean); }); } @@ -115,8 +115,8 @@ export const workspaceDiffStatsSchema = z.object({ }).strict(); export const workspaceDiffResponseSchema = z.object({ - workspaceId: z.string().uuid(), - companyId: z.string().uuid(), + workspaceId: z.string(), + companyId: z.string(), view: workspaceDiffViewSchema, baseRef: z.string().nullable(), headSha: z.string().nullable(), @@ -129,4 +129,14 @@ export const workspaceDiffResponseSchema = z.object({ truncated: z.boolean(), }).strict(); -export type WorkspaceDiffQuery = z.infer; +export type WorkspaceDiffView = z.infer; +export type WorkspaceDiffFileStatus = z.infer; +export type WorkspaceDiffPatchKind = z.infer; +export type WorkspaceDiffWarningCode = z.infer; +export type WorkspaceDiffQueryOptions = z.infer; +export type WorkspaceDiffWarning = z.infer; +export type WorkspaceDiffCaps = z.infer; +export type WorkspaceDiffFilePatch = z.infer; +export type WorkspaceDiffFile = z.infer; +export type WorkspaceDiffStats = z.infer; +export type WorkspaceDiffResponse = z.infer; diff --git a/packages/plugins/plugin-workspace-diff/src/diff-model.ts b/packages/plugins/plugin-workspace-diff/src/diff-model.ts index ca500e6633d..b8dfa464ea8 100644 --- a/packages/plugins/plugin-workspace-diff/src/diff-model.ts +++ b/packages/plugins/plugin-workspace-diff/src/diff-model.ts @@ -3,7 +3,7 @@ import type { WorkspaceDiffFilePatch, WorkspaceDiffResponse, WorkspaceDiffWarning, -} from "@paperclipai/plugin-sdk"; +} from "./contracts.js"; export type DiffRenderMode = "unified" | "split"; diff --git a/packages/plugins/plugin-workspace-diff/src/manifest.ts b/packages/plugins/plugin-workspace-diff/src/manifest.ts index 10a80585d74..5c5f9335a30 100644 --- a/packages/plugins/plugin-workspace-diff/src/manifest.ts +++ b/packages/plugins/plugin-workspace-diff/src/manifest.ts @@ -8,7 +8,7 @@ const manifest: PaperclipPluginManifestV1 = { 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.", + description: "Adds a Changes tab to execution and project workspaces using plugin-local Git diff computation and @pierre/diffs.", author: "Paperclip", categories: ["workspace", "ui"], capabilities: [ diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index 5b0795afcad..a14029af0af 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -1,6 +1,5 @@ import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui"; import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui"; -import type { WorkspaceDiffResponse } from "@paperclipai/plugin-sdk"; import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs"; import type { PatchDiffProps } from "@pierre/diffs/react"; import { useFileDiffInstance } from "@pierre/diffs/react"; @@ -16,6 +15,7 @@ import { type DiffPatchViewModel, type DiffRenderMode, } from "../diff-model.js"; +import type { WorkspaceDiffResponse } from "../contracts.js"; type WorkspaceDiffData = WorkspaceDiffResponse; type WorkspacePatchDiffOptions = PatchDiffProps["options"]; diff --git a/packages/plugins/plugin-workspace-diff/src/worker.ts b/packages/plugins/plugin-workspace-diff/src/worker.ts index ebcc80695aa..520117f61cf 100644 --- a/packages/plugins/plugin-workspace-diff/src/worker.ts +++ b/packages/plugins/plugin-workspace-diff/src/worker.ts @@ -1,5 +1,5 @@ import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; -import type { WorkspaceDiffQueryOptions } from "@paperclipai/plugin-sdk"; +import { workspaceDiffQuerySchema } from "./contracts.js"; import { workspaceDiffService } from "./workspace-diff.js"; const PLUGIN_NAME = "workspace-diff"; @@ -8,29 +8,6 @@ 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): WorkspaceDiffQueryOptions { - 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`); @@ -57,15 +34,15 @@ const plugin = definePlugin({ id: workspace.id, companyId, cwd: workspace.path, - baseRef: null, - }, readDiffOptions(params)); + baseRef: workspace.defaultRef ?? workspace.repoRef ?? null, + }, workspaceDiffQuerySchema.parse(params)); } const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId); if (!workspace) { throw new Error("Workspace not found"); } - return workspaceDiff.getDiff(workspace, readDiffOptions(params)); + return workspaceDiff.getDiff(workspace, workspaceDiffQuerySchema.parse(params)); }); }, diff --git a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts index 39ced604301..d4d173256c7 100644 --- a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts +++ b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts @@ -2,8 +2,8 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk"; import type { - PluginExecutionWorkspaceMetadata, WorkspaceDiffCaps, WorkspaceDiffFile, WorkspaceDiffFilePatch, @@ -13,7 +13,7 @@ import type { WorkspaceDiffResponse, WorkspaceDiffWarning, WorkspaceDiffWarningCode, -} from "@paperclipai/plugin-sdk"; +} from "./contracts.js"; const execFileAsync = promisify(execFile); @@ -51,7 +51,7 @@ function warning(code: WorkspaceDiffWarningCode, message: string, filePath: stri function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record = {}) { const error = new Error(message); - Object.assign(error, { code, details: { code, ...details } }); + Object.assign(error, { code, status: 422, details: { code, ...details } }); return error; } diff --git a/packages/plugins/plugin-workspace-diff/tests/contracts.spec.ts b/packages/plugins/plugin-workspace-diff/tests/contracts.spec.ts new file mode 100644 index 00000000000..ee6df0e1450 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/contracts.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "../src/contracts.js"; +import { diffResponse } from "./fixtures.js"; + +describe("workspace diff plugin contracts", () => { + it("normalizes query options from plugin data parameters", () => { + expect(workspaceDiffQuerySchema.parse({ + view: "head", + baseRef: " main ", + includeUntracked: "false", + path: ["src/app.ts, README.md", "packages/shared/src/index.ts"], + })).toEqual({ + view: "head", + baseRef: "main", + includeUntracked: false, + paths: ["src/app.ts", "README.md", "packages/shared/src/index.ts"], + }); + }); + + it("validates the plugin-owned response shape", () => { + expect(workspaceDiffResponseSchema.parse(diffResponse())).toMatchObject({ + workspaceId: "11111111-1111-4111-8111-111111111111", + stats: { fileCount: 1 }, + }); + }); +}); diff --git a/packages/plugins/plugin-workspace-diff/tests/fixtures.ts b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts index 3724ae1e650..7ccafa0f843 100644 --- a/packages/plugins/plugin-workspace-diff/tests/fixtures.ts +++ b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts @@ -1,4 +1,4 @@ -import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "@paperclipai/plugin-sdk"; +import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "../src/contracts.js"; export function changedFile(overrides: Partial = {}): WorkspaceDiffFile { return { diff --git a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts index 9004c557bec..4610173fac1 100644 --- a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts +++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts @@ -25,6 +25,7 @@ async function createGitWorkspace() { await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n"); await git(root, ["add", "src/app.ts"]); await git(root, ["commit", "-m", "initial"]); + await git(root, ["branch", "-M", "main"]); return root; } @@ -106,6 +107,47 @@ describe("workspace diff plugin", () => { })).resolves.toMatchObject({ files: [], truncated: false }); }); + it("fetches project workspace diffs from generic project workspace metadata", async () => { + const root = await createGitWorkspace(); + await git(root, ["checkout", "-b", "feature"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 3;\n"); + await git(root, ["add", "src/app.ts"]); + await git(root, ["commit", "-m", "project workspace change"]); + const harness = createTestHarness({ manifest }); + harness.ctx.projects.listWorkspaces = async (projectId, companyId) => { + expect(projectId).toBe("project-1"); + expect(companyId).toBe("company-1"); + return [{ + id: "workspace-1", + projectId: "project-1", + name: "Primary", + path: root, + repoUrl: null, + repoRef: "feature", + defaultRef: "main", + isPrimary: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }]; + }; + await plugin.definition.setup(harness.ctx); + + const result = await harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + projectId: "project-1", + entityType: "project_workspace", + view: "head", + includeUntracked: false, + }); + + expect(result).toMatchObject({ + baseRef: "main", + stats: { fileCount: 1 }, + files: [expect.objectContaining({ path: "src/app.ts" })], + }); + }); + it("returns a clear bridge error when required context is missing", async () => { const harness = createTestHarness({ manifest }); await plugin.definition.setup(harness.ctx); diff --git a/server/src/__tests__/workspace-diff-service.test.ts b/packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts similarity index 68% rename from server/src/__tests__/workspace-diff-service.test.ts rename to packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts index b56c344f031..3976fd4328e 100644 --- a/server/src/__tests__/workspace-diff-service.test.ts +++ b/packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts @@ -5,8 +5,9 @@ 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"; +import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk"; +import type { WorkspaceDiffQueryOptions } from "../src/contracts.js"; +import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../src/workspace-diff.js"; const execFileAsync = promisify(execFile); const tempDirs = new Set(); @@ -16,7 +17,7 @@ async function runGit(cwd: string, args: string[]) { } async function createTempRepo() { - const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-")); + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-")); tempDirs.add(repoRoot); await runGit(repoRoot, ["init"]); await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); @@ -32,34 +33,19 @@ async function createTempRepo() { return repoRoot; } -function createWorkspace(cwd: string | null, overrides: Partial = {}): ExecutionWorkspace { - const now = new Date(); +function createWorkspace(cwd: string | null, overrides: Partial = {}): PluginExecutionWorkspaceMetadata { return { id: randomUUID(), companyId: randomUUID(), projectId: randomUUID(), projectWorkspaceId: null, - sourceIssueId: null, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "Diff workspace", - status: "active", + path: cwd, 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, + providerMetadata: null, ...overrides, }; } @@ -81,7 +67,7 @@ afterEach(async () => { tempDirs.clear(); }); -describe("workspaceDiffService", () => { +describe("plugin workspace diff service", () => { 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"); @@ -98,34 +84,12 @@ describe("workspaceDiffService", () => { 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")).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("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("empty-untracked.txt")?.patches[0]?.patch).toBe([ "diff --git a/empty-untracked.txt b/empty-untracked.txt", @@ -134,18 +98,9 @@ describe("workspaceDiffService", () => { "+++ b/empty-untracked.txt", "", ].join("\n")); - 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", - ])); + 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 () => { @@ -173,12 +128,39 @@ describe("workspaceDiffService", () => { 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 () => { + it("filters changed files by relative workspace paths", async () => { + const repoRoot = await createTempRepo(); + await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\none\n", "utf8"); + await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\ntwo\n", "utf8"); + + const diff = await workspaceDiffService().getDiff( + createWorkspace(repoRoot), + workingTreeQuery({ paths: ["tracked-staged.txt"] }), + ); + + expect(diff.paths).toEqual(["tracked-staged.txt"]); + expect(diff.files.map((file) => file.path)).toEqual(["tracked-staged.txt"]); + }, 20_000); + + it("applies output caps to large workspace responses", async () => { + const repoRoot = await createTempRepo(); + for (let index = 0; index < WORKSPACE_DIFF_CAPS.maxFiles + 1; index += 1) { + await fs.writeFile(path.join(repoRoot, `untracked-${String(index).padStart(3, "0")}.txt`), "", "utf8"); + } + + const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery()); + + expect(diff.files).toHaveLength(WORKSPACE_DIFF_CAPS.maxFiles); + expect(diff.truncated).toBe(true); + expect(diff.warnings).toContainEqual(expect.objectContaining({ code: "file_count_truncated" })); + }, 20_000); + + it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as plugin 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-")); + const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-non-git-")); tempDirs.add(nonGitDir); await expect(svc.getDiff(createWorkspace(nonGitDir), workingTreeQuery())) .rejects.toMatchObject({ status: 422, details: { code: "non_git_workspace" } }); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 1e747ec87c7..92db6d696cf 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -246,15 +246,6 @@ export type { PluginEntityQuery, PluginWorkspace, PluginExecutionWorkspaceMetadata, - WorkspaceDiffFile, - WorkspaceDiffFilePatch, - WorkspaceDiffCaps, - WorkspaceDiffFileStatus, - WorkspaceDiffPatchKind, - WorkspaceDiffQueryOptions, - WorkspaceDiffResponse, - WorkspaceDiffWarning, - WorkspaceDiffWarningCode, Company, Project, Issue, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 38d879fe7ab..de34e8ae112 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -81,6 +81,7 @@ export interface TestHarness { issueComments?: IssueComment[]; agents?: Agent[]; goals?: Goal[]; + projectWorkspaces?: PluginWorkspace[]; executionWorkspaces?: PluginExecutionWorkspaceMetadata[]; }): void; setConfig(config: Record): void; @@ -2058,6 +2059,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { } for (const row of input.agents ?? []) agents.set(row.id, row); for (const row of input.goals ?? []) goals.set(row.id, row); + for (const row of input.projectWorkspaces ?? []) { + const list = projectWorkspaces.get(row.projectId) ?? []; + list.push(row); + projectWorkspaces.set(row.projectId, list); + } for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row); }, setConfig(config) { diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 778b866c9dc..bcbb7f4e71d 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -39,15 +39,6 @@ import type { RoutineRun, Agent, Goal, - WorkspaceDiffFile, - WorkspaceDiffFilePatch, - WorkspaceDiffCaps, - WorkspaceDiffFileStatus, - WorkspaceDiffPatchKind, - WorkspaceDiffQueryOptions, - WorkspaceDiffResponse, - WorkspaceDiffWarning, - WorkspaceDiffWarningCode, } from "@paperclipai/shared"; // --------------------------------------------------------------------------- @@ -129,15 +120,6 @@ export type { IssueSurfaceVisibility, Agent, Goal, - WorkspaceDiffFile, - WorkspaceDiffFilePatch, - WorkspaceDiffCaps, - WorkspaceDiffFileStatus, - WorkspaceDiffPatchKind, - WorkspaceDiffQueryOptions, - WorkspaceDiffResponse, - WorkspaceDiffWarning, - WorkspaceDiffWarningCode, } from "@paperclipai/shared"; // --------------------------------------------------------------------------- @@ -362,6 +344,12 @@ export interface PluginWorkspace { name: string; /** Absolute filesystem path to the workspace directory. */ path: string; + /** Repository URL, when known. */ + repoUrl: string | null; + /** Checkout/ref requested for the workspace, when known. */ + repoRef: string | null; + /** Default comparison ref for workspace tooling, when known. */ + defaultRef: string | null; /** Whether this is the project's primary workspace. */ isPrimary: boolean; /** ISO 8601 creation timestamp. */ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 29f26afb2b2..8dd130c268e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -356,17 +356,6 @@ export type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus, - WorkspaceDiffCaps, - WorkspaceDiffFile, - WorkspaceDiffFilePatch, - WorkspaceDiffFileStatus, - WorkspaceDiffPatchKind, - WorkspaceDiffQueryOptions, - WorkspaceDiffResponse, - WorkspaceDiffStats, - WorkspaceDiffView, - WorkspaceDiffWarning, - WorkspaceDiffWarningCode, WorkspaceRuntimeDesiredState, WorkspaceRealizationRecord, WorkspaceRealizationRequest, @@ -836,17 +825,6 @@ export { executionWorkspaceCloseLinkedIssueSchema, executionWorkspaceCloseReadinessSchema, executionWorkspaceCloseReadinessStateSchema, - workspaceDiffCapsSchema, - workspaceDiffFilePatchSchema, - workspaceDiffFileSchema, - workspaceDiffFileStatusSchema, - workspaceDiffPatchKindSchema, - workspaceDiffQuerySchema, - workspaceDiffResponseSchema, - workspaceDiffStatsSchema, - workspaceDiffViewSchema, - workspaceDiffWarningCodeSchema, - workspaceDiffWarningSchema, issueDocumentFormatSchema, issueDocumentKeySchema, upsertIssueDocumentSchema, @@ -873,7 +851,6 @@ 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 fc8a578d18b..9cb46a2c3ca 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -130,19 +130,6 @@ 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 deleted file mode 100644 index b84dc9f9044..00000000000 --- a/packages/shared/src/types/workspace-diff.ts +++ /dev/null @@ -1,101 +0,0 @@ -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; - 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 fb99f06ca28..3380c5534a3 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -265,21 +265,6 @@ 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 deleted file mode 100644 index 67186a58096..00000000000 --- a/packages/shared/src/validators/workspace-diff.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -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", - 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/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts index a20cbfe6044..bb38461d4eb 100644 --- a/server/src/__tests__/execution-workspaces-routes.test.ts +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -12,10 +12,6 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({ update: vi.fn(), })); -const mockWorkspaceDiffService = vi.hoisted(() => ({ - getDiff: vi.fn(), -})); - const mockWorkspaceOperationService = vi.hoisted(() => ({ listForExecutionWorkspace: vi.fn(), createRecorder: vi.fn(), @@ -26,7 +22,6 @@ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); vi.mock("../services/index.js", () => ({ executionWorkspaceService: () => mockExecutionWorkspaceService, logActivity: mockLogActivity, - workspaceDiffService: () => mockWorkspaceDiffService, workspaceOperationService: () => mockWorkspaceOperationService, })); @@ -61,37 +56,6 @@ describe.sequential("execution workspace routes", () => { }, ]); 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 () => { @@ -117,58 +81,4 @@ 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__/project-routes-env.test.ts b/server/src/__tests__/project-routes-env.test.ts index 91874741f1d..10450a37b72 100644 --- a/server/src/__tests__/project-routes-env.test.ts +++ b/server/src/__tests__/project-routes-env.test.ts @@ -21,9 +21,6 @@ const mockEnvironmentService = vi.hoisted(() => ({ getById: vi.fn(), })); const mockWorkspaceOperationService = vi.hoisted(() => ({})); -const mockWorkspaceDiffService = vi.hoisted(() => ({ - getDiff: vi.fn(), -})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); @@ -47,10 +44,6 @@ vi.mock("../services/secrets.js", () => ({ secretService: () => mockSecretService, })); -vi.mock("../services/workspace-diff.js", () => ({ - workspaceDiffService: () => mockWorkspaceDiffService, -})); - vi.mock("../services/workspace-runtime.js", () => ({ startRuntimeServicesForWorkspaceControl: vi.fn(), stopRuntimeServicesForProjectWorkspace: vi.fn(), @@ -77,10 +70,6 @@ function registerModuleMocks() { secretService: () => mockSecretService, })); - vi.doMock("../services/workspace-diff.js", () => ({ - workspaceDiffService: () => mockWorkspaceDiffService, - })); - vi.doMock("../services/workspace-runtime.js", () => ({ startRuntimeServicesForWorkspaceControl: vi.fn(), stopRuntimeServicesForProjectWorkspace: vi.fn(), @@ -161,37 +150,6 @@ describe("project env routes", () => { mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.createWorkspace.mockResolvedValue(null); mockProjectService.listWorkspaces.mockResolvedValue([]); - mockWorkspaceDiffService.getDiff.mockResolvedValue({ - workspaceId: "workspace-1", - companyId: "company-1", - repoRoot: "/tmp/project", - cwd: "/tmp/project", - view: "head", - baseRef: "origin/master", - headSha: "abc123", - includeUntracked: false, - 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, - }); mockEnvironmentService.getById.mockReset(); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); }); @@ -261,49 +219,4 @@ describe("project env routes", () => { }), ); }); - - it("returns project workspace diffs with explicit upstream refs", async () => { - mockProjectService.getById.mockResolvedValue(buildProject()); - mockProjectService.listWorkspaces.mockResolvedValue([ - { - id: "workspace-1", - companyId: "company-1", - projectId: "project-1", - name: "Primary", - sourceType: "local_path", - cwd: "/tmp/project", - repoUrl: null, - repoRef: "master", - defaultRef: "origin/master", - visibility: "default", - setupCommand: null, - cleanupCommand: null, - remoteProvider: null, - remoteWorkspaceRef: null, - sharedWorkspaceKey: null, - metadata: null, - runtimeConfig: null, - isPrimary: true, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]); - - const app = await createApp(); - const res = await request(app) - .get("/api/projects/project-1/workspaces/workspace-1/diff?view=head&baseRef=origin/master&includeUntracked=false"); - - expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockWorkspaceDiffService.getDiff).toHaveBeenCalledWith({ - id: "workspace-1", - companyId: "company-1", - cwd: "/tmp/project", - baseRef: "origin/master", - }, { - view: "head", - baseRef: "origin/master", - includeUntracked: false, - paths: [], - }); - }); }); diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index d502280a8bd..ac2488c38aa 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -26,7 +26,6 @@ const mockEnvironmentService = vi.hoisted(() => ({ })); const mockWorkspaceOperationService = vi.hoisted(() => ({})); -const mockWorkspaceDiffService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); @@ -42,7 +41,6 @@ vi.mock("../services/index.js", () => ({ logActivity: mockLogActivity, projectService: () => mockProjectService, secretService: () => mockSecretService, - workspaceDiffService: () => mockWorkspaceDiffService, workspaceOperationService: () => mockWorkspaceOperationService, })); @@ -69,7 +67,6 @@ function registerWorkspaceRouteMocks() { logActivity: mockLogActivity, projectService: () => mockProjectService, secretService: () => mockSecretService, - workspaceDiffService: () => mockWorkspaceDiffService, workspaceOperationService: () => mockWorkspaceOperationService, })); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index c9f548c4dd0..ba2f9180f21 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -6,12 +6,11 @@ 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, workspaceDiffService, workspaceOperationService } from "../services/index.js"; +import { executionWorkspaceService, logActivity, 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"; @@ -37,7 +36,6 @@ 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) => { @@ -67,18 +65,6 @@ 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/projects.ts b/server/src/routes/projects.ts index 048af150e6a..eccf3f7f746 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -8,14 +8,12 @@ import { matchWorkspaceRuntimeServiceToCommand, updateProjectSchema, updateProjectWorkspaceSchema, - workspaceDiffQuerySchema, workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared"; import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; -import { workspaceDiffService } from "../services/workspace-diff.js"; import { conflict, forbidden } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { @@ -44,7 +42,6 @@ export function projectRoutes(db: Db) { const router = Router(); const svc = projectService(db); const secretsSvc = secretService(db); - const diffSvc = workspaceDiffService(); const workspaceOperations = workspaceOperationService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const environmentsSvc = environmentService(db); @@ -342,29 +339,6 @@ export function projectRoutes(db: Db) { }, ); - router.get("/projects/:id/workspaces/:workspaceId/diff", async (req, res) => { - const id = req.params.id as string; - const workspaceId = req.params.workspaceId as string; - const project = await svc.getById(id); - if (!project) { - res.status(404).json({ error: "Project not found" }); - return; - } - assertCompanyAccess(req, project.companyId); - const workspace = (await svc.listWorkspaces(id)).find((entry) => entry.id === workspaceId); - if (!workspace) { - res.status(404).json({ error: "Project workspace not found" }); - return; - } - const query = workspaceDiffQuerySchema.parse(req.query); - res.json(await diffSvc.getDiff({ - id: workspace.id, - companyId: workspace.companyId, - cwd: workspace.cwd, - baseRef: workspace.defaultRef ?? workspace.repoRef ?? null, - }, query)); - }); - async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) { const id = req.params.id as string; const workspaceId = req.params.workspaceId as string; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 6f857264d1f..2e7c6bcdaf5 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -49,7 +49,6 @@ 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-host-services.ts b/server/src/services/plugin-host-services.ts index d0faf3b1b35..fef165dc1a8 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1148,6 +1148,9 @@ export function buildHostServices( projectId: row.projectId, name, path, + repoUrl: row.repoUrl, + repoRef: row.repoRef, + defaultRef: row.defaultRef, isPrimary: row.isPrimary, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -1167,6 +1170,9 @@ export function buildHostServices( projectId: project.id, name, path, + repoUrl: row?.repoUrl ?? project.codebase.repoUrl, + repoRef: row?.repoRef ?? project.codebase.repoRef, + defaultRef: row?.defaultRef ?? project.codebase.defaultRef, isPrimary: true, createdAt: (row?.createdAt ?? project.createdAt).toISOString(), updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(), @@ -1190,6 +1196,9 @@ export function buildHostServices( projectId: project.id, name, path, + repoUrl: row?.repoUrl ?? project.codebase.repoUrl, + repoRef: row?.repoRef ?? project.codebase.repoRef, + defaultRef: row?.defaultRef ?? project.codebase.defaultRef, isPrimary: true, createdAt: (row?.createdAt ?? project.createdAt).toISOString(), updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(), diff --git a/server/src/services/workspace-diff.ts b/server/src/services/workspace-diff.ts deleted file mode 100644 index 8c56f7de5d6..00000000000 --- a/server/src/services/workspace-diff.ts +++ /dev/null @@ -1,690 +0,0 @@ -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; -} - -type WorkspaceDiffTarget = Pick; - -function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning { - return { code, message, path: filePath }; -} - -function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record = {}) { - 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: WorkspaceDiffTarget) { - if (!workspace.cwd?.trim()) { - throw workspaceDiffError( - "missing_cwd", - "Execution workspace needs a local path before Paperclip can inspect diffs", - { workspaceId: workspace.id }, - ); - } - - const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid"); - let repoRoot: string; - try { - repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim(); - } catch { - throw workspaceDiffError( - "non_git_workspace", - "Execution workspace path is not inside a git repository", - { workspaceId: workspace.id, cwd }, - ); - } - - const repoRootReal = await realDirectory(repoRoot, "non_git_workspace"); - if (!isWithinDirectory(cwd, repoRootReal)) { - throw workspaceDiffError( - "workspace_path_invalid", - "Execution workspace path resolved outside its git repository", - { workspaceId: workspace.id, cwd, repoRoot: repoRootReal }, - ); - } - - return { cwd, repoRoot: repoRootReal }; -} - -function normalizePathFilter(rawPath: string) { - const value = rawPath.trim().replaceAll("\\", "/"); - if (!value || value === ".") return null; - if (value.includes("\0") || value.startsWith("/")) { - throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath }); - } - const normalized = path.posix.normalize(value); - if ( - normalized === "." || - normalized === ".." || - normalized.startsWith("../") || - normalized.includes("/../") - ) { - throw workspaceDiffError( - "path_filter_invalid", - "Path filters must not contain traversal segments", - { path: rawPath }, - ); - } - return normalized; -} - -function normalizePathFilters(paths: string[]) { - return Array.from(new Set(paths.map(normalizePathFilter).filter((value): value is string => Boolean(value)))); -} - -function statusFromGitStatus(status: string): WorkspaceDiffFileStatus { - if (status.startsWith("R")) return "renamed"; - if (status.startsWith("C")) return "copied"; - switch (status[0]) { - case "A": - return "added"; - case "D": - return "deleted"; - case "M": - return "modified"; - case "T": - return "type_changed"; - default: - return "unknown"; - } -} - -function parseNameStatus(output: string): GitStatusEntry[] { - const tokens = output.split("\0").filter(Boolean); - const entries: GitStatusEntry[] = []; - let index = 0; - while (index < tokens.length) { - const statusCode = tokens[index++] ?? ""; - if (!statusCode) continue; - if (statusCode.startsWith("R") || statusCode.startsWith("C")) { - const oldPath = tokens[index++] ?? ""; - const newPath = tokens[index++] ?? ""; - if (newPath) { - entries.push({ - status: statusFromGitStatus(statusCode), - path: newPath, - oldPath: oldPath || null, - }); - } - continue; - } - - const filePath = tokens[index++] ?? ""; - if (filePath) { - entries.push({ - status: statusFromGitStatus(statusCode), - path: filePath, - oldPath: null, - }); - } - } - return entries; -} - -async function readDiffNameStatus(cwd: string, scopeArgs: string[], paths: string[]) { - const result = await runGit(cwd, [ - "diff", - "--name-status", - "-z", - "--no-ext-diff", - "--find-renames", - ...scopeArgs, - "--", - ...paths, - ]); - return parseNameStatus(result.stdout); -} - -async function readUntrackedPaths(cwd: string, paths: string[]) { - const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]); - return result.stdout.split("\0").filter(Boolean); -} - -function ensureFile( - files: Map, - filePath: string, - status: WorkspaceDiffFileStatus, - oldPath: string | null, -) { - const existing = files.get(filePath); - if (existing) { - if (existing.status === "unknown" || status === "renamed" || status === "copied") { - existing.status = status; - } - if (!existing.oldPath && oldPath) existing.oldPath = oldPath; - return existing; - } - - const file: MutableWorkspaceDiffFile = { - path: filePath, - oldPath, - status, - staged: false, - unstaged: false, - untracked: false, - binary: false, - oversized: false, - truncated: false, - additions: 0, - deletions: 0, - sizeBytes: null, - patches: [], - warnings: [], - patchScopes: [], - }; - files.set(filePath, file); - return file; -} - -function addStatusEntries( - files: Map, - entries: GitStatusEntry[], - scope: DiffScope, -) { - for (const entry of entries) { - const file = ensureFile(files, entry.path, entry.status, entry.oldPath); - if (scope === "staged") file.staged = true; - else if (scope === "unstaged") file.unstaged = true; - if (!file.patchScopes.includes(scope)) file.patchScopes.push(scope); - } -} - -function parseNumstat(output: string) { - const line = output.split(/\r?\n/).find(Boolean); - if (!line) return { additions: 0, deletions: 0, binary: false }; - const [additionsRaw, deletionsRaw] = line.split(/\t/); - if (additionsRaw === "-" || deletionsRaw === "-") { - return { additions: 0, deletions: 0, binary: true }; - } - return { - additions: Number.parseInt(additionsRaw ?? "0", 10) || 0, - deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0, - binary: false, - }; -} - -async function readNumstat(cwd: string, scopeArgs: string[], filePath: string) { - const result = await runGit(cwd, [ - "diff", - "--numstat", - "--no-ext-diff", - "--find-renames", - ...scopeArgs, - "--", - filePath, - ], 128 * 1024); - return parseNumstat(result.stdout); -} - -async function statWorkspaceFile(repoRoot: string, filePath: string) { - const target = path.resolve(repoRoot, filePath); - if (!isWithinDirectory(target, repoRoot)) return null; - try { - const stat = await fs.stat(target); - return stat.isFile() ? stat.size : null; - } catch { - return null; - } -} - -function isMaxBufferError(error: unknown) { - return typeof error === "object" - && error !== null - && "code" in error - && (error as { code?: unknown }).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; -} - -async function readPatchOutput(cwd: string, args: string[]) { - try { - return await execFileAsync("git", ["-C", cwd, ...args], { - cwd, - timeout: GIT_TIMEOUT_MS, - maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024, - }); - } catch (error) { - if (isMaxBufferError(error)) { - return null; - } - const stderr = typeof (error as { stderr?: unknown }).stderr === "string" - ? String((error as { stderr?: unknown }).stderr).trim() - : ""; - throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args }); - } -} - -function reservePatchBytes( - patch: string, - budget: PatchBudget, - filePath: string, - warnings: WorkspaceDiffWarning[], -) { - const patchBytes = Buffer.byteLength(patch, "utf8"); - if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) { - warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath)); - return null; - } - if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) { - warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath)); - return null; - } - budget.totalPatchBytes += patchBytes; - return patch; -} - -async function buildTrackedPatch(input: { - cwd: string; - repoRoot: string; - filePath: string; - kind: WorkspaceDiffPatchKind; - scopeArgs: string[]; - budget: PatchBudget; -}): Promise { - const warnings: WorkspaceDiffWarning[] = []; - const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath); - const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath); - - if (numstat.binary) { - warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath)); - return { - kind: input.kind, - patch: null, - additions: 0, - deletions: 0, - binary: true, - oversized: false, - truncated: false, - warnings, - }; - } - - if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) { - warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath)); - return { - kind: input.kind, - patch: null, - additions: numstat.additions, - deletions: numstat.deletions, - binary: false, - oversized: true, - truncated: false, - warnings, - }; - } - - const patchOutput = await readPatchOutput(input.cwd, [ - "diff", - "--no-ext-diff", - "--find-renames", - "--unified=3", - ...input.scopeArgs, - "--", - input.filePath, - ]); - if (!patchOutput) { - warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath)); - return { - kind: input.kind, - patch: null, - additions: numstat.additions, - deletions: numstat.deletions, - binary: false, - oversized: false, - truncated: true, - warnings, - }; - } - - const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings); - return { - kind: input.kind, - patch, - additions: numstat.additions, - deletions: numstat.deletions, - binary: false, - oversized: false, - truncated: patch === null, - warnings, - }; -} - -function isProbablyBinary(buffer: Buffer) { - return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0); -} - -function countAddedLines(content: string) { - if (content.length === 0) return 0; - return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length; -} - -function buildUntrackedPatch(filePath: string, content: string) { - const lines = content.length === 0 ? [] : content.split("\n"); - if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); - const lineCount = countAddedLines(content); - const header = [ - `diff --git a/${filePath} b/${filePath}`, - "new file mode 100644", - "--- /dev/null", - `+++ b/${filePath}`, - ]; - if (lineCount === 0) return `${header.join("\n")}\n`; - const hunkLines = lines.map((line) => `+${line}`).join("\n"); - return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n"); -} - -async function buildUntrackedFilePatch(input: { - repoRoot: string; - filePath: string; - budget: PatchBudget; -}): Promise { - const warnings: WorkspaceDiffWarning[] = []; - const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath); - if (sizeBytes === null) { - return { - kind: "untracked", - patch: null, - additions: 0, - deletions: 0, - binary: false, - oversized: false, - truncated: false, - warnings, - }; - } - if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) { - warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath)); - return { - kind: "untracked", - patch: null, - additions: 0, - deletions: 0, - binary: false, - oversized: true, - truncated: false, - warnings, - }; - } - - const buffer = await fs.readFile(path.join(input.repoRoot, input.filePath)); - if (isProbablyBinary(buffer)) { - warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath)); - return { - kind: "untracked", - patch: null, - additions: 0, - deletions: 0, - binary: true, - oversized: false, - truncated: false, - warnings, - }; - } - - const content = buffer.toString("utf8"); - const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings); - return { - kind: "untracked", - patch, - additions: countAddedLines(content), - deletions: 0, - binary: false, - oversized: false, - truncated: patch === null, - warnings, - }; -} - -function applyPatchToFile(file: MutableWorkspaceDiffFile, patch: WorkspaceDiffFilePatch, sizeBytes: number | null) { - file.patches.push(patch); - file.additions += patch.additions; - file.deletions += patch.deletions; - file.binary = file.binary || patch.binary; - file.oversized = file.oversized || patch.oversized; - file.truncated = file.truncated || patch.truncated; - file.warnings.push(...patch.warnings); - if (file.sizeBytes === null && sizeBytes !== null) file.sizeBytes = sizeBytes; -} - -function finalizeStats(files: WorkspaceDiffFile[]) { - return { - fileCount: files.length, - stagedFileCount: files.filter((file) => file.staged).length, - unstagedFileCount: files.filter((file) => file.unstaged).length, - untrackedFileCount: files.filter((file) => file.untracked).length, - binaryFileCount: files.filter((file) => file.binary).length, - oversizedFileCount: files.filter((file) => file.oversized).length, - truncatedFileCount: files.filter((file) => file.truncated).length, - additions: files.reduce((sum, file) => sum + file.additions, 0), - deletions: files.reduce((sum, file) => sum + file.deletions, 0), - }; -} - -async function resolveHeadSha(cwd: string) { - try { - return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null; - } catch { - return null; - } -} - -async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) { - const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null; - if (!resolvedBaseRef) { - throw workspaceDiffError( - "base_ref_missing", - "A baseRef query parameter or execution workspace baseRef is required for head diffs", - { workspaceId: workspace.id }, - ); - } - try { - await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], { - cwd, - timeout: GIT_TIMEOUT_MS, - maxBuffer: 128 * 1024, - }); - } catch { - throw workspaceDiffError( - "base_ref_invalid", - `Could not resolve baseRef "${resolvedBaseRef}" in this workspace`, - { workspaceId: workspace.id, baseRef: resolvedBaseRef }, - ); - } - return resolvedBaseRef; -} - -async function collectFiles(input: { - cwd: string; - workspace: WorkspaceDiffTarget; - query: WorkspaceDiffQueryOptions; - paths: string[]; -}) { - const files = new Map(); - let baseRef: string | null = null; - - if (input.query.view === "head") { - baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace); - addStatusEntries( - files, - await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths), - "head", - ); - } else { - addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged"); - addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged"); - if (input.query.includeUntracked) { - for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) { - const file = ensureFile(files, untrackedPath, "untracked", null); - file.untracked = true; - if (!file.patchScopes.includes("unstaged")) file.patchScopes.push("unstaged"); - } - } - } - - return { files, baseRef }; -} - -export function workspaceDiffService() { - return { - async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise { - const { cwd, repoRoot } = await resolveWorkspacePaths(workspace); - const paths = normalizePathFilters(query.paths); - const warnings: WorkspaceDiffWarning[] = []; - const { files: filesByPath, baseRef } = await collectFiles({ cwd, workspace, query, paths }); - const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path)); - const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles); - if (allFiles.length > cappedFiles.length) { - warnings.push(warning( - "file_count_truncated", - `Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`, - )); - } - - const patchBudget: PatchBudget = { totalPatchBytes: 0 }; - for (const file of cappedFiles) { - if (query.view === "head") { - const patch = await buildTrackedPatch({ - cwd, - repoRoot, - filePath: file.path, - kind: "head", - scopeArgs: [`${baseRef}...HEAD`], - budget: patchBudget, - }); - applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); - continue; - } - - if (file.staged) { - const patch = await buildTrackedPatch({ - cwd, - repoRoot, - filePath: file.path, - kind: "staged", - scopeArgs: ["--cached"], - budget: patchBudget, - }); - applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); - } - if (file.unstaged) { - const patch = await buildTrackedPatch({ - cwd, - repoRoot, - filePath: file.path, - kind: "unstaged", - scopeArgs: [], - budget: patchBudget, - }); - applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); - } - if (file.untracked) { - const patch = await buildUntrackedFilePatch({ - repoRoot, - filePath: file.path, - budget: patchBudget, - }); - applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); - } - } - - const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file); - const patchWarnings = files.flatMap((file) => file.warnings); - return { - workspaceId: workspace.id, - companyId: workspace.companyId, - view: query.view, - baseRef, - headSha: await resolveHeadSha(cwd), - includeUntracked: query.includeUntracked, - paths, - files, - stats: finalizeStats(files), - warnings: [...warnings, ...patchWarnings], - caps: WORKSPACE_DIFF_CAPS, - truncated: warnings.some((item) => item.code === "file_count_truncated") - || files.some((file) => file.truncated), - }; - }, - }; -} diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts index 25dec2a363e..ba91f66674a 100644 --- a/ui/src/api/execution-workspaces.test.ts +++ b/ui/src/api/execution-workspaces.test.ts @@ -27,16 +27,4 @@ describe("executionWorkspacesApi.listSummaries", () => { ); }); - 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 07669be03c6..cd5b7341752 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -2,8 +2,6 @@ import type { ExecutionWorkspace, ExecutionWorkspaceSummary, ExecutionWorkspaceCloseReadiness, - WorkspaceDiffQueryOptions, - WorkspaceDiffResponse, WorkspaceOperation, WorkspaceRuntimeControlTarget, } from "@paperclipai/shared"; @@ -53,19 +51,6 @@ 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/projects.ts b/ui/src/api/projects.ts index 7b898fc76af..e975f829c11 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,8 +1,6 @@ import type { Project, ProjectWorkspace, - WorkspaceDiffQueryOptions, - WorkspaceDiffResponse, WorkspaceOperation, WorkspaceRuntimeControlTarget, } from "@paperclipai/shared"; @@ -35,24 +33,6 @@ export const projectsApi = { projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`), data, ), - getWorkspaceDiff: ( - projectId: string, - workspaceId: string, - options: Partial = {}, - companyId?: string, - ) => { - 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 suffix = `/workspaces/${encodeURIComponent(workspaceId)}/diff${params.toString() ? `?${params}` : ""}`; - return api.get(projectPath(projectId, companyId, suffix)); - }, controlWorkspaceRuntimeServices: ( projectId: string, workspaceId: string,