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:
+8
@@ -9,6 +9,8 @@ import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} 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', () => {
|
||||
let service: PreInstalledAppsService;
|
||||
@@ -18,11 +20,13 @@ describe('PreInstalledAppsService', () => {
|
||||
findOne: jest.Mock;
|
||||
};
|
||||
let workspaceIteratorService: { iterate: jest.Mock };
|
||||
let messageQueueService: { add: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
applicationInstallService = { installApplication: jest.fn() };
|
||||
applicationRegistrationRepository = { find: jest.fn(), findOne: jest.fn() };
|
||||
workspaceIteratorService = { iterate: jest.fn() };
|
||||
messageQueueService = { add: jest.fn() };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -39,6 +43,10 @@ describe('PreInstalledAppsService', () => {
|
||||
provide: WorkspaceIteratorService,
|
||||
useValue: workspaceIteratorService,
|
||||
},
|
||||
{
|
||||
provide: getQueueToken(MessageQueue.workspaceQueue),
|
||||
useValue: messageQueueService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const INSTALL_PRE_INSTALLED_APPS_JOB_NAME = 'InstallPreInstalledAppsJob';
|
||||
|
||||
export type InstallPreInstalledAppsJobData = {
|
||||
workspaceId: string;
|
||||
};
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -10,6 +10,13 @@ import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} 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()
|
||||
export class PreInstalledAppsService {
|
||||
@@ -20,8 +27,18 @@ export class PreInstalledAppsService {
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
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 —
|
||||
// `ApplicationInstallService` holds a per-app cache lock so parallel
|
||||
// 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 { 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 { 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 { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
|
||||
@@ -95,6 +97,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
ApplicationInstallModule,
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationUpgradeModule,
|
||||
PreInstalledAppsModule,
|
||||
OnboardingModule,
|
||||
BillingReminderModule,
|
||||
],
|
||||
@@ -113,6 +116,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
GenerateSdkClientJob,
|
||||
UpgradeApplicationsJob,
|
||||
InstallOnboardingAppsJob,
|
||||
InstallPreInstalledAppsJob,
|
||||
],
|
||||
})
|
||||
export class JobsModule {
|
||||
|
||||
+8
-2
@@ -411,6 +411,8 @@ export class WorkspaceService {
|
||||
await this.activateAndInitializeUpgradeState({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
await this.enqueuePreInstalledAppsInstallation(workspace.id);
|
||||
} catch (error) {
|
||||
await this.workspaceRepository.update(workspace.id, {
|
||||
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
|
||||
@@ -952,12 +954,16 @@ export class WorkspaceService {
|
||||
);
|
||||
this.exceptionHandlerService.captureExceptions([error as Error]);
|
||||
}
|
||||
}
|
||||
|
||||
private async enqueuePreInstalledAppsInstallation(
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.preInstalledAppsService.installOnWorkspace(workspaceId);
|
||||
await this.preInstalledAppsService.enqueueInstallOnWorkspace(workspaceId);
|
||||
} catch (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,
|
||||
);
|
||||
this.exceptionHandlerService.captureExceptions([error as Error]);
|
||||
|
||||
Reference in New Issue
Block a user