Files
PaperClipAI/server/src/services/workspace-realization.ts
T
417336f8be fix(workspaces): attach PR preparation to existing branches (#11703)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Execution workspaces isolate an agent task from the primary
checkout.
> - Pull request preparation can need a branch that already contains
completed work.
> - The workspace policy could not require an exact existing branch.
> - Workspace cleanup also treated worktree creation as branch
ownership.
> - This pull request adds an exact existing-branch policy and separate
branch ownership metadata.
> - The benefit is safe pull request preparation that preserves every
existing commit and operator-owned branch.

## Linked Issues or Issue Description

**What happened?**

A pull request preparation run could not pin its execution workspace to
an exact existing branch. Workspace reuse and cleanup could also confuse
worktree creation with branch ownership.

**Expected behavior**

The run must attach only to the requested branch in an isolated Git
worktree. It must fail if the branch is missing, busy, or inconsistent.
Cleanup must not delete a branch that Paperclip does not own.

**Steps to reproduce**

1. Create a branch that contains completed work.
2. Configure a pull request preparation task to use that branch.
3. Start the task and observe that the prior policy cannot require the
exact branch.

**Paperclip version or commit**

This behavior reproduces on the base revision before this pull request.

**Deployment mode**

Local development with isolated Git worktrees.

## What Changed

- Add `existingBranch` to the execution workspace policy and shared
validation contracts.
- Require `existingBranch` to use an isolated Git worktree and reject
conflicting branch templates.
- Attach to the exact branch without creating, renaming, resetting, or
deleting it.
- Track branch ownership separately from worktree creation and use that
ownership during cleanup.
- Return HTTP 422 for invalid existing-branch settings on every
issue-producing route.
- Add a bounded repair script for existing pull request preparation
tasks.
- Add focused policy, route, heartbeat, runtime, and ready-comment
tests.
- Document the exact-branch behavior and safety rules.

## Verification

- `pnpm exec vitest run
server/src/__tests__/execution-workspace-policy.test.ts
server/src/__tests__/heartbeat-workspace-session.test.ts
server/src/__tests__/issue-existing-branch-validation-status.test.ts
server/src/__tests__/workspace-runtime.test.ts
server/src/services/workspace-runtime-exposure.test.ts
server/src/services/workspace-runtime-ready-comment.test.ts` passed 335
tests.
- `pnpm -r typecheck` passed for all workspace projects.
- `pnpm test:run` passed 4,431 tests. Two unrelated embedded-Postgres
setup hooks timed out under aggregate load. Their isolated rerun passed
74 tests.
- `pnpm build` passed for all workspace projects.
- The two review regressions passed with 139 unrelated tests skipped.
- All latest-head CI gates passed after one unrelated timing-sensitive
test passed on rerun.
- Greptile scored the latest head 5/5 with no unresolved review threads.

## Risks

- Invalid workspace settings now return HTTP 422 instead of a generic
validation response.
- The exact branch must already exist and must not be checked out by
another worktree.
- The new policy fails closed when it cannot prove branch identity or
ownership.
- This change has no database migration.

> 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 from the GPT-5 family assisted with this change. The
runtime did not expose its exact deployment ID or context window. The
agent used high-reasoning mode, repository tools, shell execution, and
code execution.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 17:23:18 -05:00

360 lines
14 KiB
TypeScript

import type {
Environment,
EnvironmentLease,
ExecutionWorkspaceConfig,
WorkspaceRealizationRecord,
WorkspaceRealizationRequest,
} from "@paperclipai/shared";
import type { RealizedExecutionWorkspace } from "./workspace-runtime.js";
function parseObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.map(readString).filter((entry): entry is string => entry !== null)
: [];
}
function readPathAliases(value: unknown): Array<{ path: string; target: string }> {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const parsed = parseObject(entry);
const aliasPath = readString(parsed.path);
const target = readString(parsed.target);
return aliasPath && target ? [{ path: aliasPath, target }] : [];
});
}
// Read the additional referenced (mentioned) project sources. Legacy payloads omit the field, so
// this defaults to an empty array. Each source needs a localPath; entries without one are dropped.
function readAdditionalSources(
value: unknown,
): NonNullable<WorkspaceRealizationRequest["additionalSources"]> {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const parsed = parseObject(entry);
const localPath = readString(parsed.localPath);
if (!localPath) return [];
return [
{
localPath,
projectId: readString(parsed.projectId),
projectWorkspaceId: readString(parsed.projectWorkspaceId),
repoUrl: readString(parsed.repoUrl),
repoRef: readString(parsed.repoRef),
},
];
});
}
export function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRequest | null {
const parsed = parseObject(value);
if (parsed.version !== 1) return null;
const source = parseObject(parsed.source);
const runtimeOverlay = parseObject(parsed.runtimeOverlay);
const localPath = readString(source.localPath);
const companyId = readString(parsed.companyId);
const environmentId = readString(parsed.environmentId);
const heartbeatRunId = readString(parsed.heartbeatRunId);
const adapterType = readString(parsed.adapterType);
if (!localPath || !companyId || !environmentId || !heartbeatRunId || !adapterType) return null;
return {
version: 1,
adapterType,
companyId,
environmentId,
executionWorkspaceId: readString(parsed.executionWorkspaceId),
issueId: readString(parsed.issueId),
heartbeatRunId,
requestedMode: readString(parsed.requestedMode),
source: {
kind:
source.kind === "task_session" || source.kind === "agent_home"
? source.kind
: "project_primary",
localPath,
projectId: readString(source.projectId),
projectWorkspaceId: readString(source.projectWorkspaceId),
repoUrl: readString(source.repoUrl),
repoRef: readString(source.repoRef),
strategy: source.strategy === "git_worktree" ? "git_worktree" : "project_primary",
branchName: readString(source.branchName),
worktreePath: readString(source.worktreePath),
},
additionalSources: readAdditionalSources(parsed.additionalSources),
runtimeOverlay: {
provisionCommand: readString(runtimeOverlay.provisionCommand),
runtimeProvisionCommand: readString(runtimeOverlay.runtimeProvisionCommand),
teardownCommand: readString(runtimeOverlay.teardownCommand),
cleanupCommand: readString(runtimeOverlay.cleanupCommand),
workspaceRuntime: Object.keys(parseObject(runtimeOverlay.workspaceRuntime)).length > 0
? parseObject(runtimeOverlay.workspaceRuntime)
: null,
},
};
}
export function buildWorkspaceRealizationRequest(input: {
adapterType: string;
companyId: string;
environmentId: string;
executionWorkspaceId: string | null;
issueId: string | null;
heartbeatRunId: string;
requestedMode: string | null;
workspace: RealizedExecutionWorkspace;
workspaceConfig: ExecutionWorkspaceConfig | null;
}): WorkspaceRealizationRequest {
return {
version: 1,
adapterType: input.adapterType,
companyId: input.companyId,
environmentId: input.environmentId,
executionWorkspaceId: input.executionWorkspaceId,
issueId: input.issueId,
heartbeatRunId: input.heartbeatRunId,
requestedMode: input.requestedMode,
source: {
kind: input.workspace.source,
localPath: input.workspace.cwd,
projectId: input.workspace.projectId,
projectWorkspaceId: input.workspace.workspaceId,
repoUrl: input.workspace.repoUrl,
repoRef: input.workspace.repoRef,
strategy: input.workspace.strategy,
branchName: input.workspace.branchName,
worktreePath: input.workspace.worktreePath,
},
// The additional (referenced) sources carry the read-only referenced-project workspaces. Run
// preparation resolves them for a local execution target only and exposes each local path to
// the agent through the workspace-hints channel (`PAPERCLIP_WORKSPACES_JSON`). A remote target
// never receives a referenced source: run preparation skips referenced-project resolution on a
// remote target, so this array is empty there. The `sync` block below therefore realizes only
// the anchor source; a remote-transport sync of the referenced trees is not implemented yet.
additionalSources: (input.workspace.additionalWorkspaces ?? []).map((additional) => ({
localPath: additional.cwd,
projectId: additional.projectId,
projectWorkspaceId: additional.workspaceId,
repoUrl: additional.repoUrl,
repoRef: additional.repoRef,
})),
runtimeOverlay: {
provisionCommand: input.workspaceConfig?.provisionCommand ?? null,
runtimeProvisionCommand: input.workspaceConfig?.runtimeProvisionCommand ?? null,
teardownCommand: input.workspaceConfig?.teardownCommand ?? null,
cleanupCommand: input.workspaceConfig?.cleanupCommand ?? null,
workspaceRuntime: input.workspaceConfig?.workspaceRuntime ?? null,
},
};
}
export function buildWorkspaceRealizationRecord(input: {
environment: Environment;
lease: EnvironmentLease;
request: WorkspaceRealizationRequest;
realizedCwd?: string | null;
providerMetadata?: Record<string, unknown> | null;
}): WorkspaceRealizationRecord {
const leaseMetadata = input.lease.metadata ?? {};
const providerMetadata = input.providerMetadata ?? {};
const transport =
input.environment.driver === "ssh" || input.environment.driver === "sandbox" || input.environment.driver === "plugin"
? input.environment.driver
: "local";
const remotePath =
readString(providerMetadata.remoteCwd) ??
readString(leaseMetadata.remoteCwd) ??
readString(providerMetadata.remotePath) ??
null;
const host = readString(leaseMetadata.host);
const port = readNumber(leaseMetadata.port);
const username = readString(leaseMetadata.username);
const sandboxId = readString(leaseMetadata.sandboxId) ?? readString(providerMetadata.sandboxId);
const realizationMetadata = {
...parseObject(leaseMetadata.workspaceRealization),
...parseObject(providerMetadata.workspaceRealization),
...providerMetadata,
};
const mode = realizationMetadata.mode === "in_place" || realizationMetadata.realizationMode === "in_place"
? "in_place" as const
: "copy" as const;
const authoritativeRoot =
readString(realizationMetadata.authoritativeRoot) ??
(mode === "in_place" ? remotePath : null) ??
input.request.source.localPath;
const pathAliases = readPathAliases(realizationMetadata.pathAliases ?? realizationMetadata.workspaceAliases);
const outboundRestorePaths = readStringArray(realizationMetadata.outboundRestorePaths);
const sync = (() => {
if (mode === "in_place" || transport === "local") {
return {
strategy: "none" as const,
prepare: "Use the realized local execution workspace directly.",
syncBack: null,
};
}
if (transport === "ssh") {
return {
strategy: "ssh_git_import_export" as const,
prepare: "Import the local git workspace to the remote SSH workspace before adapter execution.",
syncBack: "Export remote SSH workspace changes back to the local execution workspace after adapter execution.",
};
}
if (transport === "sandbox") {
return {
strategy: "sandbox_archive_upload_download" as const,
prepare: "Upload a workspace archive into the sandbox filesystem before adapter execution.",
syncBack: "Download a workspace archive from the sandbox and mirror it back locally after adapter execution.",
};
}
return {
strategy: "provider_defined" as const,
prepare: "Delegate workspace materialization to the plugin environment driver.",
syncBack: "Delegate result synchronization to the plugin environment driver.",
};
})();
const provider =
input.lease.provider ??
(transport === "ssh" ? "ssh" : transport === "local" ? "local" : null);
const localPath = input.request.source.localPath;
const summary =
transport === "local"
? `Local workspace realized at ${localPath}.`
: transport === "ssh"
? `SSH workspace realized at ${username ?? "user"}@${host ?? "host"}:${port ?? 22}:${remotePath ?? input.request.source.localPath}.`
: transport === "sandbox"
? `Sandbox workspace realized at ${remotePath ?? "/"}${sandboxId ? ` in ${sandboxId}` : ""}.`
: `Plugin workspace realized at ${input.realizedCwd ?? remotePath ?? localPath}.`;
return {
version: 1,
mode,
authoritativeRoot,
pathAliases,
outboundRestorePaths,
transport,
provider,
environmentId: input.environment.id,
leaseId: input.lease.id,
providerLeaseId: input.lease.providerLeaseId,
local: {
path: localPath,
source: input.request.source.kind,
strategy: input.request.source.strategy,
projectId: input.request.source.projectId,
projectWorkspaceId: input.request.source.projectWorkspaceId,
repoUrl: input.request.source.repoUrl,
repoRef: input.request.source.repoRef,
branchName: input.request.source.branchName,
worktreePath: input.request.source.worktreePath,
},
additional: (input.request.additionalSources ?? []).map((additional) => ({
path: additional.localPath,
projectId: additional.projectId,
projectWorkspaceId: additional.projectWorkspaceId,
repoUrl: additional.repoUrl,
repoRef: additional.repoRef,
})),
remote: {
path: remotePath,
...(host ? { host } : {}),
...(port ? { port } : {}),
...(username ? { username } : {}),
...(sandboxId ? { sandboxId } : {}),
},
sync,
bootstrap: {
command: input.request.runtimeOverlay.provisionCommand,
},
rebuild: {
executionWorkspaceId: input.request.executionWorkspaceId,
mode: input.request.requestedMode,
repoUrl: input.request.source.repoUrl,
repoRef: input.request.source.repoRef,
localPath,
remotePath,
providerLeaseId: input.lease.providerLeaseId,
metadata: {
source: input.request.source,
runtimeOverlay: input.request.runtimeOverlay,
environmentDriver: input.environment.driver,
provider,
providerMetadata,
},
},
summary,
};
}
/**
* Build the workspace-realization record from the run request. The server owns the record;
* a driver realize handler (built-in or plugin) returns only a realized cwd and provider
* metadata. Every `realizeWorkspace` exit must route through this helper, so the record carries
* the referenced (mentioned) project sources in `additional`. The adapter reads `additional` to
* stage each referenced tree into the target; a realize exit that returns a raw provider result
* without this helper drops the mentioned projects.
*/
export function buildWorkspaceRealizationRecordFromDriverInput(input: {
environment: Environment;
lease: EnvironmentLease;
workspace: {
localPath?: string;
remotePath?: string;
mode?: string;
metadata?: Record<string, unknown>;
};
cwd?: string | null;
providerMetadata?: Record<string, unknown> | null;
}): WorkspaceRealizationRecord {
const request =
readWorkspaceRealizationRequest(input.workspace.metadata?.workspaceRealizationRequest) ??
readWorkspaceRealizationRequest(input.workspace.metadata?.request) ??
buildWorkspaceRealizationRequest({
adapterType: "unknown",
companyId: input.lease.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.lease.executionWorkspaceId,
issueId: input.lease.issueId,
heartbeatRunId: input.lease.heartbeatRunId ?? "unknown",
requestedMode: input.workspace.mode ?? null,
workspace: {
baseCwd: input.workspace.localPath ?? input.cwd ?? input.workspace.remotePath ?? "/",
source: "task_session",
projectId: null,
workspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
cwd: input.workspace.localPath ?? input.cwd ?? input.workspace.remotePath ?? "/",
branchName: null,
worktreePath: null,
warnings: [],
created: false,
branchCreatedByRuntime: false,
},
workspaceConfig: null,
});
return buildWorkspaceRealizationRecord({
environment: input.environment,
lease: input.lease,
request,
realizedCwd: input.cwd ?? null,
providerMetadata: input.providerMetadata,
});
}