[codex] Add workspace diff viewer plugin (#6071)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-18 08:50:06 -05:00 committed by GitHub
parent 242a2c2f2b
commit 5071c4c776
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 4119 additions and 71 deletions

View file

@ -55,6 +55,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"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"],

View file

@ -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<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readProviderMetadata = (metadata: Record<string, unknown> | 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<Awaited<ReturnType<typeof executionWorkspaces.getById>>>,
): 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 = <T extends { companyId: string | null | undefined }>(
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);

View file

@ -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,