[Upgrade] Fix workspace creation cursor (#19701)

## Summary

### Problem

The upgrade migration system required new workspaces to always start
from a workspace command, which was too rigid. When the system was
mid-upgrade within an instance command (IC) segment, workspace creation
would fail or produce inconsistent state.

### Solution

#### Workspace-scoped instance command rows

Instance commands now write upgrade migration rows for **all
active/suspended workspaces** alongside the global row. This means every
workspace has a complete migration history, including instance command
records.

- `InstanceCommandRunnerService` reloads `activeOrSuspendedWorkspaceIds`
immediately before writing records (both success and failure paths) to
mitigate race conditions with concurrent workspace creation.
- `recordUpgradeMigration` in `UpgradeMigrationService` accepts a
discriminated union over `status`, handles `error: unknown` formatting
internally, and writes global + workspace rows in batch.

#### Flexible initial cursor for new workspaces

`getInitialCursorForNewWorkspace` now accepts the last **attempted**
(not just completed) instance command with its status:

- If the IC is `completed` and the next step is a workspace segment →
cursor is set to the last WC of that segment (existing behavior).
- If the IC is `failed` or not the last of its segment → cursor is set
to that IC itself, preserving its status.

This allows workspaces to be created at any point during the upgrade
lifecycle, including mid-IC-segment and after IC failure.

#### Relaxed workspace segment validation

`validateWorkspaceCursorsAreInWorkspaceSegment` accepts workspaces whose
cursor is:
1. Within the current workspace segment, OR
2. At the immediately preceding instance command with `completed` status
(handles the `-w` single-workspace upgrade scenario).

Workspaces with cursors in a previous segment, ahead of the current
segment, or at a preceding IC with `failed` status are rejected.

### Test plan
created empty workspaces to allow testing upgrade with several active
workspaces
This commit is contained in:
Paul Rastoin
2026-04-15 17:41:10 +02:00
committed by GitHub
parent 0f8152f536
commit a4cc7fb9c5
18 changed files with 1538 additions and 203 deletions
@@ -21,11 +21,17 @@ export type CreateWorkspaceInput = Pick<
export const SEED_APPLE_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
export const SEED_YCOMBINATOR_WORKSPACE_ID =
'3b8e6458-5fc1-4e63-8563-008ccddaa6db';
export const SEED_EMPTY_WORKSPACE_3_ID = '506915ec-21ca-431b-a04a-257eb216865e';
export const SEED_EMPTY_WORKSPACE_4_ID = 'aa8fdcb1-8ee1-4012-98af-44a97caa7411';
export type SeededWorkspacesIds =
| typeof SEED_APPLE_WORKSPACE_ID
| typeof SEED_YCOMBINATOR_WORKSPACE_ID;
export type SeededEmptyWorkspacesIds =
| typeof SEED_EMPTY_WORKSPACE_3_ID
| typeof SEED_EMPTY_WORKSPACE_4_ID;
export const SEEDER_CREATE_WORKSPACE_INPUT = {
[SEED_APPLE_WORKSPACE_ID]: {
id: SEED_APPLE_WORKSPACE_ID,
@@ -49,3 +55,29 @@ export const SEEDER_CREATE_WORKSPACE_INPUT = {
SeededWorkspacesIds,
Omit<CreateWorkspaceInput, 'workspaceCustomApplicationId'>
>;
// Empty workspaces with no users, metadata, or data — used by integration tests
// that need more than 2 workspaces (e.g. upgrade sequence runner tests).
export const SEEDER_CREATE_EMPTY_WORKSPACE_INPUT = {
[SEED_EMPTY_WORKSPACE_3_ID]: {
id: SEED_EMPTY_WORKSPACE_3_ID,
displayName: 'Empty3',
subdomain: 'empty3',
inviteHash: 'empty3.dev-invite-hash',
logo: '',
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
isTwoFactorAuthenticationEnforced: false,
},
[SEED_EMPTY_WORKSPACE_4_ID]: {
id: SEED_EMPTY_WORKSPACE_4_ID,
displayName: 'Empty4',
subdomain: 'empty4',
inviteHash: 'empty4.dev-invite-hash',
logo: '',
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
isTwoFactorAuthenticationEnforced: false,
},
} as const satisfies Record<
SeededEmptyWorkspacesIds,
Omit<CreateWorkspaceInput, 'workspaceCustomApplicationId'>
>;
@@ -10,6 +10,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -146,6 +147,27 @@ export class DevSeederPermissionsService {
roleId: adminRole.id,
});
const memberRole = await this.initMinimalPermissionsAndActivateWorkspace({
workspaceId,
workspaceCustomFlatApplication,
});
if (memberUserWorkspaceIds.length > 0) {
await this.userRoleService.assignRoleToManyUserWorkspace({
workspaceId,
userWorkspaceIds: memberUserWorkspaceIds,
roleId: memberRole.id,
});
}
}
public async initMinimalPermissionsAndActivateWorkspace({
workspaceId,
workspaceCustomFlatApplication,
}: {
workspaceId: string;
workspaceCustomFlatApplication: FlatApplication;
}): Promise<RoleDTO> {
const memberRole = await this.roleService.createMemberRole({
workspaceId,
ownerFlatApplication: workspaceCustomFlatApplication,
@@ -158,13 +180,7 @@ export class DevSeederPermissionsService {
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
if (memberUserWorkspaceIds.length > 0) {
await this.userRoleService.assignRoleToManyUserWorkspace({
workspaceId,
userWorkspaceIds: memberUserWorkspaceIds,
roleId: memberRole.id,
});
}
return memberRole;
}
private async createLimitedRoleForSeedWorkspace({
@@ -10,6 +10,7 @@ import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/s
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@@ -18,7 +19,9 @@ import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/work
import { seedBillingCustomers } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-customers.util';
import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util';
import {
type SeededEmptyWorkspacesIds,
type SeededWorkspacesIds,
SEEDER_CREATE_EMPTY_WORKSPACE_INPUT,
SEEDER_CREATE_WORKSPACE_INPUT,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { DevSeederPermissionsService } from 'src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service';
@@ -73,14 +76,18 @@ export class DevSeederService {
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
const appVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
const lastWorkspaceCommand =
this.upgradeSequenceReaderService.getLastWorkspaceCommand();
const lastAttemptedInstanceCommand =
await this.upgradeMigrationService.getLastAttemptedInstanceCommandOrThrow();
const initialCursor =
this.upgradeSequenceReaderService.getInitialCursorForNewWorkspace(
lastAttemptedInstanceCommand,
);
await this.seedCoreSchema({
workspaceId,
seedBilling: isBillingEnabled,
appVersion,
lastUpgradeStepName: lastWorkspaceCommand.name,
initialCursor,
});
await this.applicationRegistrationService.createCliRegistrationIfNotExists();
@@ -181,15 +188,93 @@ export class DevSeederService {
await this.workspaceCacheStorageService.flush(workspaceId, undefined);
}
public async seedEmptyWorkspace(
workspaceId: SeededEmptyWorkspacesIds,
): Promise<void> {
const appVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
const lastAttemptedInstanceCommand =
await this.upgradeMigrationService.getLastAttemptedInstanceCommandOrThrow();
const initialCursor =
this.upgradeSequenceReaderService.getInitialCursorForNewWorkspace(
lastAttemptedInstanceCommand,
);
const createWorkspaceStaticInput =
SEEDER_CREATE_EMPTY_WORKSPACE_INPUT[workspaceId];
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const workspaceCustomApplicationId = v4();
await createWorkspace({
queryRunner,
schemaName: 'core',
createWorkspaceInput: {
...createWorkspaceStaticInput,
workspaceCustomApplicationId,
},
});
await this.applicationService.createWorkspaceCustomApplication(
{
workspaceId,
applicationId: workspaceCustomApplicationId,
},
queryRunner,
);
await this.applicationService.createTwentyStandardApplication(
{
workspaceId,
skipCacheInvalidation: true,
},
queryRunner,
);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
await this.devSeederPermissionsService.initMinimalPermissionsAndActivateWorkspace(
{
workspaceId,
workspaceCustomFlatApplication,
},
);
await this.upgradeMigrationService.markAsWorkspaceInitial({
name: initialCursor.name,
workspaceId,
executedByVersion: appVersion,
status: initialCursor.status,
});
await this.workspaceCacheStorageService.flush(workspaceId, undefined);
}
private async seedCoreSchema({
workspaceId,
appVersion,
lastUpgradeStepName,
initialCursor,
seedBilling = true,
}: {
workspaceId: SeededWorkspacesIds;
appVersion: string;
lastUpgradeStepName: string;
initialCursor: { name: string; status: UpgradeMigrationStatus };
seedBilling?: boolean;
}): Promise<void> {
const schemaName = 'core';
@@ -247,10 +332,11 @@ export class DevSeederService {
await seedMetadataEntities({ queryRunner, schemaName, workspaceId });
await this.upgradeMigrationService.markAsInitial({
name: lastUpgradeStepName,
await this.upgradeMigrationService.markAsWorkspaceInitial({
name: initialCursor.name,
workspaceId,
executedByVersion: appVersion,
status: initialCursor.status,
queryRunner,
});