Install pre-installed apps in a dedicated job after the workspace upgrade cursor is written (#23517)

## Problem

Application registrations flagged `isPreInstalled: true` were not
installed on newly created workspaces.

The call was wired in, but it ran too early. `activateWorkspace` invoked
`preInstalledAppsService.installOnWorkspace` from inside
`prefillCreatedWorkspaceRecords`, which runs **before**
`activateAndInitializeUpgradeState`.

The install path validates app/workspace version compatibility:

- `ApplicationInstallService.runInstall` reads `engines.twenty` from the
app's `package.json` and calls `validateWorkspaceCompatibility`
- `ApplicationVersionValidationService.validateWorkspaceCompatibility`
resolves the workspace version through
`UpgradeStatusService.getWorkspaceCompletedVersion`
- that reads the workspace's upgrade-migration cursor, which is only
written by `markAsWorkspaceInitial` inside
`activateAndInitializeUpgradeState`

During creation the workspace has no cursor row yet, so
`getWorkspaceCompletedVersion` returns `null`, the install throws
`INVALID_WORKSPACE_VERSION`, and the failure is swallowed twice over:
`PreInstalledAppsService` logs per-app failures without rethrowing, and
`activateWorkspace` wraps the whole call in non-critical error handling.
The workspace comes up silently missing its apps.

This affects most real apps, since they pin `engines.twenty`:
`fireflies`, `last-contact`, `people-data-labs`, `call-recorder`,
`postcard`, `self-hosting`, `twenty-partners` (`>=2.23.0`) and `exa`,
`real-estate` (`>=2.19.0`). Only apps with no `engines.twenty` installed
successfully. The same interaction is already documented in
`2-23-workspace-command-1784565137000-upgrade-people-data-labs-application.command.ts`,
which works around it with `skipWorkspaceCompatibilityCheck: true`.

## Changes

- Added `InstallPreInstalledAppsJob` on the workspace queue, mirroring
the existing `InstallOnboardingAppsJob`.
- `activateWorkspace` now enqueues that job instead of installing
synchronously, so workspace creation no longer blocks on package
fetching and manifest application.
- The enqueue happens after `activateAndInitializeUpgradeState` writes
the upgrade cursor, so the compatibility check has a workspace version
to resolve by the time the worker picks the job up.

## Notes

Workspaces created before this fix can be repaired with the existing
`install-pre-installed-apps` backfill command, which is idempotent.
This commit is contained in:
martmull
2026-07-29 18:30:00 +02:00
committed by GitHub
parent ada7eb1d88
commit c4a79c50c3
6 changed files with 70 additions and 2 deletions
@@ -9,6 +9,8 @@ import {
ApplicationException, ApplicationException,
ApplicationExceptionCode, ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception'; } from 'src/engine/core-modules/application/application.exception';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
describe('PreInstalledAppsService', () => { describe('PreInstalledAppsService', () => {
let service: PreInstalledAppsService; let service: PreInstalledAppsService;
@@ -18,11 +20,13 @@ describe('PreInstalledAppsService', () => {
findOne: jest.Mock; findOne: jest.Mock;
}; };
let workspaceIteratorService: { iterate: jest.Mock }; let workspaceIteratorService: { iterate: jest.Mock };
let messageQueueService: { add: jest.Mock };
beforeEach(async () => { beforeEach(async () => {
applicationInstallService = { installApplication: jest.fn() }; applicationInstallService = { installApplication: jest.fn() };
applicationRegistrationRepository = { find: jest.fn(), findOne: jest.fn() }; applicationRegistrationRepository = { find: jest.fn(), findOne: jest.fn() };
workspaceIteratorService = { iterate: jest.fn() }; workspaceIteratorService = { iterate: jest.fn() };
messageQueueService = { add: jest.fn() };
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
@@ -39,6 +43,10 @@ describe('PreInstalledAppsService', () => {
provide: WorkspaceIteratorService, provide: WorkspaceIteratorService,
useValue: workspaceIteratorService, useValue: workspaceIteratorService,
}, },
{
provide: getQueueToken(MessageQueue.workspaceQueue),
useValue: messageQueueService,
},
], ],
}).compile(); }).compile();
@@ -0,0 +1,5 @@
export const INSTALL_PRE_INSTALLED_APPS_JOB_NAME = 'InstallPreInstalledAppsJob';
export type InstallPreInstalledAppsJobData = {
workspaceId: string;
};
@@ -0,0 +1,28 @@
import { Logger } from '@nestjs/common';
import {
INSTALL_PRE_INSTALLED_APPS_JOB_NAME,
type InstallPreInstalledAppsJobData,
} from 'src/engine/core-modules/application/pre-installed-apps/jobs/install-pre-installed-apps.job-constants';
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@Processor(MessageQueue.workspaceQueue)
export class InstallPreInstalledAppsJob {
private readonly logger = new Logger(InstallPreInstalledAppsJob.name);
constructor(
private readonly preInstalledAppsService: PreInstalledAppsService,
) {}
@Process(INSTALL_PRE_INSTALLED_APPS_JOB_NAME)
async handle({ workspaceId }: InstallPreInstalledAppsJobData): Promise<void> {
this.logger.log(
`Installing pre-installed apps on workspace ${workspaceId}`,
);
await this.preInstalledAppsService.installOnWorkspace(workspaceId);
}
}
@@ -10,6 +10,13 @@ import {
ApplicationException, ApplicationException,
ApplicationExceptionCode, ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception'; } from 'src/engine/core-modules/application/application.exception';
import {
INSTALL_PRE_INSTALLED_APPS_JOB_NAME,
type InstallPreInstalledAppsJobData,
} from 'src/engine/core-modules/application/pre-installed-apps/jobs/install-pre-installed-apps.job-constants';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@Injectable() @Injectable()
export class PreInstalledAppsService { export class PreInstalledAppsService {
@@ -20,8 +27,18 @@ export class PreInstalledAppsService {
@InjectRepository(ApplicationRegistrationEntity) @InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>, private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly workspaceIteratorService: WorkspaceIteratorService, private readonly workspaceIteratorService: WorkspaceIteratorService,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly messageQueueService: MessageQueueService,
) {} ) {}
async enqueueInstallOnWorkspace(workspaceId: string): Promise<void> {
await this.messageQueueService.add<InstallPreInstalledAppsJobData>(
INSTALL_PRE_INSTALLED_APPS_JOB_NAME,
{ workspaceId },
{ id: `${INSTALL_PRE_INSTALLED_APPS_JOB_NAME}-${workspaceId}` },
);
}
// Per-app failures are logged but never block the other installs — // Per-app failures are logged but never block the other installs —
// `ApplicationInstallService` holds a per-app cache lock so parallel // `ApplicationInstallService` holds a per-app cache lock so parallel
// installs are safe. // installs are safe.
@@ -16,6 +16,8 @@ import { ApplicationInstallModule } from 'src/engine/core-modules/application/ap
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module'; import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
import { UpgradeApplicationsJob } from 'src/engine/core-modules/application/jobs/upgrade-applications.job'; import { UpgradeApplicationsJob } from 'src/engine/core-modules/application/jobs/upgrade-applications.job';
import { InstallPreInstalledAppsJob } from 'src/engine/core-modules/application/pre-installed-apps/jobs/install-pre-installed-apps.job';
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
import { InstallOnboardingAppsJob } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job'; import { InstallOnboardingAppsJob } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job'; import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
@@ -95,6 +97,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
ApplicationInstallModule, ApplicationInstallModule,
ApplicationRegistrationModule, ApplicationRegistrationModule,
ApplicationUpgradeModule, ApplicationUpgradeModule,
PreInstalledAppsModule,
OnboardingModule, OnboardingModule,
BillingReminderModule, BillingReminderModule,
], ],
@@ -113,6 +116,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
GenerateSdkClientJob, GenerateSdkClientJob,
UpgradeApplicationsJob, UpgradeApplicationsJob,
InstallOnboardingAppsJob, InstallOnboardingAppsJob,
InstallPreInstalledAppsJob,
], ],
}) })
export class JobsModule { export class JobsModule {
@@ -411,6 +411,8 @@ export class WorkspaceService {
await this.activateAndInitializeUpgradeState({ await this.activateAndInitializeUpgradeState({
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
await this.enqueuePreInstalledAppsInstallation(workspace.id);
} catch (error) { } catch (error) {
await this.workspaceRepository.update(workspace.id, { await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.PENDING_CREATION, activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
@@ -952,12 +954,16 @@ export class WorkspaceService {
); );
this.exceptionHandlerService.captureExceptions([error as Error]); this.exceptionHandlerService.captureExceptions([error as Error]);
} }
}
private async enqueuePreInstalledAppsInstallation(
workspaceId: string,
): Promise<void> {
try { try {
await this.preInstalledAppsService.installOnWorkspace(workspaceId); await this.preInstalledAppsService.enqueueInstallOnWorkspace(workspaceId);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Non-critical: failed to install pre-installed apps for workspace ${workspaceId}`, `Non-critical: failed to enqueue pre-installed apps installation for workspace ${workspaceId}`,
error, error,
); );
this.exceptionHandlerService.captureExceptions([error as Error]); this.exceptionHandlerService.captureExceptions([error as Error]);