From fe8f46ddc738a3016d95a83568fedaaa577661b6 Mon Sep 17 00:00:00 2001 From: Jonathan Reyes Date: Fri, 11 Sep 2026 08:32:04 -0600 Subject: [PATCH] fix: keep project and execution workspaces consistent --- server/src/__tests__/issues-service.test.ts | 183 ++++++++++++++++++++ server/src/services/issues.ts | 87 +++++++++- 2 files changed, 263 insertions(+), 7 deletions(-) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 3b91c8856c..d1a1efd7f6 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -3612,6 +3612,189 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); + it("keeps explicit project workspace consistent across inherited create and update execution linkage", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const agentId = randomUUID(); + const checkoutRunId = randomUUID(); + const parentIssueId = randomUUID(); + const legacyParentIssueId = randomUUID(); + const dashboardProjectWorkspaceId = randomUUID(); + const dashboardExecutionWorkspaceId = randomUUID(); + const wwwProjectWorkspaceId = randomUUID(); + const wwwExecutionWorkspaceId = "00000000-0000-4000-8000-000000000090"; + + await db.insert(companies).values({ + id: companyId, + name: "FarmHub", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "API Engineer", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: checkoutRunId, + companyId, + agentId, + status: "running", + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Reliability", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values([ + { + id: dashboardProjectWorkspaceId, + companyId, + projectId, + name: "dashboard", + repoUrl: "https://github.com/thefarmhub/dashboard.git", + cwd: "/workspaces/dashboard", + }, + { + id: wwwProjectWorkspaceId, + companyId, + projectId, + name: "www", + repoUrl: "https://github.com/thefarmhub/www.git", + cwd: "/workspaces/www", + isPrimary: true, + }, + ]); + await db.insert(executionWorkspaces).values([ + { + id: dashboardExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: dashboardProjectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "dashboard worktree", + status: "active", + providerType: "git_worktree", + providerRef: "/workspaces/dashboard", + }, + { + id: wwwExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: wwwProjectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "www checkout", + status: "active", + providerType: "local_fs", + providerRef: "/workspaces/www", + repoUrl: "https://github.com/thefarmhub/www.git", + cwd: "/workspaces/www", + }, + ]); + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId: dashboardProjectWorkspaceId, + title: "Reliability parent", + status: "in_progress", + priority: "high", + executionWorkspaceId: dashboardExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: "isolated_workspace" }, + }); + await db.insert(issues).values({ + id: legacyParentIssueId, + companyId, + projectId, + projectWorkspaceId: null, + title: "Legacy reliability parent", + status: "in_progress", + priority: "high", + executionWorkspaceId: dashboardExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: "isolated_workspace" }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + projectWorkspaceId: wwwProjectWorkspaceId, + title: "Fix www reliability", + assigneeAgentId: agentId, + status: "todo", + }); + + expect(child.projectWorkspaceId).toBe(wwwProjectWorkspaceId); + expect(child.executionWorkspaceId).toBeNull(); + + const legacyChild = await svc.create(companyId, { + parentId: legacyParentIssueId, + projectId, + title: "Child of legacy reliability parent", + }); + expect(legacyChild.projectWorkspaceId).toBe(wwwProjectWorkspaceId); + expect(legacyChild.executionWorkspaceId).toBeNull(); + expect(legacyChild.executionWorkspacePreference).toBeNull(); + expect(legacyChild.executionWorkspaceSettings).toBeNull(); + + await expect(svc.create(companyId, { + projectId, + projectWorkspaceId: wwwProjectWorkspaceId, + executionWorkspaceId: dashboardExecutionWorkspaceId, + title: "Invalid explicit create pair", + })).rejects.toMatchObject({ + status: 422, + message: "Execution workspace must belong to the selected project workspace", + }); + + await svc.update(child.id, { + executionWorkspaceId: wwwExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: "shared_workspace" }, + }); + const checkedOut = await svc.checkout(child.id, agentId, ["todo"], checkoutRunId); + expect(checkedOut.executionWorkspaceId).toBe(wwwExecutionWorkspaceId); + const [resolvedWwwWorkspace] = await db + .select({ + id: executionWorkspaces.id, + repoUrl: executionWorkspaces.repoUrl, + cwd: executionWorkspaces.cwd, + }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, checkedOut.executionWorkspaceId!)); + expect(resolvedWwwWorkspace).toEqual({ + id: wwwExecutionWorkspaceId, + repoUrl: "https://github.com/thefarmhub/www.git", + cwd: "/workspaces/www", + }); + const movedToDashboard = await svc.update(child.id, { + projectWorkspaceId: dashboardProjectWorkspaceId, + }); + expect(movedToDashboard?.projectWorkspaceId).toBe(dashboardProjectWorkspaceId); + expect(movedToDashboard?.executionWorkspaceId).toBeNull(); + expect(movedToDashboard?.executionWorkspacePreference).toBeNull(); + expect(movedToDashboard?.executionWorkspaceSettings).toBeNull(); + + await expect(svc.update(child.id, { + projectWorkspaceId: wwwProjectWorkspaceId, + executionWorkspaceId: dashboardExecutionWorkspaceId, + })).rejects.toMatchObject({ + status: 422, + message: "Execution workspace must belong to the selected project workspace", + }); + }); + it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => { const companyId = randomUUID(); const projectId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e51af10035..04ac68d697 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -7193,6 +7193,7 @@ export function issueService(db: Db) { id: executionWorkspaces.id, companyId: executionWorkspaces.companyId, projectId: executionWorkspaces.projectId, + projectWorkspaceId: executionWorkspaces.projectWorkspaceId, }) .from(executionWorkspaces) .where(eq(executionWorkspaces.id, executionWorkspaceId)) @@ -7208,6 +7209,34 @@ export function issueService(db: Db) { return workspace; } + function assertProjectExecutionWorkspaceConsistency( + projectWorkspaceId: string | null | undefined, + executionWorkspace: { + id: string; + projectWorkspaceId: string | null; + } | null, + ) { + if ( + !projectWorkspaceId || + !executionWorkspace || + executionWorkspace.projectWorkspaceId === projectWorkspaceId + ) { + return; + } + throw unprocessable( + "Execution workspace must belong to the selected project workspace", + { + code: "project_execution_workspace_mismatch", + projectWorkspaceId, + executionWorkspaceId: executionWorkspace.id, + executionWorkspaceProjectWorkspaceId: + executionWorkspace.projectWorkspaceId, + remediation: + "Select an execution workspace linked to the selected project workspace, or omit executionWorkspaceId.", + }, + ); + } + async function assertValidLabelIds( companyId: string, labelIds: string[], @@ -9920,6 +9949,7 @@ export function issueService(db: Db) { let executionWorkspaceSettings = (issueData.executionWorkspaceSettings as Record | null | undefined) ?? null; + let executionWorkspaceWasInherited = false; const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance ? null : (inheritExecutionWorkspaceFromIssueId ?? @@ -9964,6 +9994,7 @@ export function issueService(db: Db) { .select({ id: executionWorkspaces.id, mode: executionWorkspaces.mode, + projectWorkspaceId: executionWorkspaces.projectWorkspaceId, }) .from(executionWorkspaces) .where( @@ -9973,8 +10004,13 @@ export function issueService(db: Db) { ), ) .then((rows) => rows[0] ?? null); - if (sourceWorkspace) { + if ( + sourceWorkspace && + (!projectWorkspaceId || + sourceWorkspace.projectWorkspaceId === projectWorkspaceId) + ) { executionWorkspaceId = sourceWorkspace.id; + executionWorkspaceWasInherited = true; executionWorkspacePreference = "reuse_existing"; executionWorkspaceSettings = { ...((workspaceSource.executionWorkspaceSettings as @@ -10094,12 +10130,26 @@ export function issueService(db: Db) { ); } if (executionWorkspaceId) { - await assertValidExecutionWorkspace( + const executionWorkspace = await assertValidExecutionWorkspace( companyId, issueData.projectId, executionWorkspaceId, tx, ); + if ( + executionWorkspaceWasInherited && + projectWorkspaceId && + executionWorkspace.projectWorkspaceId !== projectWorkspaceId + ) { + executionWorkspaceId = null; + executionWorkspacePreference = null; + executionWorkspaceSettings = null; + } else { + assertProjectExecutionWorkspaceConsistency( + projectWorkspaceId, + executionWorkspace, + ); + } } if ( isolatedWorkspacesEnabled && @@ -10760,15 +10810,15 @@ export function issueService(db: Db) { issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId; - const nextExecutionWorkspaceId = + let nextExecutionWorkspaceId = issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId; - const nextExecutionWorkspacePreference = + let nextExecutionWorkspacePreference = issueData.executionWorkspacePreference !== undefined ? issueData.executionWorkspacePreference : existing.executionWorkspacePreference; - const nextExecutionWorkspaceSettings = + let nextExecutionWorkspaceSettings = issueData.executionWorkspaceSettings !== undefined ? parseIssueExecutionWorkspaceSettings( issueData.executionWorkspaceSettings, @@ -10782,7 +10832,11 @@ export function issueService(db: Db) { : null; } let validatedProjectWorkspace: { projectId: string } | null = null; - let validatedExecutionWorkspace: { projectId: string } | null = null; + let validatedExecutionWorkspace: { + id: string; + projectId: string; + projectWorkspaceId: string | null; + } | null = null; if (!nextProjectId && nextProjectWorkspaceId) { const workspace = await assertValidProjectWorkspace( existing.companyId, @@ -10814,10 +10868,29 @@ export function issueService(db: Db) { } if (nextExecutionWorkspaceId) { if (!validatedExecutionWorkspace) { - await assertValidExecutionWorkspace( + validatedExecutionWorkspace = await assertValidExecutionWorkspace( existing.companyId, nextProjectId, nextExecutionWorkspaceId, + dbOrTx, + ); + } + if ( + issueData.projectWorkspaceId !== undefined && + issueData.executionWorkspaceId === undefined && + validatedExecutionWorkspace.projectWorkspaceId !== + nextProjectWorkspaceId + ) { + nextExecutionWorkspaceId = null; + nextExecutionWorkspacePreference = null; + nextExecutionWorkspaceSettings = null; + patch.executionWorkspaceId = null; + patch.executionWorkspacePreference = null; + patch.executionWorkspaceSettings = null; + } else { + assertProjectExecutionWorkspaceConsistency( + nextProjectWorkspaceId, + validatedExecutionWorkspace, ); } }