fix: keep project and execution workspaces consistent

This commit is contained in:
Jonathan Reyes 2026-09-11 08:32:04 -06:00
parent d10cbde815
commit fe8f46ddc7
2 changed files with 263 additions and 7 deletions

View File

@ -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();

View File

@ -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<string, unknown> | 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,
);
}
}