From 5071c4c7768b2594e8414a1ee3f9f91931c921e3 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 18 May 2026 08:50:06 -0500 Subject: [PATCH] [codex] Add workspace diff viewer plugin (#6071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Operators need to inspect what agents changed inside execution and project workspaces. > - The existing workspace detail views did not provide a first-party rich diff surface for staged, unstaged, head, renamed, binary, oversized, and untracked changes. > - The plugin system is the intended extension point for optional rich UI surfaces. > - This pull request adds a workspace diff plugin plus host services and shared contracts so Changes tabs can render workspace diffs through plugin slots. > - The diff-renderer dependency should stay owned by the plugin package rather than the core UI app. > - The dependency surface must stay aligned with repository PR policy, including intentionally omitting `pnpm-lock.yaml` from the PR. > - The benefit is a more reviewable workspace surface without hard-coding the renderer into every page. ## What Changed - Added `@paperclipai/plugin-workspace-diff`, including diff normalization, plugin manifest/worker/UI entrypoints, and focused plugin tests. - Kept `@pierre/diffs` scoped to `@paperclipai/plugin-workspace-diff`; removed the core UI lab diff-renderer surface and direct UI package dependency. - Added shared workspace diff types and validators, plus plugin SDK surface for workspace diff host services. - Added server workspace diff service support and route coverage for execution/project workspace diff flows. - Wired Execution Workspace and Project Workspace Changes tabs to load the diff plugin, including loading/error fallback behavior. - Added UI tests and fixtures for the Changes tabs and plugin bridge behavior. - Added the new plugin package manifest to the Docker deps stage so PR policy can validate dependency coverage. - Addressed review hardening around empty untracked patches, workspace path exposure, project workspace read capability checks, and default base refs. ## Verification - `pnpm --filter @paperclipai/plugin-workspace-diff test` - `pnpm exec vitest run packages/shared/src/validators/workspace-diff.test.ts server/src/__tests__/workspace-diff-service.test.ts ui/src/pages/ProjectWorkspaceDetail.test.tsx ui/src/pages/ExecutionWorkspaceDetail.test.tsx` - `pnpm exec vitest run ui/src/plugins/bridge.test.ts server/src/__tests__/workspace-runtime-routes-authz.test.ts` - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/plugin-workspace-diff typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `node ./scripts/check-docker-deps-stage.mjs` - Browser screenshot captured from the local worktree dev server: https://files.catbox.moe/ofdpsp.png - Confirmed branch is rebased onto `public-gh/master`, `.github/workflows/pr.yml` is not included in the PR diff, `ui/package.json` is not included in the PR diff, and `pnpm-lock.yaml` is not included in the PR diff. ## Risks - Medium UI integration risk: the Changes tab depends on the plugin slot and host diff service path. - Medium dependency risk: this adds `@pierre/diffs` in the plugin package, but `pnpm-lock.yaml` is intentionally omitted per packaging instructions because repository automation manages lockfile updates. - Current CI blocker: downstream frozen installs fail until the repository policy path for new plugin package dependencies is chosen. - Diff rendering edge cases are covered for common working-tree and head diff states, but very large repositories may still expose performance limits. - No migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 class coding model, tool-enabled local execution environment. Exact context window was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- Dockerfile | 1 + .../plugin-workspace-diff/package.json | 72 ++ .../scripts/build-ui.mjs | 24 + .../plugin-workspace-diff/src/contracts.ts | 144 ++++ .../plugin-workspace-diff/src/diff-model.ts | 143 ++++ .../plugin-workspace-diff/src/index.ts | 2 + .../plugin-workspace-diff/src/manifest.ts | 37 + .../plugin-workspace-diff/src/ui/index.tsx | 617 ++++++++++++++ .../plugin-workspace-diff/src/worker.ts | 91 ++ .../src/workspace-diff.ts | 787 ++++++++++++++++++ .../tests/contracts.spec.ts | 26 + .../tests/diff-model.spec.ts | 193 +++++ .../plugin-workspace-diff/tests/fixtures.ts | 78 ++ .../tests/plugin.spec.ts | 238 ++++++ .../tests/ui-error-state.spec.ts | 20 + .../tests/workspace-diff.spec.ts | 200 +++++ .../plugin-workspace-diff/tsconfig.json | 18 + .../plugin-workspace-diff/tsconfig.test.json | 8 + .../plugin-workspace-diff/vitest.config.ts | 8 + .../plugins/sdk/src/host-client-factory.ts | 9 + packages/plugins/sdk/src/index.ts | 2 + packages/plugins/sdk/src/protocol.ts | 8 + packages/plugins/sdk/src/testing.ts | 17 + packages/plugins/sdk/src/types.ts | 56 ++ packages/plugins/sdk/src/worker-rpc-host.ts | 6 + packages/shared/src/constants.ts | 3 + packages/shared/src/validators/plugin.test.ts | 24 + scripts/bootstrap-npm-package.test.mjs | 6 + scripts/release-package-manifest.json | 5 + .../execution-workspaces-routes.test.ts | 8 +- ...eartbeat-issue-liveness-escalation.test.ts | 39 + .../plugin-execution-workspace-bridge.test.ts | 54 ++ .../plugin-orchestration-apis.test.ts | 57 ++ .../src/__tests__/plugin-sdk-testing.test.ts | 57 ++ server/src/routes/plugins.ts | 10 +- .../services/plugin-capability-validator.ts | 1 + server/src/services/plugin-host-services.ts | 53 ++ server/src/services/recovery/service.ts | 24 + ui/src/api/execution-workspaces.test.ts | 1 + ui/src/api/plugins.ts | 2 +- .../MissingPluginTabPlaceholder.tsx | 23 + .../pages/ExecutionWorkspaceDetail.test.tsx | 321 +++++++ ui/src/pages/ExecutionWorkspaceDetail.tsx | 118 ++- ui/src/pages/PluginManager.tsx | 10 +- ui/src/pages/ProjectWorkspaceDetail.test.tsx | 341 ++++++++ ui/src/pages/ProjectWorkspaceDetail.tsx | 176 +++- ui/src/plugins/bridge.test.ts | 19 + ui/src/plugins/slots.tsx | 33 +- 48 files changed, 4119 insertions(+), 71 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/contracts.ts 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/src/workspace-diff.ts create mode 100644 packages/plugins/plugin-workspace-diff/tests/contracts.spec.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/tests/ui-error-state.spec.ts create mode 100644 packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts create mode 100644 packages/plugins/plugin-workspace-diff/tsconfig.json create mode 100644 packages/plugins/plugin-workspace-diff/tsconfig.test.json create mode 100644 packages/plugins/plugin-workspace-diff/vitest.config.ts create mode 100644 server/src/__tests__/plugin-execution-workspace-bridge.test.ts create mode 100644 ui/src/components/MissingPluginTabPlaceholder.tsx create mode 100644 ui/src/pages/ExecutionWorkspaceDetail.test.tsx create mode 100644 ui/src/pages/ProjectWorkspaceDetail.test.tsx diff --git a/Dockerfile b/Dockerfile index ece9d86b..cf2e0a93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,7 @@ COPY packages/plugins/sdk/package.json packages/plugins/sdk/ COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/ COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/ COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/ +COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/ COPY patches/ patches/ RUN pnpm install --frozen-lockfile diff --git a/packages/plugins/plugin-workspace-diff/package.json b/packages/plugins/plugin-workspace-diff/package.json new file mode 100644 index 00000000..33e9a588 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/package.json @@ -0,0 +1,72 @@ +{ + "name": "@paperclipai/plugin-workspace-diff", + "version": "0.1.0", + "description": "First-party execution workspace Changes tab powered by plugin-local workspace metadata", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/plugins/plugin-workspace-diff" + }, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "keywords": [ + "paperclip", + "plugin", + "workspace", + "diff" + ], + "scripts": { + "postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs", + "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 && tsc --noEmit -p tsconfig.test.json", + "test": "vitest run", + "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../scripts/generate-plugin-package-json.mjs", + "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi" + }, + "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 00000000..5cd75637 --- /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/contracts.ts b/packages/plugins/plugin-workspace-diff/src/contracts.ts new file mode 100644 index 00000000..6267b9b8 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/contracts.ts @@ -0,0 +1,144 @@ +import { z } from "@paperclipai/plugin-sdk"; + +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", + "symlink_target_outside_workspace", + "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((filePath) => filePath.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(), + companyId: z.string(), + view: workspaceDiffViewSchema, + baseRef: z.string().nullable(), + defaultBaseRef: 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 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 new file mode 100644 index 00000000..b8dfa464 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/diff-model.ts @@ -0,0 +1,143 @@ +import type { + WorkspaceDiffFile, + WorkspaceDiffFilePatch, + WorkspaceDiffResponse, + WorkspaceDiffWarning, +} from "./contracts.js"; + +export type DiffRenderMode = "unified" | "split"; + +export interface DiffPatchViewModel { + kind: WorkspaceDiffFilePatch["kind"]; + patch: string | null; + lineCount: number; + additions: number; + deletions: number; + binary: boolean; + oversized: boolean; + truncated: boolean; + warnings: WorkspaceDiffWarning[]; +} + +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"][]; + patches: DiffPatchViewModel[]; + patch: string | null; + lineCount: number; + longDiff: boolean; +} + +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 const LONG_DIFF_LINE_THRESHOLD = 400; + +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 buildFilePatches(file: WorkspaceDiffFile): DiffPatchViewModel[] { + return file.patches.map((patch) => { + const textPatch = patch.patch?.trimEnd() ?? null; + const lineCount = textPatch ? textPatch.split("\n").length : 0; + return { + kind: patch.kind, + patch: textPatch && textPatch.length > 0 ? textPatch : null, + lineCount, + additions: patch.additions, + deletions: patch.deletions, + binary: patch.binary, + oversized: patch.oversized, + truncated: patch.truncated, + warnings: patch.warnings, + }; + }); +} + +export function buildFilePatch(file: WorkspaceDiffFile): string | null { + return buildFilePatches(file).find((patch) => patch.patch)?.patch ?? null; +} + +export function isLongDiffFile(file: Pick) { + return file.lineCount > LONG_DIFF_LINE_THRESHOLD; +} + +export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] { + return (diff?.files ?? []).map((file) => { + const patches = buildFilePatches(file); + const lineCount = patches.reduce((count, patch) => count + patch.lineCount, 0); + 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, + patchKinds: file.patches.map((patch) => patch.kind), + patches, + patch: patches.find((patch) => patch.patch)?.patch ?? null, + lineCount, + longDiff: isLongDiffFile({ lineCount }), + }; + }); +} + +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; + + 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; +} + +export function initialExpandedFileSet(files: readonly DiffFileViewModel[]): Set { + return new Set(files.filter((file) => !file.longDiff).map((file) => file.path)); +} 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 00000000..f301da5d --- /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 00000000..107ecfbf --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/manifest.ts @@ -0,0 +1,37 @@ +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 and project workspaces using plugin-local Git diff computation and @pierre/diffs.", + author: "Paperclip", + categories: ["workspace", "ui"], + capabilities: [ + "ui.detailTab.register", + "execution.workspaces.read", + "project.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", "project_workspace"], + order: 25, + }, + ], + }, +}; + +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 00000000..eaf70f48 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -0,0 +1,617 @@ +import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui"; +import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui"; +import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs"; +import type { PatchDiffProps } from "@pierre/diffs/react"; +import { useFileDiffInstance } from "@pierre/diffs/react"; +import { createElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + diffSummary, + fileName, + initialExpandedFileSet, + nextExpandedFileSet, + statusLabel, + toFileViewModels, + type DiffFileViewModel, + type DiffPatchViewModel, + type DiffRenderMode, +} from "../diff-model.js"; +import type { WorkspaceDiffResponse } from "../contracts.js"; + +type WorkspaceDiffData = WorkspaceDiffResponse; +type WorkspacePatchDiffOptions = PatchDiffProps["options"]; +type DiffViewMode = "working-tree" | "head"; + +type LucideIconProps = { size?: number }; + +function makeLucideIcon(paths: ReactNode) { + return function LucideIcon({ size = 16 }: LucideIconProps) { + return ( + + ); + }; +} + +// Plugin bundles cannot import host-only lucide-react; this mirrors lucide RefreshCw. +const RefreshCwIcon = makeLucideIcon( + <> + + + + + , +); + +function readInitialView(): DiffViewMode { + if (typeof window === "undefined") return "working-tree"; + return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree"; +} + +function readInitialBaseRef() { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get("baseRef") ?? ""; +} + +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.patches.every((patch) => !patch.patch)) return "No text patch"; + return null; +} + +const PATCH_KIND_LABELS: Record = { + staged: "Staged", + unstaged: "Unstaged", + head: "Head", + untracked: "Untracked", +}; + +function patchKindLabel(kind: DiffPatchViewModel["kind"]) { + return PATCH_KIND_LABELS[kind] ?? "Patch"; +} + +function patchWarningText(patch: DiffPatchViewModel) { + if (patch.binary) return "Binary file"; + if (patch.oversized) return "Too large to render"; + if (patch.truncated) return "Patch truncated"; + if (patch.warnings.length > 0) return patch.warnings[0]?.message ?? "Diff warning"; + if (!patch.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); + const expandLabel = expanded ? "Collapse file" : "Expand file"; + const fileAriaLabel = expanded ? `Collapse ${file.path}` : `Expand ${file.path}`; + + return ( +
+
+ + + +
+
+ {statusLabel(file.status)} + {`+${file.additions}`} + {`-${file.deletions}`} + {warning ? {warning} : null} +
+
+ ); +} + +// The upstream React wrapper emits React 19 key warnings for its internal slot array. +// This mounts the same Diffs custom element through the exported imperative hook. +function WorkspacePatchDiff({ + patch, + options, +}: { + patch: string; + options: WorkspacePatchDiffOptions; +}) { + const fileDiff = useMemo(() => getSingularPatch(patch), [patch]); + const { ref } = useFileDiffInstance({ + fileDiff, + options, + metrics: undefined, + lineAnnotations: undefined, + selectedLines: undefined, + prerenderedHTML: undefined, + hasGutterRenderUtility: false, + hasCustomHeader: false, + disableWorkerPool: false, + }); + + return createElement(DIFFS_TAG_NAME, { ref }); +} + +function EmptyState() { + return ( +
+
No workspace changes
+
+ The workspace matches its current comparison target. +
+
+ ); +} + +function LoadingState() { + return ( +
+ Loading workspace changes… +
+ ); +} + +export function ErrorState({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) { + return ( +
+
+
+
Unable to load workspace changes.
+
+ Retry the request or open the details below for the technical error. +
+
+ +
+
+ + Troubleshooting details + +
+          {message || "No error message was provided."}
+        
+
+
+ ); +} + +function FileDiffPanel({ + file, + mode, +}: { + file: DiffFileViewModel; + mode: DiffRenderMode; +}) { + const warning = warningText(file); + if (warning) { + return ( +
+ {warning ?? "No renderable patch is available for this file."} +
+ ); + } + + return ( +
+ {file.patches.map((patch, index) => { + const patchWarning = patchWarningText(patch); + return ( +
+ {file.patches.length > 1 ? ( +
+ {patchKindLabel(patch.kind)} + {`+${patch.additions}`} + {`-${patch.deletions}`} +
+ ) : null} + {patchWarning || !patch.patch ? ( +
+ {patchWarning ?? "No renderable patch is available for this file."} +
+ ) : ( + + )} +
+ ); + })} +
+ ); +} + +function CollapsedFilePanel({ + file, + onExpand, +}: { + file: DiffFileViewModel; + onExpand: () => void; +}) { + const title = file.longDiff ? "Large diff folded" : "Diff folded"; + const details = file.lineCount > 0 + ? `${file.lineCount.toLocaleString()} lines` + : statusLabel(file.status); + + return ( +
+
+
+
{title}
+
{details}
+
+ +
+
+ ); +} + +export function ChangesTab({ context }: PluginDetailTabProps) { + const toast = usePluginToast(); + const [mode, setMode] = useState("split"); + const [view, setView] = useState(() => readInitialView()); + const [baseRef, setBaseRef] = useState(() => readInitialBaseRef()); + const baseRefTouchedRef = useRef(Boolean(baseRef.trim())); + const [includeUntracked, setIncludeUntracked] = useState(false); + const [expandedFiles, setExpandedFiles] = useState>(() => new Set()); + const [selectedPath, setSelectedPath] = useState(null); + const fileSectionRefs = useRef(new Map()); + const diffScrollRef = useRef(null); + const scrollSyncFrameRef = useRef(null); + + const params = useMemo(() => ({ + workspaceId: context.entityId, + companyId: context.companyId ?? "", + projectId: context.projectId ?? "", + entityType: context.entityType, + view, + baseRef: baseRef.trim() || null, + includeUntracked, + }), [baseRef, context.companyId, context.entityId, context.entityType, context.projectId, includeUntracked, view]); + + 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; + const compareLabel = `${data?.baseRef ? `base ${data.baseRef}` : "working tree"}${data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}`; + + const setFileSectionRef = useCallback((filePath: string) => (node: HTMLElement | null) => { + if (node) fileSectionRefs.current.set(filePath, node); + else fileSectionRefs.current.delete(filePath); + }, []); + + const selectFile = useCallback((filePath: string) => { + setSelectedPath(filePath); + window.requestAnimationFrame(() => { + fileSectionRefs.current.get(filePath)?.scrollIntoView({ + block: "start", + behavior: "smooth", + }); + }); + }, []); + + const syncSelectedPathFromScroll = useCallback(() => { + const container = diffScrollRef.current; + if (!container || files.length === 0) return; + + const containerTop = container.getBoundingClientRect().top; + let nextPath = files[0]?.path ?? null; + for (const file of files) { + const section = fileSectionRefs.current.get(file.path); + if (!section) continue; + const offsetFromScrollTop = section.getBoundingClientRect().top - containerTop; + if (offsetFromScrollTop <= 48) { + nextPath = file.path; + } else { + break; + } + } + + if (nextPath) { + setSelectedPath((current) => current === nextPath ? current : nextPath); + } + }, [files]); + + const handleDiffScroll = useCallback(() => { + if (scrollSyncFrameRef.current !== null) return; + scrollSyncFrameRef.current = window.requestAnimationFrame(() => { + scrollSyncFrameRef.current = null; + syncSelectedPathFromScroll(); + }); + }, [syncSelectedPathFromScroll]); + + useEffect(() => { + const defaultBaseRef = data?.defaultBaseRef?.trim(); + if (!defaultBaseRef || baseRef.trim() || baseRefTouchedRef.current) return; + setBaseRef(defaultBaseRef); + }, [baseRef, data?.defaultBaseRef]); + + useEffect(() => { + if (files.length === 0) { + setExpandedFiles(new Set()); + setSelectedPath(null); + return; + } + setExpandedFiles(initialExpandedFileSet(files)); + setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null); + }, [files]); + + useEffect(() => { + return () => { + if (scrollSyncFrameRef.current !== null) { + window.cancelAnimationFrame(scrollSyncFrameRef.current); + } + }; + }, []); + + 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} +
+
+ {compareLabel} +
+
+ +
+
+ + +
+
+ + +
+ {view === "head" ? ( + { + baseRefTouchedRef.current = true; + setBaseRef(event.target.value); + }} + placeholder="origin/master" + aria-label="Base ref" + /> + ) : null} + {view === "working-tree" ? ( + + ) : null} + +
+
+ + {loading ? ( + + ) : error ? ( + + ) : files.length === 0 ? ( + + ) : ( +
+ + +
+ {files + .map((file, index) => ( +
+
+
+ + +
+
+ +
+
+ {expandedFiles.has(file.path) ? ( + + ) : ( + setExpandedFiles((current) => nextExpandedFileSet(current, file.path))} + /> + )} +
+ ))} +
+
+ )} +
+ ); +} 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 00000000..067af4f1 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/worker.ts @@ -0,0 +1,91 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; +import { workspaceDiffQuerySchema } from "./contracts.js"; +import { workspaceDiffService } from "./workspace-diff.js"; + +const PLUGIN_NAME = "workspace-diff"; + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function readOptionalString(value: unknown): string | null { + const trimmed = readString(value); + return trimmed || null; +} + +export function resolveDefaultBaseRef(input: { + workspaceBaseRef?: unknown; + projectWorkspaceDefaultRef?: unknown; + projectWorkspaceRepoRef?: unknown; +}): string | null { + return readOptionalString(input.workspaceBaseRef) + ?? readOptionalString(input.projectWorkspaceDefaultRef) + ?? readOptionalString(input.projectWorkspaceRepoRef); +} + +const plugin = definePlugin({ + async setup(ctx) { + ctx.logger.info(`${PLUGIN_NAME} plugin setup`); + const workspaceDiff = workspaceDiffService(); + + 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"); + } + + if (params.entityType === "project_workspace") { + const projectId = readString(params.projectId); + if (!projectId) { + throw new Error("projectId is required for project workspace diffs"); + } + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + const workspace = workspaces.find((candidate) => candidate.id === workspaceId); + if (!workspace) { + throw new Error("Workspace not found"); + } + return workspaceDiff.getDiff({ + id: workspace.id, + companyId, + cwd: workspace.path, + baseRef: resolveDefaultBaseRef({ + projectWorkspaceDefaultRef: workspace.defaultRef, + projectWorkspaceRepoRef: workspace.repoRef, + }), + }, workspaceDiffQuerySchema.parse(params)); + } + + const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId); + if (!workspace) { + throw new Error("Workspace not found"); + } + let projectWorkspaceDefaultBaseRef: string | null = null; + if (!readOptionalString(workspace.baseRef) && workspace.projectWorkspaceId) { + const workspaces = await ctx.projects.listWorkspaces(workspace.projectId, companyId); + const projectWorkspace = workspaces.find((candidate) => candidate.id === workspace.projectWorkspaceId); + projectWorkspaceDefaultBaseRef = projectWorkspace + ? resolveDefaultBaseRef({ + projectWorkspaceDefaultRef: projectWorkspace.defaultRef, + projectWorkspaceRepoRef: projectWorkspace.repoRef, + }) + : null; + } + + return workspaceDiff.getDiff({ + ...workspace, + baseRef: resolveDefaultBaseRef({ + workspaceBaseRef: workspace.baseRef, + projectWorkspaceDefaultRef: projectWorkspaceDefaultBaseRef, + }), + }, workspaceDiffQuerySchema.parse(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/src/workspace-diff.ts b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts new file mode 100644 index 00000000..3bc08b15 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts @@ -0,0 +1,787 @@ +import { execFile } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +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 { + WorkspaceDiffCaps, + WorkspaceDiffFile, + WorkspaceDiffFilePatch, + WorkspaceDiffFileStatus, + WorkspaceDiffPatchKind, + WorkspaceDiffQueryOptions, + WorkspaceDiffResponse, + WorkspaceDiffWarning, + WorkspaceDiffWarningCode, +} from "./contracts.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; +const OPEN_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0; + +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 = {}) { + const error = new Error(message); + Object.assign(error, { code, status: 422, details: { code, ...details } }); + return error; +} + +function toErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + return String(error); +} + +async function runGit(cwd: string, args: string[], maxBuffer = GIT_LIST_MAX_BUFFER) { + try { + return await execFileAsync("git", ["-C", cwd, ...args], { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer, + }); + } catch (error) { + const stderr = typeof (error as { stderr?: unknown }).stderr === "string" + ? String((error as { stderr?: unknown }).stderr).trim() + : ""; + const message = stderr || toErrorMessage(error); + throw workspaceDiffError("git_command_failed", message, { args }); + } +} + +async function realDirectory(value: string, code: WorkspaceDiffWarningCode) { + if (!path.isAbsolute(value)) { + throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value }); + } + let stat: Awaited>; + 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 resolved = await resolveWorkspaceFilePath(repoRoot, filePath); + if (resolved.status !== "ok") return null; + let handle: Awaited>; + try { + handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW); + } catch { + return null; + } + try { + const stat = await handle.stat(); + return stat.isFile() ? stat.size : null; + } catch { + return null; + } finally { + await handle.close(); + } +} + +async function resolveWorkspaceFilePath(repoRoot: string, filePath: string): Promise< + | { status: "ok"; realPath: string } + | { status: "missing" } + | { status: "outside_workspace" } +> { + const target = path.resolve(repoRoot, filePath); + if (!isWithinDirectory(target, repoRoot)) return { status: "outside_workspace" }; + try { + const realPath = await fs.realpath(target); + if (!isWithinDirectory(realPath, repoRoot)) return { status: "outside_workspace" }; + return { status: "ok", realPath }; + } catch { + return { status: "missing" }; + } +} + +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 resolved = await resolveWorkspaceFilePath(input.repoRoot, input.filePath); + if (resolved.status === "outside_workspace") { + warnings.push(warning( + "symlink_target_outside_workspace", + "Untracked file resolves outside the workspace and is summarized without reading target bytes.", + input.filePath, + )); + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + if (resolved.status === "missing") { + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + + let handle: Awaited>; + try { + handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW); + } catch { + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + + let sizeBytes: number; + let buffer: Buffer | null = null; + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + sizeBytes = stat.size; + if (sizeBytes <= WORKSPACE_DIFF_CAPS.maxFileBytes) { + buffer = await handle.readFile(); + } + } finally { + await handle.close(); + } + + 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, + }; + } + + if (!buffer) { + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + 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, + defaultBaseRef: workspace.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/packages/plugins/plugin-workspace-diff/tests/contracts.spec.ts b/packages/plugins/plugin-workspace-diff/tests/contracts.spec.ts new file mode 100644 index 00000000..ee6df0e1 --- /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/diff-model.spec.ts b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts new file mode 100644 index 00000000..8ab427c5 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { + buildFilePatch, + buildFilePatches, + diffSummary, + initialExpandedFileSet, + LONG_DIFF_LINE_THRESHOLD, + 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"], + lineCount: 7, + longDiff: false, + }); + }); + + 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 warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" }; + const file = changedFile({ + truncated: true, + warnings: [warning], + patches: [], + }); + const diff = diffResponse({ files: [file], truncated: true, warnings: [warning] }); + + expect(buildFilePatch(file)).toBeNull(); + expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]); + expect(diffSummary(diff)).toMatchObject({ + warningCount: 1, + truncated: true, + }); + }); + + it("does not duplicate aggregated patch warnings", () => { + const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" }; + const file = changedFile({ + warnings: [warning], + patches: [ + { + kind: "unstaged", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: true, + warnings: [warning], + }, + ], + }); + const diff = diffResponse({ files: [file], warnings: [warning] }); + + expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]); + expect(diffSummary(diff).warningCount).toBe(1); + }); + + it("keeps staged and unstaged patches renderable as separate single-file diffs", () => { + const stagedPatch = [ + "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"); + const unstagedPatch = [ + "diff --git a/src/app.ts b/src/app.ts", + "index 2222222..3333333 100644", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -3 +3 @@", + "-export const label = 'old';", + "+export const label = 'new';", + "", + ].join("\n"); + const file = changedFile({ + staged: true, + unstaged: true, + patches: [ + { + kind: "staged", + patch: stagedPatch, + additions: 1, + deletions: 1, + binary: false, + oversized: false, + truncated: false, + warnings: [], + }, + { + kind: "unstaged", + patch: unstagedPatch, + additions: 1, + deletions: 1, + binary: false, + oversized: false, + truncated: false, + warnings: [], + }, + ], + }); + + const patches = buildFilePatches(file); + const viewModel = toFileViewModels(diffResponse({ files: [file] }))[0]; + + expect(buildFilePatch(file)).toBe(stagedPatch.trimEnd()); + expect(patches.map((patch) => patch.kind)).toEqual(["staged", "unstaged"]); + expect(patches.map((patch) => patch.patch?.match(/^diff --git/gm)?.length ?? 0)).toEqual([1, 1]); + expect(viewModel?.patches).toHaveLength(2); + expect(viewModel?.patchKinds).toEqual(["staged", "unstaged"]); + }); + + it("marks long text diffs so the UI can fold them by default", () => { + const longPatch = [ + "diff --git a/src/large.ts b/src/large.ts", + "index 1111111..2222222 100644", + "--- a/src/large.ts", + "+++ b/src/large.ts", + "@@ -1,1 +1,1 @@", + ...Array.from({ length: LONG_DIFF_LINE_THRESHOLD }, (_, index) => `+export const value${index} = ${index};`), + "", + ].join("\n"); + const files = toFileViewModels(diffResponse({ + files: [ + changedFile({ path: "src/small.ts" }), + changedFile({ + path: "src/large.ts", + additions: LONG_DIFF_LINE_THRESHOLD, + deletions: 0, + patches: [ + { + kind: "unstaged", + patch: longPatch, + additions: LONG_DIFF_LINE_THRESHOLD, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings: [], + }, + ], + }), + ], + })); + const longFile = files.find((file) => file.path === "src/large.ts"); + const defaultExpanded = initialExpandedFileSet(files); + + expect(longFile?.lineCount).toBeGreaterThan(LONG_DIFF_LINE_THRESHOLD); + expect(longFile?.longDiff).toBe(true); + expect(defaultExpanded.has("src/small.ts")).toBe(true); + expect(defaultExpanded.has("src/large.ts")).toBe(false); + }); + + 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 00000000..9c8eb535 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts @@ -0,0 +1,78 @@ +import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "../src/contracts.js"; + +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", + view: "working-tree", + baseRef: null, + defaultBaseRef: 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 00000000..9796c178 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts @@ -0,0 +1,238 @@ +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin, { resolveDefaultBaseRef } from "../src/worker.js"; + +const execFileAsync = promisify(execFile); +const tempRoots: string[] = []; + +async function git(cwd: string, args: string[]) { + return execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function createGitWorkspace() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-plugin-")); + tempRoots.push(root); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await git(root, ["init"]); + await git(root, ["config", "user.email", "paperclip@example.com"]); + await git(root, ["config", "user.name", "Paperclip Test"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n"); + await git(root, ["add", "src/app.ts"]); + await git(root, ["commit", "-m", "initial"]); + await git(root, ["branch", "-M", "main"]); + return root; +} + +describe("workspace diff plugin", () => { + afterEach(async () => { + await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))); + tempRoots.length = 0; + }); + + it("declares workspace Changes tabs and workspace read capabilities", () => { + expect(manifest.capabilities).toContain("ui.detailTab.register"); + expect(manifest.capabilities).toContain("execution.workspaces.read"); + expect(manifest.capabilities).toContain("project.workspaces.read"); + expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({ + type: "detailTab", + displayName: "Changes", + entityTypes: ["execution_workspace", "project_workspace"], + })); + }); + + it("fetches changed execution workspace diffs from host metadata", async () => { + const root = await createGitWorkspace(); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 2;\n"); + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: root, + cwd: root, + repoUrl: null, + baseRef: "HEAD", + branchName: "main", + providerType: "git_worktree", + providerMetadata: null, + }], + }); + await plugin.definition.setup(harness.ctx); + + const result = await harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + view: "working-tree", + includeUntracked: false, + paths: ["src/app.ts"], + }); + + expect(result).toMatchObject({ + stats: { fileCount: 1 }, + files: [expect.objectContaining({ path: "src/app.ts" })], + }); + }); + + it("returns an empty diff when the workspace has no changes", async () => { + const root = await createGitWorkspace(); + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: root, + cwd: root, + repoUrl: null, + baseRef: "HEAD", + branchName: "main", + providerType: "git_worktree", + providerMetadata: null, + }], + }); + await plugin.definition.setup(harness.ctx); + + await expect(harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + })).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", + defaultBaseRef: "main", + stats: { fileCount: 1 }, + files: [expect.objectContaining({ path: "src/app.ts" })], + }); + }); + + it("resolves the default base ref from workspace and project workspace metadata", () => { + expect(resolveDefaultBaseRef({ + workspaceBaseRef: " release/main ", + projectWorkspaceDefaultRef: "origin/main", + projectWorkspaceRepoRef: "feature", + })).toBe("release/main"); + expect(resolveDefaultBaseRef({ + workspaceBaseRef: null, + projectWorkspaceDefaultRef: " origin/main ", + projectWorkspaceRepoRef: "feature", + })).toBe("origin/main"); + expect(resolveDefaultBaseRef({ + workspaceBaseRef: "", + projectWorkspaceDefaultRef: null, + projectWorkspaceRepoRef: " feature ", + })).toBe("feature"); + expect(resolveDefaultBaseRef({ + workspaceBaseRef: "", + projectWorkspaceDefaultRef: null, + projectWorkspaceRepoRef: "", + })).toBeNull(); + }); + + it("uses project workspace default refs for execution workspace head diffs", async () => { + const root = await createGitWorkspace(); + await git(root, ["checkout", "-b", "feature"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 4;\n"); + await git(root, ["add", "src/app.ts"]); + await git(root, ["commit", "-m", "feature change"]); + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + path: root, + cwd: root, + repoUrl: null, + baseRef: null, + branchName: "feature", + providerType: "git_worktree", + providerMetadata: null, + }], + }); + harness.ctx.projects.listWorkspaces = async (projectId, companyId) => { + expect(projectId).toBe("project-1"); + expect(companyId).toBe("company-1"); + return [{ + id: "project-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", + view: "head", + includeUntracked: false, + }); + + expect(result).toMatchObject({ + baseRef: "main", + defaultBaseRef: "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); + + await expect(harness.getData("workspace-diff", { + workspaceId: "workspace-1", + })).rejects.toThrow("workspaceId and companyId are required"); + }); +}); diff --git a/packages/plugins/plugin-workspace-diff/tests/ui-error-state.spec.ts b/packages/plugins/plugin-workspace-diff/tests/ui-error-state.spec.ts new file mode 100644 index 00000000..b81e70c8 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/ui-error-state.spec.ts @@ -0,0 +1,20 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ErrorState } from "../src/ui/index.js"; + +describe("workspace diff error state", () => { + it("keeps bridge error details out of the primary headline", () => { + const rawError = "Execution workspace not found"; + const html = renderToStaticMarkup(createElement(ErrorState, { + message: rawError, + onRetry: () => undefined, + })); + + expect(html).toContain("Unable to load workspace changes."); + expect(html).toContain("Retry"); + expect(html).toContain("Troubleshooting details"); + expect(html).not.toContain(`font-medium text-foreground">${rawError}`); + expect(html.indexOf(rawError)).toBeGreaterThan(html.indexOf("Troubleshooting details")); + }); +}); diff --git a/packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts b/packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts new file mode 100644 index 00000000..65bb619f --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts @@ -0,0 +1,200 @@ +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 { 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(); + +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-plugin-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 = {}): PluginExecutionWorkspaceMetadata { + return { + id: randomUUID(), + companyId: randomUUID(), + projectId: randomUUID(), + projectWorkspaceId: null, + path: cwd, + cwd, + repoUrl: null, + baseRef: null, + branchName: "feature", + providerType: "git_worktree", + providerMetadata: null, + ...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("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"); + 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, "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()); + 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("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 }); + 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("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("does not follow untracked symlinks outside the repo", async () => { + const repoRoot = await createTempRepo(); + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-secret-")); + tempDirs.add(outsideDir); + const secretContent = "external secret should not appear\n"; + const secretPath = path.join(outsideDir, "secret.txt"); + await fs.writeFile(secretPath, secretContent, "utf8"); + await fs.symlink(secretPath, path.join(repoRoot, "leak.txt")); + + const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery()); + const leak = diff.files.find((file) => file.path === "leak.txt"); + const serialized = JSON.stringify(diff); + + expect(leak).toMatchObject({ untracked: true, status: "untracked", additions: 0, sizeBytes: null }); + expect(leak?.patches[0]).toMatchObject({ + kind: "untracked", + patch: null, + warnings: [expect.objectContaining({ code: "symlink_target_outside_workspace" })], + }); + expect(diff.warnings).toContainEqual(expect.objectContaining({ + code: "symlink_target_outside_workspace", + path: "leak.txt", + })); + expect(serialized).not.toContain(secretContent.trim()); + }, 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-plugin-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/packages/plugins/plugin-workspace-diff/tsconfig.json b/packages/plugins/plugin-workspace-diff/tsconfig.json new file mode 100644 index 00000000..cf312210 --- /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": "src" + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/plugins/plugin-workspace-diff/tsconfig.test.json b/packages/plugins/plugin-workspace-diff/tsconfig.test.json new file mode 100644 index 00000000..508201b3 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "tests"] +} 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 00000000..649a293e --- /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 603ecd4f..8b85b0b7 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.get`. */ + executionWorkspaces: { + get(params: WorkerToHostMethods["executionWorkspaces.get"][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.get": gated("executionWorkspaces.get", async (params) => { + return services.executionWorkspaces.get(params); + }), "projects.managed.get": gated("projects.managed.get", async (params) => { return services.projects.getManaged(params); }), diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 2753d72c..92db6d69 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,7 @@ export type { PluginEntityRecord, PluginEntityQuery, PluginWorkspace, + PluginExecutionWorkspaceMetadata, Company, Project, Issue, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 1c849cac..24bc871b 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -51,6 +51,7 @@ import type { PluginIssueWakeupBatchResult, PluginIssueWakeupResult, PluginJobContext, + PluginExecutionWorkspaceMetadata, PluginWorkspace, ToolRunContext, ToolResult, @@ -777,6 +778,13 @@ export interface WorkerToHostMethods { params: { issueId: string; companyId: string }, result: PluginWorkspace | null, ]; + "executionWorkspaces.get": [ + params: { + workspaceId: string; + companyId: string; + }, + result: PluginExecutionWorkspaceMetadata | null, + ]; "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 20f18e3f..de34e8ae 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -33,6 +33,7 @@ import type { ToolResult, ToolRunContext, PluginWorkspace, + PluginExecutionWorkspaceMetadata, AgentSession, AgentSessionEvent, PluginLocalFolderEntry, @@ -80,6 +81,8 @@ export interface TestHarness { issueComments?: IssueComment[]; agents?: Agent[]; goals?: Goal[]; + projectWorkspaces?: PluginWorkspace[]; + executionWorkspaces?: PluginExecutionWorkspaceMetadata[]; }): void; setConfig(config: Record): void; /** Dispatch a host or plugin event to registered handlers. */ @@ -438,6 +441,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const agents = new Map(); const goals = new Map(); const projectWorkspaces = new Map(); + const executionWorkspaces = new Map(); const localFolderStatuses = new Map(); const localFolderFiles = new Map(); @@ -975,6 +979,13 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, }, }, + executionWorkspaces: { + async get(workspaceId, companyId) { + requireCapability(manifest, capabilitySet, "execution.workspaces.read"); + const workspace = executionWorkspaces.get(workspaceId); + return workspace?.companyId === companyId ? workspace : null; + }, + }, routines: { managed: { async get(routineKey, companyId) { @@ -2048,6 +2059,12 @@ 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) { currentConfig = { ...config }; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index bb9b86d3..bcbb7f4e 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -344,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. */ @@ -352,6 +358,40 @@ export interface PluginWorkspace { updatedAt: string; } +// --------------------------------------------------------------------------- +// Execution workspace metadata (read-only via ctx.executionWorkspaces) +// --------------------------------------------------------------------------- + +/** + * Plugin-safe execution workspace metadata provided by the host. This exposes + * the local/repository coordinates plugins need for workspace tooling without + * giving the SDK a host-owned diff engine. + */ +export interface PluginExecutionWorkspaceMetadata { + /** UUID primary key. */ + id: string; + /** UUID of the owning company. */ + companyId: string; + /** UUID of the parent project. */ + projectId: string; + /** UUID of the backing project workspace, when present. */ + projectWorkspaceId: string | null; + /** Absolute filesystem path to the workspace when locally realized. */ + path: string | null; + /** Current working directory for local workspace tooling. */ + cwd: string | null; + /** Repository URL, when known. */ + repoUrl: string | null; + /** Base ref configured for the workspace, when known. */ + baseRef: string | null; + /** Branch name configured for the workspace, when known. */ + branchName: string | null; + /** Host provider type for the realized workspace. */ + providerType: string | null; + /** Provider metadata already safe for plugin consumption. */ + providerMetadata: Record | null; +} + // --------------------------------------------------------------------------- // Host API surfaces exposed via PluginContext // --------------------------------------------------------------------------- @@ -818,6 +858,19 @@ export interface PluginProjectsClient { }; } +/** + * `ctx.executionWorkspaces` — read execution workspace metadata. + * + * Requires `execution.workspaces.read`. + */ +export interface PluginExecutionWorkspacesClient { + /** + * Return plugin-safe metadata for an execution workspace. The host enforces + * company access before returning any workspace coordinates. + */ + get(workspaceId: string, companyId: string): Promise; +} + /** * `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines. * @@ -1642,6 +1695,9 @@ export interface PluginContext { /** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */ projects: PluginProjectsClient; + /** Read execution workspace metadata. 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 643f9b0d..9bfae39e 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 get(workspaceId: string, companyId: string) { + return callHost("executionWorkspaces.get", { workspaceId, companyId }); + }, + }, + routines: { managed: { async get(routineKey: string, companyId: string) { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c2f8f807..9c295eae 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", @@ -961,6 +962,8 @@ export const PLUGIN_UI_SLOT_ENTITY_TYPES = [ "goal", "run", "comment", + "execution_workspace", + "project_workspace", ] as const; export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number]; diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index ccea0d6a..1984aa6b 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -104,4 +104,28 @@ describe("plugin UI slot validators", () => { if (parsed.success) return; expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true); }); + + it("accepts workspace entity types as detailTab targets", () => { + const parsed = pluginUiSlotDeclarationSchema.parse({ + type: "detailTab", + id: "workspace-diff-viewer", + displayName: "Diff", + exportName: "WorkspaceDiffViewer", + entityTypes: ["execution_workspace", "project_workspace"], + }); + + expect(parsed.entityTypes).toEqual(["execution_workspace", "project_workspace"]); + }); + + it("accepts execution_workspace as a toolbarButton entityType", () => { + const parsed = pluginUiSlotDeclarationSchema.parse({ + type: "toolbarButton", + id: "workspace-open-diff", + displayName: "Open diff", + exportName: "OpenWorkspaceDiffButton", + entityTypes: ["execution_workspace"], + }); + + expect(parsed.entityTypes).toEqual(["execution_workspace"]); + }); }); diff --git a/scripts/bootstrap-npm-package.test.mjs b/scripts/bootstrap-npm-package.test.mjs index 48deb739..32270f6b 100644 --- a/scripts/bootstrap-npm-package.test.mjs +++ b/scripts/bootstrap-npm-package.test.mjs @@ -52,3 +52,9 @@ test("resolveTargetPackage matches by package name or dir", () => { assert.equal(resolveTargetPackage("@paperclipai/a", packages).dir, "packages/a"); assert.equal(resolveTargetPackage("./packages/b", packages).name, "@paperclipai/b"); }); + +test("resolveTargetPackage includes the workspace diff plugin bootstrap package", () => { + const pkg = resolveTargetPackage("@paperclipai/plugin-workspace-diff"); + + assert.equal(pkg.dir, "packages/plugins/plugin-workspace-diff"); +}); diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index effc2d54..026bb467 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -69,6 +69,11 @@ "name": "@paperclipai/plugin-sdk", "publishFromCi": true }, + { + "dir": "packages/plugins/plugin-workspace-diff", + "name": "@paperclipai/plugin-workspace-diff", + "publishFromCi": false + }, { "dir": "server", "name": "@paperclipai/server", diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts index bbac9d61..bb38461d 100644 --- a/server/src/__tests__/execution-workspaces-routes.test.ts +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -25,15 +25,15 @@ vi.mock("../services/index.js", () => ({ 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 +55,7 @@ describe.sequential("execution workspace routes", () => { projectWorkspaceId: null, }, ]); + mockExecutionWorkspaceService.getById.mockResolvedValue(null); }); it("uses summary mode for lightweight workspace lookups", async () => { @@ -79,4 +80,5 @@ describe.sequential("execution workspace routes", () => { }); expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled(); }); + }); diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index 300098ca..ecf70fa1 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -800,4 +800,43 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false); expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true); }); + + it("removes closed liveness escalations from blocker relations during reconciliation", async () => { + await enableAutoRecovery(); + const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const heartbeat = heartbeatService(db); + + const first = await heartbeat.reconcileIssueGraphLiveness(); + expect(first.escalationsCreated).toBe(1); + + const escalations = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, "harness_liveness_escalation"), + ), + ); + expect(escalations).toHaveLength(1); + + await db + .update(issues) + .set({ status: "done", blockedByIssueIds: [] }) + .where(eq(issues.id, escalations[0]!.id)); + await db + .update(issues) + .set({ status: "done", blockedByIssueIds: [] }) + .where(eq(issues.id, blockerIssueId)); + + const second = await heartbeat.reconcileIssueGraphLiveness(); + expect(second.obsoleteRecoveryBlockerRelationsRemoved).toBe(0); + expect(second.doneRecoveryBlockerRelationsRemoved).toBe(1); + + const blockers = await db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .where(eq(issueRelations.relatedIssueId, blockedIssueId)); + expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false); + }); }); diff --git a/server/src/__tests__/plugin-execution-workspace-bridge.test.ts b/server/src/__tests__/plugin-execution-workspace-bridge.test.ts new file mode 100644 index 00000000..6c672f51 --- /dev/null +++ b/server/src/__tests__/plugin-execution-workspace-bridge.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js"; +import { PLUGIN_RPC_ERROR_CODES } from "../../../packages/plugins/sdk/src/protocol.js"; + +describe("plugin execution workspace bridge", () => { + it("routes metadata reads through the host client when the capability is declared", async () => { + const get = vi.fn().mockResolvedValue({ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: "/tmp/workspace-1", + cwd: "/tmp/workspace-1", + repoUrl: null, + baseRef: "main", + branchName: "feature/workspace-1", + providerType: "git_worktree", + providerMetadata: null, + }); + const handlers = createHostClientHandlers({ + pluginId: "workspace-plugin", + capabilities: ["execution.workspaces.read"], + services: { + executionWorkspaces: { get }, + } as any, + }); + + await expect( + handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }), + ).resolves.toMatchObject({ + id: "workspace-1", + cwd: "/tmp/workspace-1", + }); + expect(get).toHaveBeenCalledWith({ workspaceId: "workspace-1", companyId: "company-1" }); + }); + + it("rejects metadata reads when the plugin lacks execution.workspace read access", async () => { + const get = vi.fn(); + const handlers = createHostClientHandlers({ + pluginId: "workspace-plugin", + capabilities: [], + services: { + executionWorkspaces: { get }, + } as any, + }); + + await expect( + handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }), + ).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED, + }); + expect(get).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index 402419c6..de642f30 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -11,6 +11,7 @@ import { companies, costEvents, createDb, + executionWorkspaces, heartbeatRuns, issueRelations, issues, @@ -67,6 +68,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { await db.delete(agentWakeupRequests); await db.delete(issueRelations); await db.delete(issues); + await db.delete(executionWorkspaces); await db.delete(pluginManagedResources); await db.delete(projects); await db.delete(plugins); @@ -107,6 +109,61 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { return root; } + it("returns plugin-safe execution workspace metadata scoped to the company", async () => { + const { companyId } = await seedCompanyAndAgent(); + const otherCompanyId = randomUUID(); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + await db.insert(companies).values({ + id: otherCompanyId, + name: "Other", + issuePrefix: issuePrefix(otherCompanyId), + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + }); + await db.insert(executionWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Feature workspace", + status: "active", + cwd: "/tmp/paperclip-feature", + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "feature/workspace", + providerType: "git_worktree", + providerRef: "/tmp/paperclip-feature", + metadata: { + providerMetadata: { sandboxId: "sandbox-1" }, + workspaceRealizationRequest: { hiddenInternal: true }, + }, + }); + + const services = buildHostServices(db, "plugin-record-id", "paperclip.workspace", createEventBusStub()); + + await expect(services.executionWorkspaces.get({ workspaceId, companyId })).resolves.toMatchObject({ + id: workspaceId, + companyId, + projectId, + projectWorkspaceId: null, + path: "/tmp/paperclip-feature", + cwd: "/tmp/paperclip-feature", + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "feature/workspace", + providerType: "git_worktree", + providerMetadata: { sandboxId: "sandbox-1" }, + }); + await expect(services.executionWorkspaces.get({ workspaceId, companyId: otherCompanyId })).resolves.toBeNull(); + }); + it("creates plugin-origin issues with full orchestration fields and audit activity", async () => { const { companyId, agentId } = await seedCompanyAndAgent(); const blockerIssueId = randomUUID(); diff --git a/server/src/__tests__/plugin-sdk-testing.test.ts b/server/src/__tests__/plugin-sdk-testing.test.ts index b3efa7e1..25104161 100644 --- a/server/src/__tests__/plugin-sdk-testing.test.ts +++ b/server/src/__tests__/plugin-sdk-testing.test.ts @@ -3,6 +3,63 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; describe("plugin SDK test harness", () => { + it("returns scoped execution workspace metadata with the read capability", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-execution-workspace-metadata", + apiVersion: 1, + version: "0.1.0", + displayName: "Execution Workspace Metadata", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["execution.workspaces.read"], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + path: "/tmp/paperclip-test", + cwd: "/tmp/paperclip-test", + repoUrl: "https://example.com/repo.git", + baseRef: "main", + branchName: "feature/test", + providerType: "git_worktree", + providerMetadata: { sandboxId: "sandbox-1" }, + }], + }); + + await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).resolves.toMatchObject({ + id: "workspace-1", + cwd: "/tmp/paperclip-test", + branchName: "feature/test", + providerMetadata: { sandboxId: "sandbox-1" }, + }); + await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-2")).resolves.toBeNull(); + }); + + it("requires execution.workspaces.read before returning workspace metadata", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-missing-execution-workspace-read", + apiVersion: 1, + version: "0.1.0", + displayName: "Missing Workspace Read Capability", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + + await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).rejects.toThrow( + "missing required capability 'execution.workspaces.read'", + ); + }); + it("requires skills.managed capability before resetting a missing declaration", async () => { const manifest: PaperclipPluginManifestV1 = { id: "paperclip.test-missing-managed-skill-capability", diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 44368518..f3b67423 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 workspace Changes tab backed by plugin-local Git diff computation.", + 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/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index ef54d61d..e400ab88 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.get": ["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 3d4c8947..fef165dc 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -20,12 +20,14 @@ import type { IssueComment, PluginIssueAssigneeSummary, PluginIssueOrchestrationSummary, + PluginExecutionWorkspaceMetadata, } from "@paperclipai/plugin-sdk"; import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared"; 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"; @@ -520,6 +522,7 @@ export function buildHostServices( pluginWorkerManager: options.pluginWorkerManager, }); const projects = projectService(db); + const executionWorkspaces = executionWorkspaceService(db); const issues = issueService(db); const documents = documentService(db); const goals = goalService(db); @@ -588,6 +591,35 @@ export function buildHostServices( companyId: string, ): record is T => Boolean(record && record.companyId === companyId); + const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + + const readProviderMetadata = (metadata: Record | null | undefined) => { + if (!isRecord(metadata)) return null; + if (isRecord(metadata.providerMetadata)) return { ...metadata.providerMetadata }; + const rebuild = metadata.rebuild; + if (!isRecord(rebuild)) return null; + const rebuildMetadata = rebuild.metadata; + if (!isRecord(rebuildMetadata) || !isRecord(rebuildMetadata.providerMetadata)) return null; + return { ...rebuildMetadata.providerMetadata }; + }; + + const toPluginExecutionWorkspaceMetadata = ( + workspace: NonNullable>>, + ): PluginExecutionWorkspaceMetadata => ({ + id: workspace.id, + companyId: workspace.companyId, + projectId: workspace.projectId, + projectWorkspaceId: workspace.projectWorkspaceId, + path: workspace.cwd ?? workspace.providerRef, + cwd: workspace.cwd, + repoUrl: workspace.repoUrl, + baseRef: workspace.baseRef, + branchName: workspace.branchName, + providerType: workspace.providerType, + providerMetadata: readProviderMetadata(workspace.metadata), + }); + const requireInCompany = ( entityName: string, record: T | null | undefined, @@ -1116,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(), @@ -1135,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(), @@ -1158,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(), @@ -1197,6 +1238,18 @@ export function buildHostServices( }, }, + executionWorkspaces: { + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const workspace = await executionWorkspaces.getById(params.workspaceId); + if (inCompany(workspace, companyId)) { + return toPluginExecutionWorkspaceMetadata(workspace); + } + return null; + }, + }, + routines: { async managedGet(params) { const companyId = ensureCompanyId(params.companyId); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 47c6711e..895580a8 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -2970,6 +2970,28 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return result; } + async function retireDoneLivenessRecoveryBlockers() { + const closedRecoveries = await db + .select() + .from(issues) + .where( + and( + eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), + isNull(issues.hiddenAt), + inArray(issues.status, ["done", "cancelled"]), + ), + ); + + let blockerRelationsRemoved = 0; + for (const recovery of closedRecoveries) { + if (await removeRecoveryBlockerFromSource(recovery)) { + blockerRelationsRemoved += 1; + } + } + + return { blockerRelationsRemoved }; + } + function normalizeIssueGraphLivenessAutoRecoveryLookbackHours(raw: unknown) { const numeric = Math.floor(asNumber(raw, DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS)); return Math.min( @@ -3365,6 +3387,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const now = new Date(); const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings); + const doneRecoveryBlockerCleanup = await retireDoneLivenessRecoveryBlockers(); const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings); const result = { findings: findings.length, @@ -3379,6 +3402,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) obsoleteRecoveriesRetired: obsoleteRecoveryCleanup.retired, obsoleteRecoveriesActiveSkipped: obsoleteRecoveryCleanup.activeSkipped, obsoleteRecoveryBlockerRelationsRemoved: obsoleteRecoveryCleanup.blockerRelationsRemoved, + doneRecoveryBlockerRelationsRemoved: doneRecoveryBlockerCleanup.blockerRelationsRemoved, issueIds: [] as string[], escalationIssueIds: [] as string[], retiredRecoveryIssueIds: obsoleteRecoveryCleanup.retiredIssueIds, diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts index b18bd650..ba91f666 100644 --- a/ui/src/api/execution-workspaces.test.ts +++ b/ui/src/api/execution-workspaces.test.ts @@ -26,4 +26,5 @@ describe("executionWorkspacesApi.listSummaries", () => { "/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true", ); }); + }); diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts index 2e4c981a..9df0b937 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/components/MissingPluginTabPlaceholder.tsx b/ui/src/components/MissingPluginTabPlaceholder.tsx new file mode 100644 index 00000000..34965427 --- /dev/null +++ b/ui/src/components/MissingPluginTabPlaceholder.tsx @@ -0,0 +1,23 @@ +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; + +interface MissingPluginTabPlaceholderProps { + defaultTabHref: string; + defaultTabLabel: string; +} + +export function MissingPluginTabPlaceholder({ + defaultTabHref, + defaultTabLabel, +}: MissingPluginTabPlaceholderProps) { + return ( +
+
+

Workspace plugin tab is not available.

+ +
+
+ ); +} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx new file mode 100644 index 00000000..b15fba0b --- /dev/null +++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx @@ -0,0 +1,321 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ExecutionWorkspace, Project } from "@paperclipai/shared"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ExecutionWorkspaceDetail } from "./ExecutionWorkspaceDetail"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockExecutionWorkspacesApi = vi.hoisted(() => ({ + get: vi.fn(), + update: vi.fn(), + listWorkspaceOperations: vi.fn(), + controlRuntimeCommands: vi.fn(), +})); +const mockProjectsApi = vi.hoisted(() => ({ get: vi.fn() })); +const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn() })); +const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() })); +const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn() })); +const mockRoutinesApi = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), run: vi.fn() })); +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); +const mockPluginSlotOutlet = vi.hoisted(() => vi.fn()); +const mockPluginSlotMount = vi.hoisted(() => vi.fn()); +const mockPluginSlotState = vi.hoisted(() => ({ + slots: [] as unknown[], + isLoading: false, + errorMessage: null as string | null, +})); +const mockRouteLocation = vi.hoisted(() => ({ + pathname: "/execution-workspaces/workspace-1/issues", + search: "", +})); + +vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi })); +vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); +vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi })); +vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi })); +vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi })); +vi.mock("../api/routines", () => ({ routinesApi: mockRoutinesApi })); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => ( + {children} + ), + Navigate: ({ to }: { to: string }) =>
{to}
, + useLocation: () => ({ ...mockRouteLocation, hash: "", state: null }), + useNavigate: () => mockNavigate, + useParams: () => ({ workspaceId: "workspace-1" }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [{ id: "company-1", issuePrefix: "PAP" }], + selectedCompanyId: "company-1", + setSelectedCompanyId: vi.fn(), + }), +})); +vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) })); +vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) })); + +vi.mock("@/plugins/slots", () => ({ + PluginSlotMount: (props: unknown) => { + mockPluginSlotMount(props); + return
; + }, + PluginSlotOutlet: (props: unknown) => { + mockPluginSlotOutlet(props); + return
; + }, + usePluginSlots: (filters: unknown) => { + mockUsePluginSlots(filters); + const entityType = (filters as { entityType?: string }).entityType; + return { + slots: entityType === "execution_workspace" ? mockPluginSlotState.slots : [], + isLoading: mockPluginSlotState.isLoading, + errorMessage: mockPluginSlotState.errorMessage, + }; + }, +})); + +vi.mock("../components/IssuesList", () => ({ + IssuesList: () =>
, +})); +vi.mock("../components/ExecutionWorkspaceCloseDialog", () => ({ + ExecutionWorkspaceCloseDialog: () => null, +})); +vi.mock("../components/RoutineRunVariablesDialog", () => ({ + RoutineRunVariablesDialog: () => null, +})); +vi.mock("../components/WorkspaceRuntimeControls", () => ({ + buildWorkspaceRuntimeControlSections: () => [], + WorkspaceRuntimeQuickControls: () =>
, + WorkspaceRuntimeControls: () =>
, +})); +vi.mock("../components/PageTabBar", () => ({ + PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => ( +
+ {items.map((item) => ( + + ))} +
+ ), +})); +vi.mock("../components/CopyText", () => ({ CopyText: () => null })); + +function workspace(overrides: Partial = {}): ExecutionWorkspace { + const now = new Date("2026-05-01T00:00:00Z"); + return { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + sourceIssueId: null, + mode: "local", + strategyType: "local_worktree", + name: "Diff worktree", + status: "active", + cwd: "/tmp/workspace-1", + repoUrl: null, + baseRef: null, + branchName: null, + providerType: "local", + providerRef: null, + derivedFromExecutionWorkspaceId: null, + lastUsedAt: now, + openedAt: now, + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + runtimeServices: [], + createdAt: now, + updatedAt: now, + ...overrides, + } as ExecutionWorkspace; +} + +function project(overrides: Partial = {}): Project { + const now = new Date("2026-05-01T00:00:00Z"); + return { + id: "project-1", + companyId: "company-1", + urlKey: "project-1", + goalId: null, + goalIds: [], + goals: [], + name: "Test Project", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#14b8a6", + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/project-1", + effectiveLocalFolder: "/tmp/project-1", + origin: "managed_checkout", + }, + workspaces: [], + primaryWorkspace: null, + managedByPlugin: null, + archivedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function pluginSlot(overrides: Record = {}) { + return { + id: "changes-tab", + type: "detailTab", + displayName: "Changes", + exportName: "ExecutionWorkspaceChangesTab", + entityTypes: ["execution_workspace"], + pluginId: "plugin-1", + pluginKey: "paperclip.workspace-diff", + pluginDisplayName: "Workspace Changes", + pluginVersion: "0.1.0", + ...overrides, + }; +} + +async function flush() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("ExecutionWorkspaceDetail plugin slots", () => { + let root: Root | null = null; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockExecutionWorkspacesApi.get.mockResolvedValue(workspace()); + mockExecutionWorkspacesApi.listWorkspaceOperations.mockResolvedValue([]); + mockProjectsApi.get.mockResolvedValue(project()); + mockIssuesApi.list.mockResolvedValue([]); + mockAgentsApi.list.mockResolvedValue([]); + mockRoutinesApi.list.mockResolvedValue([]); + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + mockPluginSlotState.slots = []; + mockPluginSlotState.isLoading = false; + mockPluginSlotState.errorMessage = null; + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container.remove(); + vi.clearAllMocks(); + mockRouteLocation.pathname = "/execution-workspaces/workspace-1/issues"; + mockRouteLocation.search = ""; + }); + + async function render() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { + await flush(); + }); + } + + it("scopes the plugin detail-tab discovery to execution_workspace and the workspace's company", async () => { + await render(); + + const enabledDetailTabFilters = mockUsePluginSlots.mock.calls + .map(([filters]) => filters as { slotTypes: string[]; entityType: string; companyId: string | null; enabled?: boolean }) + .filter((filters) => filters.slotTypes.includes("detailTab") && filters.enabled !== false); + + expect(enabledDetailTabFilters.length).toBeGreaterThan(0); + for (const filters of enabledDetailTabFilters) { + expect(filters.entityType).toBe("execution_workspace"); + expect(filters.companyId).toBe("company-1"); + } + }); + + it("mounts a toolbar PluginSlotOutlet with execution_workspace context", async () => { + await render(); + + const outletCalls = mockPluginSlotOutlet.mock.calls.map(([props]) => props as { + slotTypes: string[]; + entityType: string; + context: { entityId: string; entityType: string; companyId: string; projectId: string }; + }); + const toolbarOutlet = outletCalls.find((props) => props.slotTypes.includes("toolbarButton")); + expect(toolbarOutlet).toBeDefined(); + expect(toolbarOutlet?.entityType).toBe("execution_workspace"); + expect(toolbarOutlet?.context).toMatchObject({ + entityId: "workspace-1", + entityType: "execution_workspace", + companyId: "company-1", + projectId: "project-1", + }); + }); + + it("does not mount plugin slots scoped to other entity types", async () => { + await render(); + + const outletCalls = mockPluginSlotOutlet.mock.calls.map(([props]) => props as { entityType: string }); + for (const props of outletCalls) { + expect(props.entityType).toBe("execution_workspace"); + } + }); + + it("shows a missing plugin placeholder instead of routines for stale plugin tab URLs", async () => { + mockRouteLocation.pathname = "/execution-workspaces/workspace-1"; + mockRouteLocation.search = "?tab=plugin%3Amissing%3Aslot"; + + await render(); + + expect(container.textContent).toContain("Workspace plugin tab is not available."); + expect(container.querySelector('a[href="/execution-workspaces/workspace-1/issues"]')?.textContent).toBe("Back to issues"); + expect(container.textContent).not.toContain("Workspace routines"); + expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull(); + }); + + it("orders execution workspace plugin tabs against built-in tabs by slot order", async () => { + mockPluginSlotState.slots = [ + pluginSlot({ id: "default-tab", displayName: "Default" }), + pluginSlot({ id: "changes-tab", displayName: "Changes", order: 25 }), + pluginSlot({ id: "inspect-tab", displayName: "Inspect", order: 50 }), + ]; + + await render(); + + const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent); + expect(tabLabels).toEqual([ + "Issues", + "Services", + "Changes", + "Configuration", + "Runtime logs", + "Inspect", + "Routines", + "Default", + ]); + }); +}); diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index f541616c..e60848a1 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -11,6 +11,7 @@ import { Tabs } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { CopyText } from "../components/CopyText"; import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; +import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder"; import { agentsApi } from "../api/agents"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { heartbeatsApi } from "../api/heartbeats"; @@ -19,6 +20,7 @@ import { projectsApi } from "../api/projects"; import { routinesApi } from "../api/routines"; import { IssuesList } from "../components/IssuesList"; import { PageTabBar } from "../components/PageTabBar"; +import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; import { RoutineRunVariablesDialog, type RoutineRunDialogSubmitData, @@ -54,9 +56,36 @@ type WorkspaceFormState = { workspaceRuntime: string; }; -type ExecutionWorkspaceTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines"; +type ExecutionWorkspaceBaseTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines"; +type ExecutionWorkspacePluginTab = `plugin:${string}`; +type ExecutionWorkspaceTab = ExecutionWorkspaceBaseTab | ExecutionWorkspacePluginTab; +type OrderedExecutionWorkspaceTabItem = { + value: ExecutionWorkspaceTab; + label: string; + order: number; +}; -function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null { +const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100; +const EXECUTION_WORKSPACE_BASE_TAB_ITEMS: OrderedExecutionWorkspaceTabItem[] = [ + { value: "issues", label: "Issues", order: 10 }, + { value: "services", label: "Services", order: 20 }, + { value: "configuration", label: "Configuration", order: 30 }, + { value: "runtime_logs", label: "Runtime logs", order: 40 }, + { value: "routines", label: "Routines", order: 60 }, +]; + +function isExecutionWorkspacePluginTab(value: string | null): value is ExecutionWorkspacePluginTab { + return typeof value === "string" && value.startsWith("plugin:"); +} + +function orderExecutionWorkspaceTabItems(items: OrderedExecutionWorkspaceTabItem[]) { + return items + .map((item, index) => ({ item, index })) + .sort((left, right) => left.item.order - right.item.order || left.index - right.index) + .map(({ item }) => item); +} + +function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceBaseTab | null { const segments = pathname.split("/").filter(Boolean); const executionWorkspacesIndex = segments.indexOf("execution-workspaces"); if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null; @@ -69,7 +98,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex return null; } -function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceTab) { +function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceBaseTab) { const segment = tab === "runtime_logs" ? "runtime-logs" : tab; return `/execution-workspaces/${workspaceId}/${segment}`; } @@ -536,7 +565,12 @@ export function ExecutionWorkspaceDetail() { const [errorMessage, setErrorMessage] = useState(null); const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); - const activeTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null; + const activeRouteTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null; + const pluginTabFromSearch = useMemo(() => { + const tab = new URLSearchParams(location.search).get("tab"); + return isExecutionWorkspacePluginTab(tab) ? tab : null; + }, [location.search]); + const activeTab: ExecutionWorkspaceTab | null = activeRouteTab ?? pluginTabFromSearch; const workspaceQuery = useQuery({ queryKey: queryKeys.executionWorkspaces.detail(workspaceId!), @@ -580,6 +614,30 @@ export function ExecutionWorkspaceDetail() { () => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null, [project, workspace?.projectWorkspaceId], ); + + const { + slots: workspacePluginDetailSlots, + isLoading: workspacePluginDetailSlotsLoading, + errorMessage: workspacePluginDetailSlotsError, + } = usePluginSlots({ + slotTypes: ["detailTab"], + entityType: "execution_workspace", + companyId: workspace?.companyId ?? null, + enabled: !!workspace?.companyId, + }); + const workspacePluginTabItems = useMemo( + () => workspacePluginDetailSlots.map((slot) => ({ + value: `plugin:${slot.pluginKey}:${slot.id}` as ExecutionWorkspacePluginTab, + label: slot.displayName, + order: slot.order ?? DEFAULT_PLUGIN_DETAIL_TAB_ORDER, + slot, + })), + [workspacePluginDetailSlots], + ); + const workspaceTabItems = useMemo( + () => orderExecutionWorkspaceTabItems([...EXECUTION_WORKSPACE_BASE_TAB_ITEMS, ...workspacePluginTabItems]), + [workspacePluginTabItems], + ); const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null; const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig; const runtimeConfigSource = @@ -684,11 +742,23 @@ export function ExecutionWorkspaceDetail() { }); const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null; + const pluginSlotContext = { + companyId: workspace.companyId, + projectId: workspace.projectId, + entityId: workspace.id, + entityType: "execution_workspace" as const, + }; + const activePluginTab = workspacePluginTabItems.find((item) => item.value === activeTab) ?? null; + if (workspaceId && activeTab === null) { return ; } const handleTabChange = (tab: ExecutionWorkspaceTab) => { + if (isExecutionWorkspacePluginTab(tab)) { + navigate(`/execution-workspaces/${workspace.id}?tab=${encodeURIComponent(tab)}`); + return; + } navigate(executionWorkspaceTabPath(workspace.id, tab)); }; @@ -731,15 +801,18 @@ export function ExecutionWorkspaceDetail() { {runtimeActionErrorMessage ?

{runtimeActionErrorMessage}

: null} {!runtimeActionErrorMessage && runtimeActionMessage ?

{runtimeActionMessage}

: null} + + handleTabChange(value as ExecutionWorkspaceTab)}> ({ value: item.value, label: item.label }))} align="start" value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)} @@ -1128,11 +1201,32 @@ export function ExecutionWorkspaceDetail() { error={linkedIssuesQuery.error as Error | null} project={project} /> - ) : ( + ) : activePluginTab ? ( + + ) : isExecutionWorkspacePluginTab(activeTab) && workspacePluginDetailSlotsLoading ? ( + + Loading workspace plugin... + + ) : isExecutionWorkspacePluginTab(activeTab) && workspacePluginDetailSlotsError ? ( + + {workspacePluginDetailSlotsError} + + ) : isExecutionWorkspacePluginTab(activeTab) ? ( + + ) : activeTab === "routines" ? ( + ) : ( + )}

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 ? ( ({ + get: vi.fn(), + updateWorkspace: vi.fn(), + controlWorkspaceCommands: vi.fn(), +})); +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn()); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); +const mockPluginSlotMount = vi.hoisted(() => vi.fn()); +const mockRouteSearch = vi.hoisted(() => ({ value: "" })); +const mockPluginSlotState = vi.hoisted(() => ({ + slots: [] as unknown[], + isLoading: false, + errorMessage: null as string | null, +})); + +vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => ( + {children} + ), + useLocation: () => ({ + pathname: "/PAP/projects/paperclip-app/workspaces/workspace-1", + search: mockRouteSearch.value, + hash: "", + state: null, + }), + useNavigate: () => mockNavigate, + useParams: () => ({ companyPrefix: "PAP", projectId: "paperclip-app", workspaceId: "workspace-1" }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [{ id: "company-1", issuePrefix: "PAP" }], + selectedCompanyId: "company-1", + setSelectedCompanyId: mockSetSelectedCompanyId, + }), +})); +vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) })); +vi.mock("../components/PathInstructionsModal", () => ({ ChoosePathButton: () => null })); +vi.mock("../components/WorkspaceRuntimeControls", () => ({ + buildWorkspaceRuntimeControlSections: () => [], + WorkspaceRuntimeControls: () =>
    , +})); +vi.mock("@/plugins/slots", () => ({ + PluginSlotMount: (props: unknown) => { + mockPluginSlotMount(props); + return
    ; + }, + usePluginSlots: (filters: unknown) => { + mockUsePluginSlots(filters); + const entityType = (filters as { entityType?: string }).entityType; + return { + slots: entityType === "project_workspace" ? mockPluginSlotState.slots : [], + isLoading: mockPluginSlotState.isLoading, + errorMessage: mockPluginSlotState.errorMessage, + }; + }, +})); +vi.mock("../components/PageTabBar", () => ({ + PageTabBar: ({ + items, + onValueChange, + }: { + items: Array<{ value: string; label: string }>; + onValueChange?: (value: string) => void; + }) => ( +
    + {items.map((item) => ( + + ))} +
    + ), +})); + +function projectWorkspace(overrides: Partial = {}): ProjectWorkspace { + const now = new Date("2026-05-01T00:00:00Z"); + return { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + name: "Primary checkout", + sourceType: "local_path", + cwd: "/tmp/paperclip", + repoUrl: "https://github.com/paperclipai/paperclip", + repoRef: "master", + defaultRef: "origin/main", + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + runtimeServices: [], + isPrimary: true, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function project(overrides: Partial = {}): Project { + const now = new Date("2026-05-01T00:00:00Z"); + const workspace = projectWorkspace(); + return { + id: "project-1", + companyId: "company-1", + urlKey: "paperclip-app", + goalId: null, + goalIds: [], + goals: [], + name: "Paperclip App", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#14b8a6", + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: workspace.id, + repoUrl: workspace.repoUrl, + repoRef: workspace.repoRef, + defaultRef: workspace.defaultRef, + repoName: "paperclip", + localFolder: workspace.cwd, + managedFolder: workspace.cwd ?? "/tmp/paperclip", + effectiveLocalFolder: workspace.cwd ?? "/tmp/paperclip", + origin: "local_folder", + }, + workspaces: [workspace], + primaryWorkspace: workspace, + managedByPlugin: null, + archivedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +async function flush() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function pluginSlot(overrides: Record = {}) { + return { + id: "quality-tab", + type: "detailTab", + displayName: "Quality", + exportName: "ProjectWorkspaceQualityTab", + entityTypes: ["project_workspace"], + pluginId: "plugin-1", + pluginKey: "paperclip.quality", + pluginDisplayName: "Quality Plugin", + pluginVersion: "0.1.0", + ...overrides, + }; +} + +describe("ProjectWorkspaceDetail plugin tabs", () => { + let root: Root | null = null; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockProjectsApi.get.mockResolvedValue(project()); + mockPluginSlotState.slots = []; + mockPluginSlotState.isLoading = false; + mockPluginSlotState.errorMessage = null; + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container.remove(); + vi.clearAllMocks(); + mockRouteSearch.value = ""; + }); + + async function render() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { + await flush(); + }); + } + + it("scopes plugin detail-tab discovery to project_workspace and the project's company", async () => { + await render(); + + const enabledDetailTabFilters = mockUsePluginSlots.mock.calls + .map(([filters]) => filters as { slotTypes: string[]; entityType: string; companyId: string | null; enabled?: boolean }) + .filter((filters) => filters.slotTypes.includes("detailTab") && filters.enabled !== false); + + expect(enabledDetailTabFilters.length).toBeGreaterThan(0); + for (const filters of enabledDetailTabFilters) { + expect(filters.entityType).toBe("project_workspace"); + expect(filters.companyId).toBe("company-1"); + } + }); + + it("renders an arbitrary project_workspace plugin detail tab from the generic URL value", async () => { + mockPluginSlotState.slots = [pluginSlot()]; + mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab&diffView=head&baseRef=origin%2Fmaster"; + + await render(); + + expect(container.querySelector('[data-tab-value="configuration"]')?.textContent).toBe("Configuration"); + expect(container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]')?.textContent).toBe("Quality"); + expect(container.querySelector('[data-tab-value="changes"]')).toBeNull(); + expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull(); + expect(mockPluginSlotMount).toHaveBeenCalledWith( + expect.objectContaining({ + slot: expect.objectContaining({ pluginKey: "paperclip.quality", id: "quality-tab" }), + context: expect.objectContaining({ entityType: "project_workspace", entityId: "workspace-1" }), + }), + ); + }); + + it("keeps the project workspace heading visible on plugin tabs", async () => { + mockPluginSlotState.slots = [pluginSlot({ displayName: "Changes" })]; + mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab"; + + await render(); + + expect(container.querySelector("h1")?.textContent).toBe("Primary checkout"); + expect(container.textContent).toContain("Project workspace"); + expect(container.textContent).toContain("This is the project’s primary codebase workspace."); + expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull(); + expect(container.textContent).not.toContain("Configure the concrete workspace"); + expect(container.textContent).not.toContain("Workspace name"); + }); + + it("orders project workspace plugin tabs against built-in tabs by slot order", async () => { + mockPluginSlotState.slots = [ + pluginSlot({ id: "late-tab", displayName: "Late", order: 40 }), + pluginSlot({ id: "early-tab", displayName: "Early", order: 20 }), + pluginSlot({ id: "default-tab", displayName: "Default" }), + ]; + + await render(); + + const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent); + expect(tabLabels).toEqual(["Early", "Configuration", "Late", "Default"]); + }); + + it("navigates plugin tabs with only the generic plugin tab parameter", async () => { + mockPluginSlotState.slots = [pluginSlot()]; + + await render(); + + await act(async () => { + (container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]') as HTMLButtonElement).click(); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + "/projects/paperclip-app/workspaces/workspace-1?tab=plugin%3Apaperclip.quality%3Aquality-tab", + ); + expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("diffView")); + expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("baseRef")); + }); + + it("does not treat the old changes tab query as a core plugin tab", async () => { + mockPluginSlotState.slots = [pluginSlot()]; + mockRouteSearch.value = "?tab=changes&diffView=head&baseRef=origin%2Fmain"; + + await render(); + + expect(container.querySelector('[data-tab-value="changes"]')).toBeNull(); + expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull(); + expect(container.textContent).toContain("Project workspace"); + }); + + it("shows a missing plugin placeholder instead of configuration for stale plugin tab URLs", async () => { + mockRouteSearch.value = "?tab=plugin%3Amissing%3Aslot"; + + await render(); + + expect(container.textContent).toContain("Workspace plugin tab is not available."); + expect(container.querySelector('a[href="/projects/paperclip-app/workspaces/workspace-1?tab=configuration"]')?.textContent).toBe( + "Back to configuration", + ); + expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull(); + expect(container.textContent).not.toContain("Configure the concrete workspace"); + expect(container.textContent).not.toContain("Workspace name"); + }); + + it("shows loading and error states for plugin tab manifests", async () => { + mockPluginSlotState.isLoading = true; + mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab"; + + await render(); + + expect(container.textContent).toContain("Loading workspace plugin..."); + + act(() => root?.unmount()); + root = null; + container.innerHTML = ""; + vi.clearAllMocks(); + mockProjectsApi.get.mockResolvedValue(project()); + mockPluginSlotState.isLoading = false; + mockPluginSlotState.errorMessage = "Plugin manifest failed"; + + await render(); + + expect(container.textContent).toContain("Plugin manifest failed"); + }); +}); diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx index cfb68265..3d87bbad 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -1,12 +1,16 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams } from "@/lib/router"; +import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared"; import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Tabs } from "@/components/ui/tabs"; import { ChoosePathButton } from "../components/PathInstructionsModal"; +import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder"; import { projectsApi } from "../api/projects"; +import { PageTabBar } from "../components/PageTabBar"; +import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; import { buildWorkspaceRuntimeControlSections, WorkspaceRuntimeControls, @@ -35,6 +39,36 @@ type WorkspaceFormState = { type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"]; type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"]; +type ProjectWorkspaceBaseTab = "configuration"; +type ProjectWorkspacePluginTab = `plugin:${string}`; +type ProjectWorkspaceTab = ProjectWorkspaceBaseTab | ProjectWorkspacePluginTab; +type OrderedProjectWorkspaceTabItem = { + value: ProjectWorkspaceTab; + label: string; + order: number; +}; + +const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100; +const PROJECT_WORKSPACE_BASE_TAB_ITEMS: OrderedProjectWorkspaceTabItem[] = [ + { value: "configuration", label: "Configuration", order: 30 }, +]; + +function isProjectWorkspacePluginTab(value: string | null): value is ProjectWorkspacePluginTab { + return typeof value === "string" && value.startsWith("plugin:"); +} + +function projectWorkspaceTabFromSearch(search: string): ProjectWorkspaceTab { + const tab = new URLSearchParams(search).get("tab"); + if (isProjectWorkspacePluginTab(tab)) return tab; + return "configuration"; +} + +function orderProjectWorkspaceTabItems(items: OrderedProjectWorkspaceTabItem[]) { + return items + .map((item, index) => ({ item, index })) + .sort((left, right) => left.item.order - right.item.order || left.index - right.index) + .map(({ item }) => item); +} const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [ { value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." }, @@ -217,6 +251,7 @@ export function ProjectWorkspaceDetail() { }>(); const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); + const location = useLocation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [form, setForm] = useState(null); @@ -224,6 +259,7 @@ export function ProjectWorkspaceDetail() { const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); const routeProjectRef = projectId ?? ""; const routeWorkspaceId = workspaceId ?? ""; + const activeTab = useMemo(() => projectWorkspaceTabFromSearch(location.search), [location.search]); const routeCompanyId = useMemo(() => { if (!companyPrefix) return null; @@ -247,6 +283,29 @@ export function ProjectWorkspaceDetail() { const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef; const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); + const { + slots: pluginDetailSlots, + isLoading: pluginDetailSlotsLoading, + errorMessage: pluginDetailSlotsError, + } = usePluginSlots({ + slotTypes: ["detailTab"], + entityType: "project_workspace", + companyId: project?.companyId ?? null, + enabled: Boolean(project?.companyId), + }); + const pluginTabItems = useMemo( + () => pluginDetailSlots.map((slot) => ({ + value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectWorkspacePluginTab, + label: slot.displayName, + order: slot.order ?? DEFAULT_PLUGIN_DETAIL_TAB_ORDER, + slot, + })), + [pluginDetailSlots], + ); + const tabItems = useMemo( + () => orderProjectWorkspaceTabItems([...PROJECT_WORKSPACE_BASE_TAB_ITEMS, ...pluginTabItems]), + [pluginTabItems], + ); useEffect(() => { if (!project?.companyId || project.companyId === selectedCompanyId) return; @@ -272,8 +331,8 @@ export function ProjectWorkspaceDetail() { useEffect(() => { if (!project) return; if (routeProjectRef === canonicalProjectRef) return; - navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true }); - }, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]); + navigate(`${projectWorkspaceUrl(project, routeWorkspaceId)}${location.search}`, { replace: true }); + }, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, location.search, navigate]); const invalidateProject = () => { if (!project) return; @@ -363,6 +422,15 @@ export function ProjectWorkspaceDetail() { }; const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null; + const handleTabChange = (tab: ProjectWorkspaceTab) => { + const workspacePath = projectWorkspaceUrl(project, routeWorkspaceId); + if (isProjectWorkspacePluginTab(tab)) { + navigate(`${workspacePath}?tab=${encodeURIComponent(tab)}`); + return; + } + navigate(workspacePath); + }; + const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null; return (
    @@ -373,45 +441,53 @@ export function ProjectWorkspaceDetail() { Back to workspaces -
    - {workspace.isPrimary ? "Primary workspace" : "Secondary workspace"} -
    +
    +
    +
    + Project workspace +
    +

    {workspace.name}

    +
    + {!workspace.isPrimary ? ( + + ) : ( +
    + + This is the project’s primary codebase workspace. +
    + )} +
    + + handleTabChange(value as ProjectWorkspaceTab)}> + ({ value: item.value, label: item.label }))} + align="start" + value={activeTab} + onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)} + /> + + + {activeTab === "configuration" ? (
    -
    -
    -
    - Project workspace -
    -

    {workspace.name}

    -

    - Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace - checkout behavior, default runtime services for child execution workspaces, and let you override setup - or cleanup commands when one workspace needs special handling. -

    -
    - {!workspace.isPrimary ? ( - - ) : ( -
    - - This is the project’s primary codebase workspace. -
    - )} -
    +

    + Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace + checkout behavior, default runtime services for child execution workspaces, and let you override setup + or cleanup commands when one workspace needs special handling. +

    @@ -643,6 +719,32 @@ export function ProjectWorkspaceDetail() {
    + ) : null} + + {isProjectWorkspacePluginTab(activeTab) ? ( + activePluginTab ? ( + + ) : pluginDetailSlotsLoading || pluginDetailSlotsError ? ( +
    + {pluginDetailSlotsError ? pluginDetailSlotsError : "Loading workspace plugin..."} +
    + ) : ( + + ) + ) : null}
    ); } diff --git a/ui/src/plugins/bridge.test.ts b/ui/src/plugins/bridge.test.ts index 543cbcc9..adc48fb5 100644 --- a/ui/src/plugins/bridge.test.ts +++ b/ui/src/plugins/bridge.test.ts @@ -23,6 +23,7 @@ import { type PluginBridgeContextValue, } from "./bridge"; import { initPluginBridge } from "./bridge-init"; +import { _createReactShimSourceForTests } from "./slots"; function clickEvent( overrides: Partial> = {}, @@ -304,3 +305,21 @@ describe("plugin SDK markdown component bridge", () => { }))).toContain("Run lint"); }); }); + +describe("plugin React shim", () => { + it("re-exports every named export from the host React module", () => { + const source = _createReactShimSourceForTests(React); + + for (const name of Object.keys(React).sort()) { + if (name === "default") continue; + if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue; + expect(source).toContain(`export const ${name} = R.${name};`); + } + + expect(source).toContain("export default R;"); + expect(source).toContain("export const useInsertionEffect = R.useInsertionEffect;"); + expect(source).toContain("export const useId = R.useId;"); + expect(source).toContain("export const useSyncExternalStore = R.useSyncExternalStore;"); + expect(source).toContain("export const startTransition = R.startTransition;"); + }); +}); diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index 80d1836a..ecc5f72d 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -29,6 +29,7 @@ import { type ReactNode, type ComponentType, } from "react"; +import * as ReactModule from "react"; import { useQuery } from "@tanstack/react-query"; import type { PluginLauncherDeclaration, @@ -244,24 +245,31 @@ function applyJsxRuntimeKey( return { ...(props ?? {}), key }; } +function createReactShimSource(reactModule: object): string { + const exportNames = Object.keys(reactModule) + .filter((name) => name !== "default" && /^[A-Za-z_$][\w$]*$/.test(name)) + .sort(); + const namedExports = exportNames + .map((name) => ` export const ${name} = R.${name};`) + .join("\n"); + + return ` + const R = globalThis.__paperclipPluginBridge__?.react; + if (!R) { + throw new Error("Paperclip plugin React runtime is not initialized."); + } + export default R; +${namedExports} + `; +} + function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" | "react/jsx-runtime" | "sdk-ui"): string { if (shimBlobUrls[specifier]) return shimBlobUrls[specifier]; let source: string; switch (specifier) { case "react": - source = ` - const R = globalThis.__paperclipPluginBridge__?.react; - export default R; - const { useState, useEffect, useCallback, useMemo, useRef, useContext, - createContext, createElement, Fragment, Component, forwardRef, - memo, lazy, Suspense, StrictMode, cloneElement, Children, - isValidElement, createRef } = R; - export { useState, useEffect, useCallback, useMemo, useRef, useContext, - createContext, createElement, Fragment, Component, forwardRef, - memo, lazy, Suspense, StrictMode, cloneElement, Children, - isValidElement, createRef }; - `; + source = createReactShimSource(ReactModule); break; case "react/jsx-runtime": source = ` @@ -900,4 +908,5 @@ export function _resetPluginModuleLoader(): void { } export const _applyJsxRuntimeKeyForTests = applyJsxRuntimeKey; +export const _createReactShimSourceForTests = createReactShimSource; export const _rewriteBareSpecifiersForTests = rewriteBareSpecifiers;