Replace admin app rollout buttons with upgrade-application CLI command (#23212)

## What

Removes the two rollout buttons from the admin application detail page
and replaces the upgrade flow with a CLI command that can be run
directly from a server or worker pod. Also restructures application stop
into its own module with a kill switch CLI command, and surfaces stopped
apps in workspace settings.

### Removed

- "Install on all workspaces" button (General tab,
`SettingsAdminApplicationRegistrationGeneralToggles`), its confirmation
modal and tooltip
- "Upgrade existing installations" button
(`SettingsApplicationRegistrationGeneralStats`), its confirmation modal
and batch size input
- `backfillApplicationInstallation` and
`upgradeRegistrationApplications` admin GraphQL mutations and their
frontend documents / generated types
- `BackfillApplicationInstallationJob` (its only trigger was the removed
mutation); `UpgradeApplicationsJob` is kept since the auto-upgrade flow
still enqueues it

Per review, the "install on all workspaces" flow is dropped without a
CLI replacement for now; a dedicated command will be added when needed.

### application:upgrade command

Located in `application-upgrade/commands`, registered in
`ApplicationUpgradeModule`:

```
yarn command:prod application:upgrade \
  --application-registration-universal-identifier <universalIdentifier> \
  [--batch-size 5] \
  [--workspace-id <id> --workspace-id <id2>] \
  [--workspace-count-limit 10] \
  [--dry-run] [--yes]
```

- `--workspace-id` (repeatable) restricts the upgrade to specific
workspaces; `--workspace-count-limit` caps how many installations are
upgraded (max 50, for canary rollouts)
- `--batch-size` and `--workspace-count-limit` are validated as positive
integers, max 50
- `--dry-run` reports how many (and which) workspaces would be upgraded,
without upgrading
- Without `--dry-run`, a confirmation prompt shows the app, target
version and impacted workspaces; the run then executes exactly the
confirmed set; `--yes` skips the prompt for non-interactive usage

The upgrade plan is computed by a new
`ApplicationUpgradeService.findApplicationsToUpgrade`, and batches run
through a new `upgradeApplications` method — both reused by
`upgradeAllApplications`, so the auto-upgrade job path is unchanged.

### Application kill switch (per review)

Global mechanism only — a per-workspace stop had no demonstrated
operational need and added a Redis key format, execution branching, CLI
options and tests; an isolated workspace issue can be handled directly
in the DB or Redis with the same effort.

- `ApplicationStopService` moved to a dedicated `application-stop/`
folder with its own `ApplicationStopModule` (imported and re-exported by
`ApplicationModule`)
- `stop` / `remove` methods that enable or clear the Redis-backed global
kill switch; the logic function executor checks it before executing
- `application:kill-switch` command with a positional action,
confirmation prompt (shows the installation count) and `--yes` bypass:

```
# Enable the kill switch (stop is the default action)
yarn command:prod application:kill-switch stop -u <universalIdentifier> [-y]
yarn command:prod application:kill-switch -u <universalIdentifier>

# Remove the kill switch
yarn command:prod application:kill-switch remove -u <universalIdentifier> [-y]
```

### Stopped apps surfaced in workspace settings (per review)

- Dedicated `isApplicationStopped(applicationUniversalIdentifier)` query
backed by the kill switch, fetched with `network-only` policy solely by
the application detail page — listing applications triggers no extra
Redis reads
- Application detail page shows a danger banner when the app is stopped:
"We are currently encountering issues with this app, its behavior may be
degraded while we work on a fix."

## Test

- `npx nx typecheck twenty-server` / `npx nx typecheck twenty-front`
pass
- `npx nx lint:diff-with-main` passes for both packages
- `application-stop.service.spec.ts` covers stop, remove, caching and
fail-open behavior
- Verified end to end locally: ran the kill switch command on a seeded
workspace and confirmed the banner renders on the app detail page
(screenshot shared separately)
This commit is contained in:
martmull
2026-07-24 16:31:25 +02:00
committed by GitHub
parent 940d150775
commit 25b0b2601f
26 changed files with 585 additions and 356 deletions
@@ -59,14 +59,6 @@ import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/applica
import { FindApplicationRegistrationInstalledWorkspacesInput } from 'src/engine/core-modules/application/application-registration/dtos/find-application-registration-installed-workspaces.input';
import { PaginatedApplicationRegistrationsDTO } from 'src/engine/core-modules/application/application-registration/dtos/paginated-application-registrations.dto';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import {
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
type BackfillApplicationInstallationJobData,
} from 'src/engine/core-modules/application/jobs/backfill-application-installation.job-constants';
import {
UPGRADE_APPLICATIONS_JOB_NAME,
type UpgradeApplicationsJobData,
} from 'src/engine/core-modules/application/jobs/upgrade-applications.job-constants';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { AdminAiModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
@@ -153,8 +145,6 @@ export class AdminPanelResolver {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly cronQueueService: MessageQueueService,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly workspaceQueueService: MessageQueueService,
) {}
@UseGuards(AdminPanelOrImpersonateGuard)
@@ -535,59 +525,6 @@ export class AdminPanelResolver {
return this.applicationRegistrationService.updateGlobal(input);
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async backfillApplicationInstallation(
@Args('applicationRegistrationId') applicationRegistrationId: string,
): Promise<boolean> {
const registration =
await this.applicationRegistrationService.findOneByIdGlobal(
applicationRegistrationId,
);
if (!registration.isPreInstalled) {
throw new UserInputError(
'Only pre-installed apps can be backfilled. Enable pre-install first.',
);
}
await this.workspaceQueueService.add<BackfillApplicationInstallationJobData>(
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
{ applicationRegistrationId },
{
id: `${BACKFILL_APPLICATION_INSTALLATION_JOB_NAME}-${applicationRegistrationId}`,
}, // Avoids triggering multiple pending jobs for the same app
);
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async upgradeRegistrationApplications(
@Args('applicationRegistrationId') applicationRegistrationId: string,
@Args('batchSize', { type: () => Int, nullable: true })
batchSize?: number,
): Promise<boolean> {
await this.applicationRegistrationService.findOneByIdGlobal(
applicationRegistrationId,
);
await this.workspaceQueueService.add<UpgradeApplicationsJobData>(
UPGRADE_APPLICATIONS_JOB_NAME,
{
applicationRegistrationId,
onlyAutoUpgrade: false,
...(isDefined(batchSize) ? { batchSize } : {}),
},
{
id: `${UPGRADE_APPLICATIONS_JOB_NAME}-${applicationRegistrationId}`,
}, // Avoids triggering multiple pending jobs for the same app
);
return true;
}
@UseGuards(AdminPanelGuard)
@Query(() => GraphQLJSON)
async getAiProviders(): Promise<Record<string, unknown>> {
@@ -3,7 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import {
APPLICATION_KILL_SWITCH_LOCAL_CACHE_TTL_MS,
ApplicationStopService,
} from 'src/engine/core-modules/application/application-stop.service';
} from 'src/engine/core-modules/application/application-stop/application-stop.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
const APPLICATION_UNIVERSAL_IDENTIFIER = 'application-universal-identifier-1';
@@ -14,6 +14,8 @@ describe('ApplicationStopService', () => {
const cacheStorageService = {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
};
beforeEach(async () => {
@@ -36,6 +38,21 @@ describe('ApplicationStopService', () => {
jest.restoreAllMocks();
});
it('sets the kill switch when stopping an application', async () => {
await applicationStopService.stop(APPLICATION_UNIVERSAL_IDENTIFIER);
expect(cacheStorageService.set).toHaveBeenCalledWith(
KILL_SWITCH_KEY,
'stopped',
);
});
it('deletes the kill switch when removing it', async () => {
await applicationStopService.remove(APPLICATION_UNIVERSAL_IDENTIFIER);
expect(cacheStorageService.del).toHaveBeenCalledWith(KILL_SWITCH_KEY);
});
it('returns true when the kill switch exists', async () => {
cacheStorageService.get.mockResolvedValue('stopped');
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
import { ApplicationKillSwitchCommand } from 'src/engine/core-modules/application/application-stop/commands/application-kill-switch.command';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
ApplicationRegistrationEntity,
ApplicationEntity,
]),
],
providers: [ApplicationStopService, ApplicationKillSwitchCommand],
exports: [ApplicationStopService],
})
export class ApplicationStopModule {}
@@ -23,6 +23,19 @@ export class ApplicationStopService {
private readonly cacheStorageService: CacheStorageService,
) {}
async stop(applicationUniversalIdentifier: string): Promise<void> {
await this.cacheStorageService.set(
this.getKillSwitchKey(applicationUniversalIdentifier),
'stopped',
);
}
async remove(applicationUniversalIdentifier: string): Promise<void> {
await this.cacheStorageService.del(
this.getKillSwitchKey(applicationUniversalIdentifier),
);
}
async isApplicationStopped(
applicationUniversalIdentifier: string,
): Promise<boolean> {
@@ -0,0 +1,136 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command, CommandRunner, Option } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { CommandLogger } from 'src/database/commands/logger';
import { askCommandConfirmation } from 'src/database/commands/utils/ask-command-confirmation.util';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
const KILL_SWITCH_ACTIONS = ['stop', 'remove'] as const;
type KillSwitchAction = (typeof KILL_SWITCH_ACTIONS)[number];
type ApplicationKillSwitchCommandOptions = {
applicationUniversalIdentifier: string;
yes?: boolean;
};
@Command({
name: 'application:kill-switch',
arguments: '[action]',
description:
'Toggle an application kill switch on every workspace: "stop" (default) halts its logic function executions until "remove" clears the switch.',
})
export class ApplicationKillSwitchCommand extends CommandRunner {
protected logger: CommandLogger;
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly applicationStopService: ApplicationStopService,
) {
super();
this.logger = new CommandLogger({
verbose: false,
constructorName: this.constructor.name,
});
}
@Option({
flags:
'-u, --application-universal-identifier <application_universal_identifier>',
description: 'Application universal identifier',
required: true,
})
parseApplicationUniversalIdentifier(value: string): string {
return value;
}
@Option({
flags: '-y, --yes',
description: 'Skip the confirmation prompt (for non-interactive usage)',
required: false,
})
parseYes(): boolean {
return true;
}
override async run(
passedParams: string[],
options: ApplicationKillSwitchCommandOptions,
): Promise<void> {
const action = (passedParams[0] ?? 'stop') as KillSwitchAction;
if (!KILL_SWITCH_ACTIONS.includes(action)) {
throw new Error(
`Invalid action "${passedParams[0]}". Expected one of: ${KILL_SWITCH_ACTIONS.join(', ')}`,
);
}
const registration = await this.applicationRegistrationRepository.findOne({
where: {
universalIdentifier: options.applicationUniversalIdentifier,
},
});
if (!isDefined(registration)) {
throw new Error(
`Application registration with universal identifier ${options.applicationUniversalIdentifier} not found`,
);
}
if (!(options.yes ?? false)) {
const isConfirmed = await this.askForConfirmation(
action,
options.applicationUniversalIdentifier,
registration.id,
);
if (!isConfirmed) {
this.logger.log('Aborted, kill switch left unchanged');
return;
}
}
if (action === 'stop') {
await this.applicationStopService.stop(
options.applicationUniversalIdentifier,
);
} else {
await this.applicationStopService.remove(
options.applicationUniversalIdentifier,
);
}
this.logger.log(
`Kill switch ${action === 'stop' ? 'enabled' : 'removed'} for "${registration.name}" (${options.applicationUniversalIdentifier}). Workers pick it up within a minute.`,
);
this.logger.log(chalk.blue('Command completed!'));
}
private async askForConfirmation(
action: KillSwitchAction,
applicationUniversalIdentifier: string,
applicationRegistrationId: string,
): Promise<boolean> {
const installationCount = await this.applicationRepository.count({
where: { applicationRegistrationId },
});
const actionLabel =
action === 'stop' ? 'stopping' : 'removing the kill switch of';
return askCommandConfirmation(
`Confirm ${actionLabel} application ${applicationUniversalIdentifier} on ${installationCount} workspace(s)`,
);
}
}
@@ -7,6 +7,7 @@ import { ApplicationRegistrationEntity } from 'src/engine/core-modules/applicati
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationUpgradeResolver } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.resolver';
import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.service';
import { UpgradeApplicationCommand } from 'src/engine/core-modules/application/application-upgrade/commands/upgrade-application.command';
import { ApplicationVersionCheckCronJob } from 'src/engine/core-modules/application/application-upgrade/crons/application-version-check.cron.job';
import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -30,6 +31,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
ApplicationUpgradeResolver,
ApplicationVersionCheckCronJob,
ApplicationVersionCheckCronCommand,
UpgradeApplicationCommand,
],
exports: [ApplicationUpgradeService, ApplicationVersionCheckCronCommand],
})
@@ -2,8 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { z } from 'zod';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
@@ -111,15 +111,21 @@ export class ApplicationUpgradeService {
}
}
async upgradeAllApplications({
async findApplicationsToUpgrade({
applicationRegistrationId,
onlyAutoUpgrade = false,
batchSize = UPGRADE_APPLICATIONS_DEFAULT_BATCH_SIZE,
workspaceIds,
workspaceCountLimit,
}: {
applicationRegistrationId: string;
onlyAutoUpgrade?: boolean;
batchSize?: number;
}): Promise<void> {
workspaceIds?: string[];
workspaceCountLimit?: number;
}): Promise<{
appRegistration: ApplicationRegistrationEntity;
targetVersion: string | null;
applicationsToUpgrade: ApplicationEntity[];
}> {
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
where: { id: applicationRegistrationId },
});
@@ -127,28 +133,56 @@ export class ApplicationUpgradeService {
const targetVersion = appRegistration.latestAvailableVersion;
if (!isDefined(targetVersion)) {
return;
return {
appRegistration,
targetVersion: null,
applicationsToUpgrade: [],
};
}
const applications = await this.applicationRepository.find({
where: {
applicationRegistrationId,
...(onlyAutoUpgrade ? { autoUpgrade: true } : {}),
...(isNonEmptyArray(workspaceIds)
? { workspaceId: In(workspaceIds) }
: {}),
},
});
const applicationsToUpgrade = applications.filter(
let applicationsToUpgrade = applications.filter(
(application) => application.version !== targetVersion,
);
if (isDefined(workspaceCountLimit)) {
applicationsToUpgrade = applicationsToUpgrade.slice(
0,
workspaceCountLimit,
);
}
return { appRegistration, targetVersion, applicationsToUpgrade };
}
async upgradeApplications({
appRegistration,
targetVersion,
applications,
batchSize = UPGRADE_APPLICATIONS_DEFAULT_BATCH_SIZE,
}: {
appRegistration: ApplicationRegistrationEntity;
targetVersion: string;
applications: ApplicationEntity[];
batchSize?: number;
}): Promise<void> {
const sanitizedBatchSize = Math.max(1, Math.floor(batchSize));
for (
let batchStart = 0;
batchStart < applicationsToUpgrade.length;
batchStart < applications.length;
batchStart += sanitizedBatchSize
) {
const batch = applicationsToUpgrade.slice(
const batch = applications.slice(
batchStart,
batchStart + sanitizedBatchSize,
);
@@ -172,6 +206,39 @@ export class ApplicationUpgradeService {
}
}
async upgradeAllApplications({
applicationRegistrationId,
onlyAutoUpgrade = false,
batchSize = UPGRADE_APPLICATIONS_DEFAULT_BATCH_SIZE,
workspaceIds,
workspaceCountLimit,
}: {
applicationRegistrationId: string;
onlyAutoUpgrade?: boolean;
batchSize?: number;
workspaceIds?: string[];
workspaceCountLimit?: number;
}): Promise<void> {
const { appRegistration, targetVersion, applicationsToUpgrade } =
await this.findApplicationsToUpgrade({
applicationRegistrationId,
onlyAutoUpgrade,
workspaceIds,
workspaceCountLimit,
});
if (!isDefined(targetVersion)) {
return;
}
await this.upgradeApplications({
appRegistration,
targetVersion,
applications: applicationsToUpgrade,
batchSize,
});
}
async upgradeApplication(params: {
appRegistrationId: string;
targetVersion: string;
@@ -0,0 +1,220 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command, CommandRunner, Option } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { CommandLogger } from 'src/database/commands/logger';
import { askCommandConfirmation } from 'src/database/commands/utils/ask-command-confirmation.util';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.service';
type UpgradeApplicationCommandOptions = {
applicationRegistrationUniversalIdentifier: string;
batchSize?: number;
workspaceId?: Set<string>;
workspaceCountLimit?: number;
dryRun?: boolean;
yes?: boolean;
};
const MAX_BATCH_SIZE = 50;
const MAX_WORKSPACE_COUNT_LIMIT = 50;
const parseBoundedPositiveInteger = (
value: string,
optionName: string,
maximum: number,
): number => {
const parsedValue = Number(value);
if (!Number.isInteger(parsedValue) || parsedValue < 1) {
throw new Error(
`Invalid ${optionName} "${value}". Expected a positive integer`,
);
}
if (parsedValue > maximum) {
throw new Error(`Invalid ${optionName} "${value}". Maximum is ${maximum}`);
}
return parsedValue;
};
@Command({
name: 'application:upgrade',
description:
'Upgrade an application to its latest available version on every workspace that already has it installed',
})
export class UpgradeApplicationCommand extends CommandRunner {
protected logger: CommandLogger;
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly applicationUpgradeService: ApplicationUpgradeService,
) {
super();
this.logger = new CommandLogger({
verbose: false,
constructorName: this.constructor.name,
});
}
@Option({
flags:
'-u, --application-registration-universal-identifier <application_registration_universal_identifier>',
description: 'Application registration universal identifier',
required: true,
})
parseApplicationRegistrationUniversalIdentifier(value: string): string {
return value;
}
@Option({
flags: '-b, --batch-size <batch_size>',
description: `Number of workspaces upgraded in parallel (defaults to 5, max ${MAX_BATCH_SIZE})`,
required: false,
})
parseBatchSize(value: string): number {
return parseBoundedPositiveInteger(value, 'batch size', MAX_BATCH_SIZE);
}
@Option({
flags: '-w, --workspace-id <workspace_id>',
description:
'Only upgrade the given workspace id. Can be repeated to target several workspaces. Upgrades all workspaces if not provided.',
required: false,
})
parseWorkspaceId(value: string, previous?: Set<string>): Set<string> {
const accumulator = previous ?? new Set<string>();
accumulator.add(value);
return accumulator;
}
@Option({
flags: '--workspace-count-limit <count>',
description: `Limit the number of workspaces to upgrade (max ${MAX_WORKSPACE_COUNT_LIMIT})`,
required: false,
})
parseWorkspaceCountLimit(value: string): number {
return parseBoundedPositiveInteger(
value,
'workspace count limit',
MAX_WORKSPACE_COUNT_LIMIT,
);
}
@Option({
flags: '-d, --dry-run',
description: 'List the workspaces that would be upgraded without upgrading',
required: false,
})
parseDryRun(): boolean {
return true;
}
@Option({
flags: '-y, --yes',
description: 'Skip the confirmation prompt (for non-interactive usage)',
required: false,
})
parseYes(): boolean {
return true;
}
override async run(
_passedParams: string[],
options: UpgradeApplicationCommandOptions,
): Promise<void> {
const registration = await this.applicationRegistrationRepository.findOne({
where: {
universalIdentifier: options.applicationRegistrationUniversalIdentifier,
},
});
if (!isDefined(registration)) {
throw new Error(
`Application registration with universal identifier ${options.applicationRegistrationUniversalIdentifier} not found`,
);
}
const workspaceIds = isDefined(options.workspaceId)
? Array.from(options.workspaceId)
: undefined;
const { appRegistration, targetVersion, applicationsToUpgrade } =
await this.applicationUpgradeService.findApplicationsToUpgrade({
applicationRegistrationId: registration.id,
onlyAutoUpgrade: false,
workspaceIds,
workspaceCountLimit: options.workspaceCountLimit,
});
if (!isDefined(targetVersion)) {
this.logger.warn(
`Application "${registration.name}" (${registration.universalIdentifier}) has no latest available version, nothing to upgrade`,
);
return;
}
const impactedWorkspaceIds = applicationsToUpgrade.map(
(application) => application.workspaceId,
);
if (options.dryRun ?? false) {
this.logger.log(
`[DRY RUN] Would upgrade "${registration.name}" (${registration.universalIdentifier}) to version ${targetVersion} on ${impactedWorkspaceIds.length} workspace(s)${
impactedWorkspaceIds.length > 0
? `: ${impactedWorkspaceIds.join(', ')}`
: ''
}`,
);
return;
}
if (impactedWorkspaceIds.length === 0) {
this.logger.log(
`No workspace to upgrade, every targeted installation of "${registration.name}" already runs version ${targetVersion}`,
);
return;
}
if (!(options.yes ?? false)) {
const confirmationTarget = isDefined(workspaceIds)
? `workspace(s) ${workspaceIds.join(', ')}`
: `${impactedWorkspaceIds.length} workspace(s)`;
const isConfirmed = await askCommandConfirmation(
`Confirm upgrading application ${registration.universalIdentifier} to version ${targetVersion} on ${confirmationTarget}`,
);
if (!isConfirmed) {
this.logger.log('Aborted, no upgrade performed');
return;
}
}
this.logger.log(
`Upgrading "${registration.name}" (${registration.universalIdentifier}) to version ${targetVersion} on ${impactedWorkspaceIds.length} workspace(s)...`,
);
// Runs on the exact set shown at confirmation time, so installations
// created or versions published while the operator answered are excluded.
await this.applicationUpgradeService.upgradeApplications({
appRegistration,
targetVersion,
applications: applicationsToUpgrade,
batchSize: options.batchSize,
});
this.logger.log(chalk.blue('Command completed!'));
}
}
@@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationGaugeService } from 'src/engine/core-modules/application/application-gauge.service';
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop.service';
import { ApplicationStopModule } from 'src/engine/core-modules/application/application-stop/application-stop.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationResolver } from 'src/engine/core-modules/application/application.resolver';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
@@ -35,6 +35,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
ObjectMetadataEntity,
ApplicationVariableEntity,
]),
ApplicationStopModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceCacheModule,
TwentyConfigModule,
@@ -43,13 +44,12 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
],
exports: [
ApplicationService,
ApplicationStopService,
ApplicationStopModule,
WorkspaceFlatApplicationMapCacheService,
],
providers: [
ApplicationResolver,
ApplicationService,
ApplicationStopService,
ApplicationGaugeService,
WorkspaceFlatApplicationMapCacheService,
provideWorkspaceScopedRepository(AgentEntity),
@@ -5,6 +5,7 @@ import { isAbsoluteUrl, isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { SdkClientChecksumsDTO } from 'src/engine/core-modules/sdk-client/dtos/sdk-client-checksums.dto';
import { getInstalledSdkMetadataModule } from 'src/engine/core-modules/sdk-client/utils/get-installed-sdk-metadata-module.util';
@@ -21,6 +22,7 @@ export class ApplicationResolver {
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationStopService: ApplicationStopService,
) {}
@Query(() => SdkClientChecksumsDTO, { nullable: true })
@@ -46,6 +48,19 @@ export class ApplicationResolver {
};
}
// Surfaces the kill switch so clients can warn users that the app is
// temporarily stopped and behaving in a degraded way. Kept as a dedicated
// query so listing applications does not trigger one Redis read per app.
@Query(() => Boolean)
async isApplicationStopped(
@Args('applicationUniversalIdentifier')
applicationUniversalIdentifier: string,
): Promise<boolean> {
return this.applicationStopService.isApplicationStopped(
applicationUniversalIdentifier,
);
}
// Resolves the display url of the logo bundled in the installed
// application's public assets, so clients never build file urls themselves.
@ResolveField(() => String, { nullable: true })
@@ -1,6 +0,0 @@
export const BACKFILL_APPLICATION_INSTALLATION_JOB_NAME =
'BackfillApplicationInstallationJob';
export type BackfillApplicationInstallationJobData = {
applicationRegistrationId: string;
};
@@ -1,22 +0,0 @@
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';
import {
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
type BackfillApplicationInstallationJobData,
} from 'src/engine/core-modules/application/jobs/backfill-application-installation.job-constants';
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
@Processor(MessageQueue.workspaceQueue)
export class BackfillApplicationInstallationJob {
constructor(
private readonly preInstalledAppsService: PreInstalledAppsService,
) {}
@Process(BACKFILL_APPLICATION_INSTALLATION_JOB_NAME)
async handle(data: BackfillApplicationInstallationJobData): Promise<void> {
await this.preInstalledAppsService.backfillApplicationOnAllWorkspaces(
data.applicationRegistrationId,
);
}
}
@@ -21,7 +21,7 @@ import {
import { buildApplicationLogEnvelopes } from 'src/engine/core-modules/event-logs/producers/application-log/build-application-log-envelopes';
import { parseApplicationLogLines } from 'src/engine/core-modules/event-logs/producers/application-log/parse-application-log-lines';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop.service';
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import type { FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
@@ -15,9 +15,7 @@ import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.modu
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.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 { BackfillApplicationInstallationJob } from 'src/engine/core-modules/application/jobs/backfill-application-installation.job';
import { UpgradeApplicationsJob } from 'src/engine/core-modules/application/jobs/upgrade-applications.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';
@@ -94,7 +92,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
LogicFunctionModule,
EnterpriseModule,
EmailingModule,
PreInstalledAppsModule,
ApplicationInstallModule,
ApplicationRegistrationModule,
ApplicationUpgradeModule,
@@ -114,7 +111,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
CleanWorkspaceDeletionWarningUserVarsJob,
UpdateWorkspaceMemberEmailJob,
GenerateSdkClientJob,
BackfillApplicationInstallationJob,
UpgradeApplicationsJob,
InstallOnboardingAppsJob,
],