mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## 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>
168 lines
6.3 KiB
TypeScript
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>;
|