From 3c66683169b5f2c8da68d7a78c05748c2d8e799e Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 30 Mar 2026 08:26:14 -0500 Subject: [PATCH 1/4] Fix execution workspace reuse and slugify worktrees Co-Authored-By: Paperclip --- .../heartbeat-workspace-session.test.ts | 46 ++++++ .../src/__tests__/workspace-runtime.test.ts | 37 +++++ server/src/services/heartbeat.ts | 131 ++++++++++++------ server/src/services/workspace-runtime.ts | 8 +- 4 files changed, 176 insertions(+), 46 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index ca23d907..47d37c53 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -4,6 +4,7 @@ import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-lo import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { applyPersistedExecutionWorkspaceConfig, + buildRealizedExecutionWorkspaceFromPersisted, buildExplicitResumeSessionOverride, formatRuntimeWorkspaceWarningLog, prioritizeProjectWorkspaceCandidatesForRun, @@ -154,6 +155,51 @@ describe("applyPersistedExecutionWorkspaceConfig", () => { }); }); +describe("buildRealizedExecutionWorkspaceFromPersisted", () => { + it("reuses the persisted execution workspace path instead of deriving a new worktree", () => { + const result = buildRealizedExecutionWorkspaceFromPersisted({ + base: buildResolvedWorkspace({ + cwd: "/tmp/project-primary", + repoRef: "main", + }), + workspace: { + id: "execution-workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + sourceIssueId: "issue-1", + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "PAP-880-thumbs-capture-for-evals-feature", + status: "active", + cwd: "/tmp/reused-worktree", + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "PAP-880-thumbs-capture-for-evals-feature", + providerType: "git_worktree", + providerRef: "/tmp/reused-worktree", + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date(), + openedAt: new Date(), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + expect(result.created).toBe(false); + expect(result.strategy).toBe("git_worktree"); + expect(result.cwd).toBe("/tmp/reused-worktree"); + expect(result.worktreePath).toBe("/tmp/reused-worktree"); + expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature"); + expect(result.source).toBe("task_session"); + }); +}); + describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => { it("removes workspace runtime before heartbeat execution", () => { const input = { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 1af2eea1..573013be 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -247,6 +247,43 @@ describe("realizeExecutionWorkspace", () => { expect(second.branchName).toBe(first.branchName); }); + it("slugifies unsafe issue titles for branch names and worktree folders", async () => { + const repoRoot = await createTempRepo(); + + const realized = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-unsafe", + identifier: "PAP-991", + title: "there should be a setting for the allowance of thumbs up / thumbs down data; `rm -rf`", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(realized.branchName).toBe( + "PAP-991-there-should-be-a-setting-for-the-allowance-of-thumbs-up-thumbs-down-data-rm-rf", + ); + expect(realized.branchName?.includes("/")).toBe(false); + expect(path.basename(realized.cwd)).toBe(realized.branchName); + }); + it("runs a configured provision command inside the derived worktree", async () => { const repoRoot = await createTempRepo(); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index bc66f399..4ab87852 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4,7 +4,7 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import type { BillingType, ExecutionWorkspaceConfig } from "@paperclipai/shared"; +import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared"; import { agents, agentRuntimeState, @@ -37,6 +37,8 @@ import { persistAdapterManagedRuntimeServices, realizeExecutionWorkspace, releaseRuntimeServicesForRun, + type ExecutionWorkspaceInput, + type RealizedExecutionWorkspace, sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; @@ -109,6 +111,32 @@ export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record): Partial | null { const strategy = parseObject(config.workspaceStrategy); const snapshot: Partial = {}; @@ -2085,7 +2113,7 @@ export function heartbeatService(db: Db) { (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ?? normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)); const config = parseObject(agent.adapterConfig); - const executionWorkspaceMode = resolveExecutionWorkspaceMode({ + const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, @@ -2094,15 +2122,8 @@ export function heartbeatService(db: Db) { agent, context, previousSessionParams, - { useProjectWorkspace: executionWorkspaceMode !== "agent_default" }, + { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" }, ); - const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({ - agentConfig: config, - projectPolicy: projectExecutionWorkspacePolicy, - issueSettings: issueExecutionWorkspaceSettings, - mode: executionWorkspaceMode, - legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, - }); const issueRef = issueContext ? { id: issueContext.id, @@ -2116,10 +2137,32 @@ export function heartbeatService(db: Db) { : null; const existingExecutionWorkspace = issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; + const shouldReuseExisting = + issueRef?.executionWorkspacePreference === "reuse_existing" && + existingExecutionWorkspace && + existingExecutionWorkspace.status !== "archived"; + const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace + ? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode) + : null; + const effectiveExecutionWorkspaceMode: ReturnType = + persistedExecutionWorkspaceMode === "isolated_workspace" || + persistedExecutionWorkspaceMode === "operator_branch" || + persistedExecutionWorkspaceMode === "agent_default" + ? persistedExecutionWorkspaceMode + : requestedExecutionWorkspaceMode; + const workspaceManagedConfig = shouldReuseExisting + ? { ...config } + : buildExecutionWorkspaceAdapterConfig({ + agentConfig: config, + projectPolicy: projectExecutionWorkspacePolicy, + issueSettings: issueExecutionWorkspaceSettings, + mode: requestedExecutionWorkspaceMode, + legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, + }); const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ config: workspaceManagedConfig, workspaceConfig: existingExecutionWorkspace?.config ?? null, - mode: executionWorkspaceMode, + mode: effectiveExecutionWorkspaceMode, }); const mergedConfig = issueAssigneeOverrides?.adapterConfig ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } @@ -2140,39 +2183,43 @@ export function heartbeatService(db: Db) { heartbeatRunId: run.id, executionWorkspaceId: existingExecutionWorkspace?.id ?? null, }); - const executionWorkspace = await realizeExecutionWorkspace({ - base: { - baseCwd: resolvedWorkspace.cwd, - source: resolvedWorkspace.source, - projectId: resolvedWorkspace.projectId, - workspaceId: resolvedWorkspace.workspaceId, - repoUrl: resolvedWorkspace.repoUrl, - repoRef: resolvedWorkspace.repoRef, - }, - config: runtimeConfig, - issue: issueRef, - agent: { - id: agent.id, - name: agent.name, - companyId: agent.companyId, - }, - recorder: workspaceOperationRecorder, - }); + const executionWorkspaceBase = { + baseCwd: resolvedWorkspace.cwd, + source: resolvedWorkspace.source, + projectId: resolvedWorkspace.projectId, + workspaceId: resolvedWorkspace.workspaceId, + repoUrl: resolvedWorkspace.repoUrl, + repoRef: resolvedWorkspace.repoRef, + } satisfies ExecutionWorkspaceInput; + const executionWorkspace = shouldReuseExisting && existingExecutionWorkspace + ? buildRealizedExecutionWorkspaceFromPersisted({ + base: executionWorkspaceBase, + workspace: existingExecutionWorkspace, + }) + : await realizeExecutionWorkspace({ + base: executionWorkspaceBase, + config: runtimeConfig, + issue: issueRef, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + recorder: workspaceOperationRecorder, + }); const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; - const shouldReuseExisting = - issueRef?.executionWorkspacePreference === "reuse_existing" && - existingExecutionWorkspace && - existingExecutionWorkspace.status !== "archived"; let persistedExecutionWorkspace = null; const nextExecutionWorkspaceMetadataBase = { ...(existingExecutionWorkspace?.metadata ?? {}), source: executionWorkspace.source, createdByRuntime: executionWorkspace.created, } as Record; - const nextExecutionWorkspaceMetadata = configSnapshot - ? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot) - : nextExecutionWorkspaceMetadataBase; + const nextExecutionWorkspaceMetadata = shouldReuseExisting + ? nextExecutionWorkspaceMetadataBase + : configSnapshot + ? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot) + : nextExecutionWorkspaceMetadataBase; try { persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { @@ -2193,11 +2240,11 @@ export function heartbeatService(db: Db) { projectWorkspaceId: resolvedProjectWorkspaceId, sourceIssueId: issueRef?.id ?? null, mode: - executionWorkspaceMode === "isolated_workspace" + requestedExecutionWorkspaceMode === "isolated_workspace" ? "isolated_workspace" - : executionWorkspaceMode === "operator_branch" + : requestedExecutionWorkspaceMode === "operator_branch" ? "operator_branch" - : executionWorkspaceMode === "agent_default" + : requestedExecutionWorkspaceMode === "agent_default" ? "adapter_managed" : "shared_workspace", strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary", @@ -2272,8 +2319,8 @@ export function heartbeatService(db: Db) { const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); const shouldSwitchIssueToExistingWorkspace = issueRef?.executionWorkspacePreference === "reuse_existing" || - executionWorkspaceMode === "isolated_workspace" || - executionWorkspaceMode === "operator_branch"; + requestedExecutionWorkspaceMode === "isolated_workspace" || + requestedExecutionWorkspaceMode === "operator_branch"; const nextIssuePatch: Record = {}; if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; @@ -2326,7 +2373,7 @@ export function heartbeatService(db: Db) { context.paperclipWorkspace = { cwd: executionWorkspace.cwd, source: executionWorkspace.source, - mode: executionWorkspaceMode, + mode: effectiveExecutionWorkspaceMode, strategy: executionWorkspace.strategy, projectId: executionWorkspace.projectId, workspaceId: executionWorkspace.workspaceId, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index dfece576..7f30d247 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -194,9 +194,9 @@ function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial 0 ? normalized : fallback; } @@ -231,9 +231,9 @@ function renderWorkspaceTemplate(template: string, input: { function sanitizeBranchName(value: string): string { return value .trim() - .replace(/[^A-Za-z0-9._/-]+/g, "-") + .replace(/[^A-Za-z0-9_-]+/g, "-") .replace(/-+/g, "-") - .replace(/^[-/.]+|[-/.]+$/g, "") + .replace(/^[-_]+|[-_]+$/g, "") .slice(0, 120) || "paperclip-work"; } From ec1210caaab8f2645c74b3555e0f2aaa1a0c9a24 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 30 Mar 2026 14:08:44 -0500 Subject: [PATCH 2/4] Preserve workspaces for follow-up issues Co-Authored-By: Paperclip --- packages/plugins/sdk/src/protocol.ts | 1 + packages/plugins/sdk/src/types.ts | 1 + packages/plugins/sdk/src/worker-rpc-host.ts | 1 + packages/shared/src/validators/issue.ts | 1 + server/src/__tests__/issues-service.test.ts | 280 ++++++++++++++++++ server/src/onboarding-assets/ceo/HEARTBEAT.md | 2 +- server/src/services/issues.ts | 107 ++++++- skills/paperclip/SKILL.md | 3 +- 8 files changed, 380 insertions(+), 16 deletions(-) diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 8a99bcba..a26bf5dc 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -579,6 +579,7 @@ export interface WorkerToHostMethods { projectId?: string; goalId?: string; parentId?: string; + inheritExecutionWorkspaceFromIssueId?: string; title: string; description?: string; priority?: string; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 06046983..51824651 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -872,6 +872,7 @@ export interface PluginIssuesClient { projectId?: string; goalId?: string; parentId?: string; + inheritExecutionWorkspaceFromIssueId?: string; title: string; description?: string; priority?: Issue["priority"]; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index df387490..20ca02fc 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -590,6 +590,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost projectId: input.projectId, goalId: input.goalId, parentId: input.parentId, + inheritExecutionWorkspaceFromIssueId: input.inheritExecutionWorkspaceFromIssueId, title: input.title, description: input.description, priority: input.priority, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 22ae43d2..e6d1a34a 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -32,6 +32,7 @@ export const createIssueSchema = z.object({ projectWorkspaceId: z.string().uuid().optional().nullable(), goalId: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), + inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(), title: z.string().min(1), description: z.string().optional().nullable(), status: z.enum(ISSUE_STATUSES).optional().default("backlog"), diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ee79d514..9543a614 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -6,15 +6,18 @@ import { companies, createDb, executionWorkspaces, + instanceSettings, issueComments, issueInboxArchives, issues, + projectWorkspaces, projects, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +import { instanceSettingsService } from "../services/instance-settings.ts"; import { issueService } from "../services/issues.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -43,8 +46,10 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await db.delete(activityLog); await db.delete(issues); await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(agents); + await db.delete(instanceSettings); await db.delete(companies); }); @@ -398,3 +403,278 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { ])); }); }); + +describeEmbeddedPostgres("issueService.create workspace inheritance", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + sharedWorkspaceKey: "workspace-key", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + providerType: "git_worktree", + providerRef: `/tmp/${executionWorkspaceId}`, + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + }); + + expect(child.parentId).toBe(parentIssueId); + expect(child.projectWorkspaceId).toBe(projectWorkspaceId); + expect(child.executionWorkspaceId).toBe(executionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }); + }); + + it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const parentProjectWorkspaceId = randomUUID(); + const parentExecutionWorkspaceId = randomUUID(); + const explicitProjectWorkspaceId = randomUUID(); + const explicitExecutionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values([ + { + id: parentProjectWorkspaceId, + companyId, + projectId, + name: "Parent workspace", + }, + { + id: explicitProjectWorkspaceId, + companyId, + projectId, + name: "Explicit workspace", + }, + ]); + + await db.insert(executionWorkspaces).values([ + { + id: parentExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Parent worktree", + status: "active", + providerType: "git_worktree", + }, + { + id: explicitExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: explicitProjectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Explicit shared workspace", + status: "active", + providerType: "local_fs", + }, + ]); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId: parentExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + projectWorkspaceId: explicitProjectWorkspaceId, + executionWorkspaceId: explicitExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "shared_workspace", + }, + }); + + expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId); + expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", + }); + }); + + it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const sourceIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "operator_branch", + strategyType: "git_worktree", + name: "Operator branch", + status: "active", + providerType: "git_worktree", + }); + + await db.insert(issues).values({ + id: sourceIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Source issue", + status: "todo", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "operator_branch", + }, + }); + + const followUp = await svc.create(companyId, { + projectId, + title: "Follow-up issue", + inheritExecutionWorkspaceFromIssueId: sourceIssueId, + }); + + expect(followUp.parentId).toBeNull(); + expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId); + expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId); + expect(followUp.executionWorkspacePreference).toBe("reuse_existing"); + expect(followUp.executionWorkspaceSettings).toEqual({ + mode: "operator_branch", + }); + }); +}); diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 161348a2..bc6273c7 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -37,7 +37,7 @@ If `PAPERCLIP_APPROVAL_ID` is set: ## 6. Delegation -- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. +- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue. - Use `paperclip-create-agent` skill when hiring new agents. - Assign work to the right agent for the job. diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 0e1defe3..b6bdb066 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -26,6 +26,7 @@ import { conflict, notFound, unprocessable } from "../errors.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseProjectExecutionWorkspacePolicy, } from "./execution-workspace-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; @@ -105,6 +106,11 @@ type IssueUserContextInput = { updatedAt: Date | string; }; type ProjectGoalReader = Pick; +type DbReader = Pick; +type IssueCreateInput = Omit & { + labelIds?: string[]; + inheritExecutionWorkspaceFromIssueId?: string | null; +}; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; @@ -131,6 +137,28 @@ async function getProjectDefaultGoalId( return row?.goalId ?? null; } +async function getWorkspaceInheritanceIssue( + db: DbReader, + companyId: string, + issueId: string, +) { + const issue = await db + .select({ + id: issues.id, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!issue) { + throw notFound("Workspace inheritance issue not found"); + } + return issue; +} + function touchedByUserCondition(companyId: string, userId: string) { return sql` ( @@ -487,8 +515,13 @@ export function issueService(db: Db) { } } - async function assertValidProjectWorkspace(companyId: string, projectId: string | null | undefined, projectWorkspaceId: string) { - const workspace = await db + async function assertValidProjectWorkspace( + companyId: string, + projectId: string | null | undefined, + projectWorkspaceId: string, + dbOrTx: DbReader = db, + ) { + const workspace = await dbOrTx .select({ id: projectWorkspaces.id, companyId: projectWorkspaces.companyId, @@ -504,8 +537,13 @@ export function issueService(db: Db) { } } - async function assertValidExecutionWorkspace(companyId: string, projectId: string | null | undefined, executionWorkspaceId: string) { - const workspace = await db + async function assertValidExecutionWorkspace( + companyId: string, + projectId: string | null | undefined, + executionWorkspaceId: string, + dbOrTx: DbReader = db, + ) { + const workspace = await dbOrTx .select({ id: executionWorkspaces.id, companyId: executionWorkspaces.companyId, @@ -869,9 +907,9 @@ export function issueService(db: Db) { create: async ( companyId: string, - data: Omit & { labelIds?: string[] }, + data: IssueCreateInput, ) => { - const { labelIds: inputLabelIds, ...issueData } = data; + const { labelIds: inputLabelIds, inheritExecutionWorkspaceFromIssueId, ...issueData } = data; const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; if (!isolatedWorkspacesEnabled) { delete issueData.executionWorkspaceId; @@ -887,21 +925,55 @@ export function issueService(db: Db) { if (data.assigneeUserId) { await assertAssignableUser(companyId, data.assigneeUserId); } - if (data.projectWorkspaceId) { - await assertValidProjectWorkspace(companyId, data.projectId, data.projectWorkspaceId); - } - if (data.executionWorkspaceId) { - await assertValidExecutionWorkspace(companyId, data.projectId, data.executionWorkspaceId); - } if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) { throw unprocessable("in_progress issues require an assignee"); } return db.transaction(async (tx) => { const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId); const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId); + let projectWorkspaceId = issueData.projectWorkspaceId ?? null; + let executionWorkspaceId = issueData.executionWorkspaceId ?? null; + let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null; let executionWorkspaceSettings = (issueData.executionWorkspaceSettings as Record | null | undefined) ?? null; - if (executionWorkspaceSettings == null && issueData.projectId) { + const workspaceInheritanceIssueId = inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null; + const hasExplicitExecutionWorkspaceOverride = + issueData.executionWorkspaceId !== undefined || + issueData.executionWorkspacePreference !== undefined || + issueData.executionWorkspaceSettings !== undefined; + if (workspaceInheritanceIssueId) { + const workspaceSource = await getWorkspaceInheritanceIssue(tx, companyId, workspaceInheritanceIssueId); + if (projectWorkspaceId == null && workspaceSource.projectWorkspaceId) { + projectWorkspaceId = workspaceSource.projectWorkspaceId; + } + if ( + isolatedWorkspacesEnabled && + !hasExplicitExecutionWorkspaceOverride && + workspaceSource.executionWorkspaceId + ) { + const sourceWorkspace = await tx + .select({ + id: executionWorkspaces.id, + mode: executionWorkspaces.mode, + }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, workspaceSource.executionWorkspaceId)) + .then((rows) => rows[0] ?? null); + if (sourceWorkspace) { + executionWorkspaceId = sourceWorkspace.id; + executionWorkspacePreference = "reuse_existing"; + executionWorkspaceSettings = { + ...((workspaceSource.executionWorkspaceSettings as Record | null | undefined) ?? {}), + mode: issueExecutionWorkspaceModeForPersistedWorkspace(sourceWorkspace.mode), + }; + } + } + } + if ( + executionWorkspaceSettings == null && + executionWorkspaceId == null && + issueData.projectId + ) { const project = await tx .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects) @@ -915,7 +987,6 @@ export function issueService(db: Db) { ), ) as Record | null; } - let projectWorkspaceId = issueData.projectWorkspaceId ?? null; if (!projectWorkspaceId && issueData.projectId) { const project = await tx .select({ @@ -935,6 +1006,12 @@ export function issueService(db: Db) { .then((rows) => rows[0]?.id ?? null); } } + if (projectWorkspaceId) { + await assertValidProjectWorkspace(companyId, issueData.projectId, projectWorkspaceId, tx); + } + if (executionWorkspaceId) { + await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx); + } const [company] = await tx .update(companies) .set({ issueCounter: sql`${companies.issueCounter} + 1` }) @@ -954,6 +1031,8 @@ export function issueService(db: Db) { defaultGoalId: defaultCompanyGoal?.id ?? null, }), ...(projectWorkspaceId ? { projectWorkspaceId } : {}), + ...(executionWorkspaceId ? { executionWorkspaceId } : {}), + ...(executionWorkspacePreference ? { executionWorkspacePreference } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), companyId, issueNumber, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 142ee63a..1d319ad3 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -85,7 +85,7 @@ Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`. Priority values: `critical`, `high`, `medium`, `low`. Other updatable fields: `title`, `description`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`. -**Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. Set `billingCode` for cross-team work. +**Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. ## Project Setup Workflow (CEO/Manager Common Path) @@ -147,6 +147,7 @@ If you are asked to install a skill for the company or an agent you MUST read: Resolve requesting user id from the triggering comment thread (`authorUserId`) when available; otherwise use the issue's `createdByUserId` if it matches the requester context. - **Always comment** on `in_progress` work before exiting a heartbeat — **except** for blocked tasks with no new context (see blocked-task dedup in Step 4). - **Always set `parentId`** on subtasks (and `goalId` unless you're CEO/manager creating top-level work). +- **Preserve workspace continuity for follow-ups.** Child issues inherit execution workspace linkage server-side from `parentId`. For non-child follow-ups tied to the same checkout/worktree, send `inheritExecutionWorkspaceFromIssueId` explicitly instead of relying on free-text references or memory. - **Never cancel cross-team tasks.** Reassign to your manager with a comment. - **Always update blocked issues explicitly.** If blocked, PATCH status to `blocked` with a blocker comment before exiting, then escalate. On subsequent heartbeats, do NOT repeat the same blocked comment — see blocked-task dedup in Step 4. - **@-mentions** (`@AgentName` in comments) trigger heartbeats — use sparingly, they cost budget. From 2b18fc4007206800f1a599d1e47c21a8ddc913e4 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 30 Mar 2026 14:09:07 -0500 Subject: [PATCH 3/4] Repair server workspace package links in worktrees Co-Authored-By: Paperclip --- scripts/ensure-workspace-package-links.ts | 113 ++++++++++++++++++++++ server/package.json | 9 +- server/src/adapters/utils.ts | 91 ++++++++++++----- 3 files changed, 185 insertions(+), 28 deletions(-) create mode 100644 scripts/ensure-workspace-package-links.ts diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts new file mode 100644 index 00000000..d2494545 --- /dev/null +++ b/scripts/ensure-workspace-package-links.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env -S node --import tsx +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { repoRoot } from "./dev-service-profile.ts"; + +type WorkspaceLinkMismatch = { + packageName: string; + expectedPath: string; + actualPath: string | null; +}; + +function readJsonFile(filePath: string): Record { + return JSON.parse(readFileSync(filePath, "utf8")) as Record; +} + +function resolveWorkspacePackagePath(packageName: string): string | null { + if (packageName === "@paperclipai/adapter-utils") { + return path.join(repoRoot, "packages", "adapter-utils"); + } + if (packageName === "@paperclipai/db") { + return path.join(repoRoot, "packages", "db"); + } + if (packageName === "@paperclipai/shared") { + return path.join(repoRoot, "packages", "shared"); + } + if (packageName === "@paperclipai/plugin-sdk") { + return path.join(repoRoot, "packages", "plugins", "sdk"); + } + if (packageName.startsWith("@paperclipai/adapter-")) { + return path.join(repoRoot, "packages", "adapters", packageName.slice("@paperclipai/adapter-".length)); + } + return null; +} + +function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] { + const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json")); + const dependencies = { + ...(serverPackageJson.dependencies as Record | undefined), + ...(serverPackageJson.devDependencies as Record | undefined), + }; + const mismatches: WorkspaceLinkMismatch[] = []; + + for (const [packageName, version] of Object.entries(dependencies)) { + if (typeof version !== "string" || !version.startsWith("workspace:")) continue; + + const expectedPath = resolveWorkspacePackagePath(packageName); + if (!expectedPath) continue; + + const linkPath = path.join(repoRoot, "server", "node_modules", ...packageName.split("/")); + const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null; + if (actualPath === path.resolve(expectedPath)) continue; + + mismatches.push({ + packageName, + expectedPath: path.resolve(expectedPath), + actualPath, + }); + } + + return mismatches; +} + +function runCommand(command: string, args: string[], cwd: string) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: process.env, + stdio: "inherit", + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `${command} ${args.join(" ")} failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}`, + ), + ); + }); + }); +} + +async function ensureServerWorkspaceLinksCurrent() { + const mismatches = findServerWorkspaceLinkMismatches(); + if (mismatches.length === 0) return; + + console.log("[paperclip] detected stale workspace package links for server; relinking dependencies..."); + for (const mismatch of mismatches) { + console.log( + `[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`, + ); + } + + const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + await runCommand( + pnpmBin, + ["install", "--force", "--config.confirmModulesPurge=false"], + repoRoot, + ); + + const remainingMismatches = findServerWorkspaceLinkMismatches(); + if (remainingMismatches.length === 0) return; + + throw new Error( + `Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`, + ); +} + +await ensureServerWorkspaceLinksCurrent(); diff --git a/server/package.json b/server/package.json index 0f7efa44..b2d17ad3 100644 --- a/server/package.json +++ b/server/package.json @@ -32,15 +32,16 @@ "skills" ], "scripts": { - "dev": "tsx src/index.ts", - "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", + "preflight:workspace-links": "tsx ../scripts/ensure-workspace-package-links.ts", + "dev": "pnpm run preflight:workspace-links && tsx src/index.ts", + "dev:watch": "pnpm run preflight:workspace-links && cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", - "build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", + "build": "pnpm run preflight:workspace-links && tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", "prepack": "pnpm run prepare:ui-dist", "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "start": "node dist/index.js", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + "typecheck": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index 3c85852b..d9201bb6 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -1,34 +1,77 @@ // Re-export everything from the shared adapter-utils/server-utils package. // This file is kept as a convenience shim so existing in-tree // imports (process/, http/, heartbeat.ts) don't need rewriting. +import type { ChildProcess } from "node:child_process"; import { logger } from "../middleware/logger.js"; -export { - type RunProcessResult, - runningProcesses, - MAX_CAPTURE_BYTES, - MAX_EXCERPT_BYTES, - parseObject, - asString, - asNumber, - asBoolean, - asStringArray, - parseJson, - appendWithCap, - resolvePathValue, - renderTemplate, - redactEnvForLogs, - buildInvocationEnvForLogs, - buildPaperclipEnv, - defaultPathForPlatform, - ensurePathInEnv, - ensureAbsoluteDirectory, - ensureCommandResolvable, - resolveCommandForLogs, -} from "@paperclipai/adapter-utils/server-utils"; +import * as serverUtils from "@paperclipai/adapter-utils/server-utils"; +export type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; + +type BuildInvocationEnvForLogsOptions = { + runtimeEnv?: NodeJS.ProcessEnv | Record; + includeRuntimeKeys?: string[]; + resolvedCommand?: string | null; + resolvedCommandEnvKey?: string; +}; + +export const runningProcesses: Map = + serverUtils.runningProcesses; +export const MAX_CAPTURE_BYTES = serverUtils.MAX_CAPTURE_BYTES; +export const MAX_EXCERPT_BYTES = serverUtils.MAX_EXCERPT_BYTES; +export const parseObject = serverUtils.parseObject; +export const asString = serverUtils.asString; +export const asNumber = serverUtils.asNumber; +export const asBoolean = serverUtils.asBoolean; +export const asStringArray = serverUtils.asStringArray; +export const parseJson = serverUtils.parseJson; +export const appendWithCap = serverUtils.appendWithCap; +export const resolvePathValue = serverUtils.resolvePathValue; +export const renderTemplate = serverUtils.renderTemplate; +export const redactEnvForLogs = serverUtils.redactEnvForLogs; +export const buildPaperclipEnv = serverUtils.buildPaperclipEnv; +export const defaultPathForPlatform = serverUtils.defaultPathForPlatform; +export const ensurePathInEnv = serverUtils.ensurePathInEnv; +export const ensureAbsoluteDirectory = serverUtils.ensureAbsoluteDirectory; +export const ensureCommandResolvable = serverUtils.ensureCommandResolvable; +export const resolveCommandForLogs = serverUtils.resolveCommandForLogs; + +export function buildInvocationEnvForLogs( + env: Record, + options: BuildInvocationEnvForLogsOptions = {}, +): Record { + const maybeBuildInvocationEnvForLogs = ( + serverUtils as typeof serverUtils & { + buildInvocationEnvForLogs?: ( + env: Record, + options?: BuildInvocationEnvForLogsOptions, + ) => Record; + } + ).buildInvocationEnvForLogs; + + if (typeof maybeBuildInvocationEnvForLogs === "function") { + return maybeBuildInvocationEnvForLogs(env, options); + } + + const merged: Record = { ...env }; + const runtimeEnv = options.runtimeEnv ?? {}; + + for (const key of options.includeRuntimeKeys ?? []) { + if (key in merged) continue; + const value = runtimeEnv[key]; + if (typeof value !== "string" || value.length === 0) continue; + merged[key] = value; + } + + const resolvedCommand = options.resolvedCommand?.trim(); + if (resolvedCommand) { + merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand; + } + + return redactEnvForLogs(merged); +} // Re-export runChildProcess with the server's pino logger wired in. -import { runChildProcess as _runChildProcess } from "@paperclipai/adapter-utils/server-utils"; import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; +const _runChildProcess = serverUtils.runChildProcess; export async function runChildProcess( runId: string, From 477ef78fedfd6a4f3f1d77085f67cdb3532185d3 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 30 Mar 2026 14:55:44 -0500 Subject: [PATCH 4/4] Address Greptile feedback on workspace reuse Co-Authored-By: Paperclip --- scripts/ensure-workspace-package-links.ts | 47 ++++++++++++------- .../heartbeat-workspace-session.test.ts | 38 +++++++++++++++ .../src/__tests__/workspace-runtime.test.ts | 34 ++++++++++++++ server/src/adapters/utils.ts | 1 + server/src/services/heartbeat.ts | 9 ++-- server/src/services/workspace-runtime.ts | 4 +- 6 files changed, 109 insertions(+), 24 deletions(-) diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts index d2494545..430ba589 100644 --- a/scripts/ensure-workspace-package-links.ts +++ b/scripts/ensure-workspace-package-links.ts @@ -1,6 +1,6 @@ #!/usr/bin/env -S node --import tsx import { spawn } from "node:child_process"; -import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import { repoRoot } from "./dev-service-profile.ts"; @@ -14,25 +14,36 @@ function readJsonFile(filePath: string): Record { return JSON.parse(readFileSync(filePath, "utf8")) as Record; } -function resolveWorkspacePackagePath(packageName: string): string | null { - if (packageName === "@paperclipai/adapter-utils") { - return path.join(repoRoot, "packages", "adapter-utils"); +function discoverWorkspacePackagePaths(rootDir: string): Map { + const packagePaths = new Map(); + const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]); + + function visit(dirPath: string) { + const packageJsonPath = path.join(dirPath, "package.json"); + if (existsSync(packageJsonPath)) { + const packageJson = readJsonFile(packageJsonPath); + if (typeof packageJson.name === "string" && packageJson.name.length > 0) { + packagePaths.set(packageJson.name, dirPath); + } + } + + for (const entry of readdirSync(dirPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (ignoredDirNames.has(entry.name)) continue; + visit(path.join(dirPath, entry.name)); + } } - if (packageName === "@paperclipai/db") { - return path.join(repoRoot, "packages", "db"); - } - if (packageName === "@paperclipai/shared") { - return path.join(repoRoot, "packages", "shared"); - } - if (packageName === "@paperclipai/plugin-sdk") { - return path.join(repoRoot, "packages", "plugins", "sdk"); - } - if (packageName.startsWith("@paperclipai/adapter-")) { - return path.join(repoRoot, "packages", "adapters", packageName.slice("@paperclipai/adapter-".length)); - } - return null; + + visit(path.join(rootDir, "packages")); + visit(path.join(rootDir, "server")); + visit(path.join(rootDir, "ui")); + visit(path.join(rootDir, "cli")); + + return packagePaths; } +const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot); + function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] { const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json")); const dependencies = { @@ -44,7 +55,7 @@ function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] { for (const [packageName, version] of Object.entries(dependencies)) { if (typeof version !== "string" || !version.startsWith("workspace:")) continue; - const expectedPath = resolveWorkspacePackagePath(packageName); + const expectedPath = workspacePackagePaths.get(packageName); if (!expectedPath) continue; const linkPath = path.join(repoRoot, "server", "node_modules", ...packageName.split("/")); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 47d37c53..5a1498f2 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -198,6 +198,44 @@ describe("buildRealizedExecutionWorkspaceFromPersisted", () => { expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature"); expect(result.source).toBe("task_session"); }); + + it("falls back to realization when the persisted workspace has no local path yet", () => { + const result = buildRealizedExecutionWorkspaceFromPersisted({ + base: buildResolvedWorkspace({ + cwd: "/tmp/project-primary", + repoRef: "main", + }), + workspace: { + id: "execution-workspace-2", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + sourceIssueId: "issue-2", + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "PAP-999-missing-provider-ref", + status: "active", + cwd: null, + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "feature/PAP-999-missing-provider-ref", + providerType: "git_worktree", + providerRef: null, + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date(), + openedAt: new Date(), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + expect(result).toBeNull(); + }); }); describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 573013be..6c98b2cf 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -284,6 +284,40 @@ describe("realizeExecutionWorkspace", () => { expect(path.basename(realized.cwd)).toBe(realized.branchName); }); + it("preserves intentional slashes and dots from the branch template", async () => { + const repoRoot = await createTempRepo(); + + const realized = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "release/{{issue.identifier}}.{{slug}}", + }, + }, + issue: { + id: "issue-template-safe", + identifier: "PAP-992", + title: "Hotfix / April.1", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(realized.branchName).toBe("release/PAP-992.hotfix-april-1"); + expect(path.basename(realized.cwd)).toBe("PAP-992.hotfix-april-1"); + }); + it("runs a configured provision command inside the derived worktree", async () => { const repoRoot = await createTempRepo(); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index d9201bb6..6dfccfcb 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -38,6 +38,7 @@ export function buildInvocationEnvForLogs( env: Record, options: BuildInvocationEnvForLogsOptions = {}, ): Record { + // TODO: Remove this fallback once @paperclipai/adapter-utils exports buildInvocationEnvForLogs everywhere we consume it. const maybeBuildInvocationEnvForLogs = ( serverUtils as typeof serverUtils & { buildInvocationEnvForLogs?: ( diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4ab87852..168db86b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -114,10 +114,10 @@ export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record