diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 439a0d3fe2c..63687a50f40 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -62,6 +62,13 @@ jobs: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile fi + - name: Upload resolved lockfile + uses: actions/upload-artifact@v4 + with: + name: pr-pnpm-lock-${{ github.run_id }} + path: pnpm-lock.yaml + retention-days: 1 + verify: needs: [policy] runs-on: ubuntu-latest @@ -82,12 +89,13 @@ jobs: node-version: 24 cache: pnpm + - name: Use policy-resolved lockfile + uses: actions/download-artifact@v4 + with: + name: pr-pnpm-lock-${{ github.run_id }} + - name: Install dependencies - run: | - pnpm install --frozen-lockfile || { - echo "Frozen lockfile install failed; retrying without frozen lockfile because PRs do not commit pnpm-lock.yaml." - pnpm install --no-frozen-lockfile - } + run: pnpm install --frozen-lockfile - name: Typecheck workspaces whose build scripts skip TypeScript run: pnpm run typecheck:build-gaps @@ -138,12 +146,13 @@ jobs: node-version: 24 cache: pnpm + - name: Use policy-resolved lockfile + uses: actions/download-artifact@v4 + with: + name: pr-pnpm-lock-${{ github.run_id }} + - name: Install dependencies - run: | - pnpm install --frozen-lockfile || { - echo "Frozen lockfile install failed; retrying without frozen lockfile because PRs do not commit pnpm-lock.yaml." - pnpm install --no-frozen-lockfile - } + run: pnpm install --frozen-lockfile - name: Run serialized server test shard run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }} @@ -169,12 +178,13 @@ jobs: node-version: 24 cache: pnpm + - name: Use policy-resolved lockfile + uses: actions/download-artifact@v4 + with: + name: pr-pnpm-lock-${{ github.run_id }} + - name: Install dependencies - run: | - pnpm install --frozen-lockfile || { - echo "Frozen lockfile install failed; retrying without frozen lockfile because PRs do not commit pnpm-lock.yaml." - pnpm install --no-frozen-lockfile - } + run: pnpm install --frozen-lockfile # `release.sh` always executes its Step 2/7 workspace build, even when # `--skip-verify` bypasses the initial verification gate. @@ -204,12 +214,13 @@ jobs: node-version: 24 cache: pnpm + - name: Use policy-resolved lockfile + uses: actions/download-artifact@v4 + with: + name: pr-pnpm-lock-${{ github.run_id }} + - name: Install dependencies - run: | - pnpm install --frozen-lockfile || { - echo "Frozen lockfile install failed; retrying without frozen lockfile because PRs do not commit pnpm-lock.yaml." - pnpm install --no-frozen-lockfile - } + run: pnpm install --frozen-lockfile - name: Install Playwright run: npx playwright install --with-deps chromium diff --git a/packages/plugins/plugin-workspace-diff/src/manifest.ts b/packages/plugins/plugin-workspace-diff/src/manifest.ts index 7d8bb833349..10a80585d74 100644 --- a/packages/plugins/plugin-workspace-diff/src/manifest.ts +++ b/packages/plugins/plugin-workspace-diff/src/manifest.ts @@ -14,6 +14,7 @@ const manifest: PaperclipPluginManifestV1 = { capabilities: [ "ui.detailTab.register", "execution.workspaces.read", + "project.workspaces.read", ], entrypoints: { worker: "./dist/worker.js", diff --git a/packages/plugins/plugin-workspace-diff/tests/fixtures.ts b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts index 13d0e07103a..3724ae1e650 100644 --- a/packages/plugins/plugin-workspace-diff/tests/fixtures.ts +++ b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts @@ -47,8 +47,6 @@ export function diffResponse(overrides: Partial = {}): Wo 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, diff --git a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts index 4060bd4211c..2a1945c4123 100644 --- a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts +++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts @@ -8,6 +8,7 @@ describe("workspace diff plugin", () => { it("declares workspace Changes tabs and host diff capability", () => { expect(manifest.capabilities).toContain("ui.detailTab.register"); expect(manifest.capabilities).toContain("execution.workspaces.read"); + expect(manifest.capabilities).toContain("project.workspaces.read"); expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({ type: "detailTab", displayName: "Changes", diff --git a/packages/shared/src/types/workspace-diff.ts b/packages/shared/src/types/workspace-diff.ts index cf7b0ed34fe..b84dc9f9044 100644 --- a/packages/shared/src/types/workspace-diff.ts +++ b/packages/shared/src/types/workspace-diff.ts @@ -88,8 +88,6 @@ export interface WorkspaceDiffStats { export interface WorkspaceDiffResponse { workspaceId: string; companyId: string; - repoRoot: string; - cwd: string; view: WorkspaceDiffView; baseRef: string | null; headSha: string | null; diff --git a/packages/shared/src/validators/workspace-diff.test.ts b/packages/shared/src/validators/workspace-diff.test.ts index 91cf3a83b27..67186a58096 100644 --- a/packages/shared/src/validators/workspace-diff.test.ts +++ b/packages/shared/src/validators/workspace-diff.test.ts @@ -20,8 +20,6 @@ describe("workspace diff validators", () => { 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", diff --git a/packages/shared/src/validators/workspace-diff.ts b/packages/shared/src/validators/workspace-diff.ts index 46f7a92a324..2d7c9eacc37 100644 --- a/packages/shared/src/validators/workspace-diff.ts +++ b/packages/shared/src/validators/workspace-diff.ts @@ -117,8 +117,6 @@ export const workspaceDiffStatsSchema = z.object({ 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(), diff --git a/server/src/__tests__/workspace-diff-service.test.ts b/server/src/__tests__/workspace-diff-service.test.ts index 41e0aa80a34..b56c344f031 100644 --- a/server/src/__tests__/workspace-diff-service.test.ts +++ b/server/src/__tests__/workspace-diff-service.test.ts @@ -91,6 +91,7 @@ describe("workspaceDiffService", () => { 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, "empty-untracked.txt"), "", "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()); @@ -126,6 +127,13 @@ describe("workspaceDiffService", () => { 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", + "new file mode 100644", + "--- /dev/null", + "+++ b/empty-untracked.txt", + "", + ].join("\n")); expect(byPath.get("binary.bin")).toMatchObject({ binary: true, unstaged: true, diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index c04817c10d0..ce75af8bfc5 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -571,6 +571,11 @@ export function buildHostServices( */ const ensurePluginAvailableForCompany = async (_companyId: string) => {}; + const ensureDeclaredCapability = (capability: import("@paperclipai/shared").PluginCapability) => { + if (!options.manifest || options.manifest.capabilities.includes(capability)) return; + throw new Error(`Missing required capability: ${capability}`); + }; + const getLocalFolderDeclaration = (folderKey: string) => requireLocalFolderDeclaration(options.manifest?.localFolders, folderKey); @@ -1227,6 +1232,7 @@ export function buildHostServices( if (!projectWorkspace) { throw new Error("Workspace not found"); } + ensureDeclaredCapability("project.workspaces.read"); return workspaceDiff.getDiff({ id: projectWorkspace.id, companyId: projectWorkspace.companyId, diff --git a/server/src/services/workspace-diff.ts b/server/src/services/workspace-diff.ts index 6df62495c74..8c56f7de5d6 100644 --- a/server/src/services/workspace-diff.ts +++ b/server/src/services/workspace-diff.ts @@ -442,17 +442,16 @@ function countAddedLines(content: string) { 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 [ + const header = [ `diff --git a/${filePath} b/${filePath}`, "new file mode 100644", "--- /dev/null", `+++ b/${filePath}`, - `@@ -0,0 +1,${lineCount} @@`, - hunkLines, - "", - ].join("\n"); + ]; + 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: { @@ -674,8 +673,6 @@ export function workspaceDiffService() { return { workspaceId: workspace.id, companyId: workspace.companyId, - repoRoot, - cwd, view: query.view, baseRef, headSha: await resolveHeadSha(cwd), diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index c744fcf7e7c..aaacd07f560 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -593,7 +593,11 @@ export function ExecutionWorkspaceDetail() { [project, workspace?.projectWorkspaceId], ); - const { slots: workspacePluginDetailSlots } = usePluginSlots({ + const { + slots: workspacePluginDetailSlots, + isLoading: workspacePluginDetailSlotsLoading, + errorMessage: workspacePluginDetailSlotsError, + } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "execution_workspace", companyId: workspace?.companyId ?? null, @@ -1183,6 +1187,14 @@ export function ExecutionWorkspaceDetail() { context={pluginSlotContext} missingBehavior="placeholder" /> + ) : isExecutionWorkspacePluginTab(activeTab) && workspacePluginDetailSlotsLoading ? ( + + Loading workspace plugin... + + ) : isExecutionWorkspacePluginTab(activeTab) && workspacePluginDetailSlotsError ? ( + + {workspacePluginDetailSlotsError} + ) : ( = [ @@ -431,8 +431,9 @@ export function ProjectWorkspaceDetail() { const params = new URLSearchParams({ tab: "changes", diffView: "head", - baseRef: upstreamBaseRef(workspace), }); + const baseRef = upstreamBaseRef(workspace); + if (baseRef) params.set("baseRef", baseRef); navigate(`${workspacePath}?${params.toString()}`); return; }