fix: keep project and execution workspaces consistent
This commit is contained in:
parent
d10cbde815
commit
fe8f46ddc7
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue