Files
PaperClipAI/packages/shared/src/validators/execution-workspace.ts
T
Nicky LeachandPaperclip f17202b571 Add execution workspace branch reconciliation route (#9170)
## Thinking Path

> - Paperclip is an open-source app that lets teams run AI agents for
work tasks; each agent session uses an execution workspace — a git
checkout — to track the agent's active code state.
> - Every execution workspace has an expected target branch
(`PAPERCLIP_WORKSPACE_BRANCH`). The workspace git HEAD should always
point to that branch so agents commit in the right place.
> - When workspace git HEAD diverges from the expected branch — for
example after a harness branch-name fix or an accidental `checkout -b`
during a CI-retrigger — the discrepancy must be corrected before agents
can continue safely.
> - Operators (board users) need a controlled, audited path to reconcile
a workspace's live branch back to the expected target, with an override
escape-hatch for cases where the normal forward path is blocked.
> - This pull request adds a board-only `POST
/api/execution-workspaces/:id/reconcile-branch` service operation and
route that validates safety preconditions, resolves matching
recovery-action fingerprints, posts source-issue audit comments, and
records the reconciliation outcome.
> - The benefit is that operators can correct branch divergence through
the API with a full audit trail, instead of via raw database edits.

## Linked Issues or Issue Description

No public GitHub issue exists for this change. Context below follows the
feature-request template format.

**Subsystem affected**

server/ — REST API & orchestration services; packages/shared — request
validation.

**Problem or motivation**

Execution workspaces have an expected branch record that must match the
checked-out worktree branch. When the live git branch and stored branch
record drift apart, operators currently lack a first-class, audited API
to reconcile the record. The fallback is manual database repair or
workspace replacement, both of which are risky and hard to audit.

**Proposed solution**

Add a board-only execution workspace branch reconciliation operation.
`forward` mode re-inspects the server-side git state and only updates
the branch record when the stored branch is an ancestor of the
checked-out branch. `override` mode is a break-glass path that requires
board access and an operator reason. Both modes require a clean, idle
workspace, write audit details, post a source-issue audit comment, and
resolve the matching workspace-validation recovery action.

**Alternatives considered**

Manual database edit (no durable audit trail and easy to mistype),
recreating the workspace (heavier operational disruption), or trusting
client-supplied ancestry evidence (unsafe because the server must verify
the git state itself).

**Roadmap alignment**

This is incremental hardening for execution-workspace recovery and
operator controls. It does not duplicate a public roadmap item.

**Additional context**

The endpoint is intended for operator recovery, not normal agent control
flow, so the generated OpenAPI metadata and runtime route both classify
it as board-only.
## What Changed

- Added `reconcileExecutionWorkspaceBranchSchema` discriminated-union
validator (`forward` with optional reason, `override` requiring a
non-empty reason string) to
`packages/shared/src/validators/execution-workspace.ts`
- Exported `ReconcileExecutionWorkspaceBranch` type and the new schema
from the shared package index
- Added board-only reconcile-branch service operation in the execution
workspaces service: safety checks, recovery-action fingerprint
resolution, source-issue audit comment, and outcome recording
- Added clean-worktree and stopped-runtime-service preconditions before
branch-record mutation.
- Marked the reconcile route as board-only in OpenAPI generated auth
metadata.
- Added `POST /api/execution-workspaces/:id/reconcile-branch` route
wired to the new service operation with board-permission gate
- Extended `execution-workspaces-routes.test.ts` and
`execution-workspaces-service.test.ts` to cover: safety-check rejection,
override-reason validation, audit-comment posting, and recovery-action
fingerprint resolution (2 files / 19 tests)

## Verification

```sh
pnpm --filter @paperclipai/shared typecheck
pnpm --filter @paperclipai/server typecheck
pnpm exec vitest run server/src/__tests__/execution-workspaces-routes.test.ts server/src/__tests__/execution-workspaces-service.test.ts
pnpm exec vitest run server/src/__tests__/execution-workspaces-service.test.ts server/src/__tests__/openapi-routes.test.ts
```

## Risks

- **Board-only gate:** the operation is gated behind the board
permission; no agent can trigger it without operator authorization.
- **Override requires reason:** the `override` mode requires a non-empty
reason string so every bypass is audited.
- **Idempotent recovery-action resolution:** re-running with the same
fingerprint is safe; duplicate resolution is a no-op.
- **No execution-state mutation:** the route records a reconciliation
intent and updates the branch record; it does not restart the workspace
or modify running agent state.
- Overall risk: **low**.

## Model Used

- Provider: Anthropic
- Model ID: `claude-sonnet-4-6` (Claude Sonnet 4.6)
- Context window: 200 K tokens
- Capabilities: tool use, code execution, multi-turn context

Follow-up safety commit:
- Provider: OpenAI
- Model ID: `codex` / GPT-5 with tool use 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
(`feat/execution-workspace-branch-reconciliation-route`) 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>
2026-07-07 13:41:23 -07:00

168 lines
6.3 KiB
TypeScript

import { z } from "zod";
import {
WORKSPACE_OVERVIEW_DEFAULT_LIMIT,
WORKSPACE_OVERVIEW_MAX_LIMIT,
} from "../constants.js";
export const executionWorkspaceStatusSchema = z.enum([
"active",
"idle",
"in_review",
"archived",
"cleanup_failed",
]);
const workspaceOverviewStatusFilterSchema = z.preprocess((value) => {
if (value === undefined || value === null) return undefined;
const rawValues = Array.isArray(value) ? value : [value];
const statuses = rawValues.flatMap((entry) => {
if (typeof entry !== "string") return [];
return entry.split(",").map((part) => part.trim()).filter(Boolean);
});
return statuses.length > 0 ? statuses : undefined;
}, z.array(executionWorkspaceStatusSchema).optional());
export const workspaceOverviewQuerySchema = z.object({
projectId: z.string().uuid().optional(),
status: workspaceOverviewStatusFilterSchema,
limit: z.coerce.number().int().min(1).max(WORKSPACE_OVERVIEW_MAX_LIMIT).optional().default(WORKSPACE_OVERVIEW_DEFAULT_LIMIT),
offset: z.coerce.number().int().min(0).optional().default(0),
}).strict();
export const executionWorkspaceConfigSchema = z.object({
environmentId: z.string().uuid().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
cleanupCommand: z.string().optional().nullable(),
workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(),
desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(),
serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(),
}).strict();
export const workspaceRuntimeControlTargetSchema = z.object({
workspaceCommandId: z.string().min(1).optional().nullable(),
runtimeServiceId: z.string().uuid().optional().nullable(),
serviceIndex: z.number().int().nonnegative().optional().nullable(),
}).strict();
export const executionWorkspaceCloseReadinessStateSchema = z.enum([
"ready",
"ready_with_warnings",
"blocked",
]);
export const executionWorkspaceCloseActionKindSchema = z.enum([
"archive_record",
"stop_runtime_services",
"cleanup_command",
"teardown_command",
"git_worktree_remove",
"git_branch_delete",
"remove_local_directory",
]);
export const executionWorkspaceCloseActionSchema = z.object({
kind: executionWorkspaceCloseActionKindSchema,
label: z.string(),
description: z.string(),
command: z.string().nullable(),
}).strict();
export const executionWorkspaceCloseLinkedIssueSchema = z.object({
id: z.string().uuid(),
identifier: z.string().nullable(),
title: z.string(),
status: z.string(),
isTerminal: z.boolean(),
}).strict();
export const executionWorkspaceCloseGitReadinessSchema = z.object({
repoRoot: z.string().nullable(),
workspacePath: z.string().nullable(),
branchName: z.string().nullable(),
baseRef: z.string().nullable(),
hasDirtyTrackedFiles: z.boolean(),
hasUntrackedFiles: z.boolean(),
dirtyEntryCount: z.number().int().nonnegative(),
untrackedEntryCount: z.number().int().nonnegative(),
aheadCount: z.number().int().nonnegative().nullable(),
behindCount: z.number().int().nonnegative().nullable(),
isMergedIntoBase: z.boolean().nullable(),
createdByRuntime: z.boolean(),
}).strict();
export const workspaceRuntimeServiceSchema = z.object({
id: z.string(),
companyId: z.string().uuid(),
projectId: z.string().uuid().nullable(),
projectWorkspaceId: z.string().uuid().nullable(),
executionWorkspaceId: z.string().uuid().nullable(),
issueId: z.string().uuid().nullable(),
scopeType: z.enum(["project_workspace", "execution_workspace", "run", "agent"]),
scopeId: z.string().nullable(),
serviceName: z.string(),
status: z.enum(["starting", "running", "stopped", "failed"]),
lifecycle: z.enum(["shared", "ephemeral"]),
reuseKey: z.string().nullable(),
command: z.string().nullable(),
cwd: z.string().nullable(),
port: z.number().int().nullable(),
url: z.string().nullable(),
provider: z.enum(["local_process", "adapter_managed"]),
providerRef: z.string().nullable(),
ownerAgentId: z.string().uuid().nullable(),
startedByRunId: z.string().uuid().nullable(),
lastUsedAt: z.coerce.date(),
startedAt: z.coerce.date(),
stoppedAt: z.coerce.date().nullable(),
stopPolicy: z.record(z.string(), z.unknown()).nullable(),
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]),
configIndex: z.number().int().nonnegative().nullable().optional(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
}).strict();
export const executionWorkspaceCloseReadinessSchema = z.object({
workspaceId: z.string().uuid(),
state: executionWorkspaceCloseReadinessStateSchema,
blockingReasons: z.array(z.string()),
warnings: z.array(z.string()),
linkedIssues: z.array(executionWorkspaceCloseLinkedIssueSchema),
plannedActions: z.array(executionWorkspaceCloseActionSchema),
isDestructiveCloseAllowed: z.boolean(),
isSharedWorkspace: z.boolean(),
isProjectPrimaryWorkspace: z.boolean(),
git: executionWorkspaceCloseGitReadinessSchema.nullable(),
runtimeServices: z.array(workspaceRuntimeServiceSchema),
}).strict();
export const updateExecutionWorkspaceSchema = z.object({
name: z.string().min(1).optional(),
cwd: z.string().optional().nullable(),
repoUrl: z.string().optional().nullable(),
baseRef: z.string().optional().nullable(),
branchName: z.string().optional().nullable(),
providerRef: z.string().optional().nullable(),
status: executionWorkspaceStatusSchema.optional(),
cleanupEligibleAt: z.string().datetime().optional().nullable(),
cleanupReason: z.string().optional().nullable(),
config: executionWorkspaceConfigSchema.optional().nullable(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
}).strict();
const branchReconcileReasonSchema = z.string().trim().min(1);
export const reconcileExecutionWorkspaceBranchSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("forward"),
reason: branchReconcileReasonSchema.optional().nullable(),
}).strict(),
z.object({
mode: z.literal("override"),
reason: branchReconcileReasonSchema,
}).strict(),
]);
export type UpdateExecutionWorkspace = z.infer<typeof updateExecutionWorkspaceSchema>;
export type ReconcileExecutionWorkspaceBranch = z.infer<typeof reconcileExecutionWorkspaceBranchSchema>;
export type WorkspaceOverviewQuery = z.infer<typeof workspaceOverviewQuerySchema>;