From bf410ae4382ed39a0c4f3e75fb3208f91384942d Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Thu, 16 Apr 2026 18:05:02 +0200
Subject: [PATCH] `upgrade:status` command (#19584)
## Introduction
Introducing a new command in order to determine the curent twenty
instance and workspaces status as it's not stored in database but a
derivation of each current curors
## `upgrade:status` all healthy
## `upgrade:status` Nearly use cases
## `upgrade:status --failed-only`
## `upgrade:status -w aa8fdcb1-8ee1-4012-98af-44a97caa7411 -w
20202020-1c25-4d02-bf25-6aeccf7ea419 -w
20202020-1c25-4d02-bf25-6aeccf7ea412`
---
.../commands/database-command.module.ts | 2 +
.../commands/upgrade-status.command.ts | 299 ++++++++++++++++++
.../__tests__/upgrade-status.service.spec.ts | 200 ++++++++++++
.../services/upgrade-migration.service.ts | 80 +++--
.../upgrade-sequence-runner.service.ts | 8 +-
.../services/upgrade-status.service.ts | 159 ++++++++++
.../core-modules/upgrade/upgrade.module.ts | 3 +
...act-version-from-command-name.util.spec.ts | 25 ++
.../extract-version-from-command-name.util.ts | 9 +
9 files changed, 759 insertions(+), 26 deletions(-)
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/commands/upgrade-status.command.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-status.service.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util.ts
diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts
index 31d898e0b4..94452fc091 100644
--- a/packages/twenty-server/src/database/commands/database-command.module.ts
+++ b/packages/twenty-server/src/database/commands/database-command.module.ts
@@ -24,6 +24,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
+import { UpgradeStatusCommand } from 'src/engine/core-modules/upgrade/commands/upgrade-status.command';
import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
@@ -86,6 +87,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
EnterpriseKeyValidationCronCommand,
GenerateApiKeyCommand,
EnforceUsageCapCronCommand,
+ UpgradeStatusCommand,
],
})
export class DatabaseCommandModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/commands/upgrade-status.command.ts b/packages/twenty-server/src/engine/core-modules/upgrade/commands/upgrade-status.command.ts
new file mode 100644
index 0000000000..ae6f1d5f79
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/commands/upgrade-status.command.ts
@@ -0,0 +1,299 @@
+import { Logger } from '@nestjs/common';
+
+import chalk from 'chalk';
+import { Command, CommandRunner, Option } from 'nest-commander';
+
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import {
+ type MigrationCursorStatus,
+ UpgradeHealth,
+ UpgradeStatusService,
+ type WorkspaceStatus,
+} from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
+
+type UpgradeStatusOptions = {
+ workspaceId?: Set;
+ failedOnly?: boolean;
+};
+
+type GroupedWorkspaceStatuses = {
+ upToDate: WorkspaceStatus[];
+ behind: WorkspaceStatus[];
+ failed: WorkspaceStatus[];
+};
+
+const HEALTH_LABELS: Record = {
+ 'up-to-date': chalk.green('Up to date'),
+ behind: chalk.yellow('Behind'),
+ failed: chalk.red('Failed'),
+};
+
+@Command({
+ name: 'upgrade:status',
+ description:
+ 'Display upgrade status for instance and workspace commands, inferring versions from migration history',
+})
+export class UpgradeStatusCommand extends CommandRunner {
+ @Option({
+ flags: '-w, --workspace-id [workspace_id]',
+ description:
+ 'Filter to specific workspace IDs. Can be passed multiple times.',
+ required: false,
+ })
+ parseWorkspaceId(value: string, previous?: Set): Set {
+ const accumulator = previous ?? new Set();
+
+ accumulator.add(value);
+
+ return accumulator;
+ }
+
+ @Option({
+ flags: '-f, --failed-only',
+ description:
+ 'Hide up-to-date entries, only display failed and behind commands',
+ })
+ parseFailedOnly(): boolean {
+ return true;
+ }
+
+ private readonly logger = new Logger(UpgradeStatusCommand.name);
+
+ constructor(
+ private readonly upgradeStatusService: UpgradeStatusService,
+ private readonly twentyConfigService: TwentyConfigService,
+ ) {
+ super();
+ }
+
+ override async run(
+ _passedParams: string[],
+ options: UpgradeStatusOptions,
+ ): Promise {
+ try {
+ const lines: string[] = this.formatHeader();
+
+ const instanceStatus =
+ await this.upgradeStatusService.getInstanceStatus();
+
+ lines.push(...this.formatInstanceStatus(instanceStatus));
+
+ const requestedWorkspaceIds = options.workspaceId
+ ? [...options.workspaceId]
+ : undefined;
+
+ const workspaceStatuses =
+ await this.upgradeStatusService.getWorkspaceStatuses(
+ requestedWorkspaceIds,
+ );
+
+ const groupedWorkspaceStatuses =
+ this.groupWorkspaceStatusesByHealth(workspaceStatuses);
+
+ lines.push(
+ ...this.formatWorkspaceStatuses(
+ groupedWorkspaceStatuses,
+ options.failedOnly,
+ ),
+ );
+
+ lines.push(
+ ...this.formatSummary(instanceStatus, groupedWorkspaceStatuses),
+ );
+
+ console.log(lines.join('\n'));
+ } catch (error) {
+ this.logger.error(
+ chalk.red(`Failed to retrieve upgrade status: ${error.message}`),
+ );
+ }
+ }
+
+ private formatHeader(): string[] {
+ const appVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
+
+ return ['', chalk.bold(`APP_VERSION: ${appVersion}`), ''];
+ }
+
+ private formatInstanceStatus(status: MigrationCursorStatus): string[] {
+ return [
+ chalk.bold.underline('Instance'),
+ ...this.formatCursorStatus(status),
+ '',
+ ];
+ }
+
+ private formatWorkspaceStatuses(
+ { upToDate, behind, failed }: GroupedWorkspaceStatuses,
+ failedOnly?: boolean,
+ ): string[] {
+ const lines: string[] = [chalk.bold.underline('Workspace')];
+
+ if (upToDate.length === 0 && behind.length === 0 && failed.length === 0) {
+ lines.push(chalk.dim(' No active/suspended workspaces found'));
+
+ return lines;
+ }
+
+ if (!failedOnly) {
+ for (const workspaceStatus of upToDate) {
+ lines.push(...this.formatWorkspaceStatus(workspaceStatus));
+ }
+ }
+
+ for (const workspaceStatus of behind) {
+ lines.push(...this.formatWorkspaceStatus(workspaceStatus));
+ }
+
+ if (failed.length > 0) {
+ const groupedByCommand = new Map();
+
+ for (const workspaceStatus of failed) {
+ const commandName = workspaceStatus.latestCommand?.name ?? 'unknown';
+
+ if (!groupedByCommand.has(commandName)) {
+ groupedByCommand.set(commandName, []);
+ }
+
+ groupedByCommand.get(commandName)!.push(workspaceStatus);
+ }
+
+ for (const [commandName, statuses] of groupedByCommand) {
+ lines.push(chalk.red.bold(` Failed at: ${commandName}`));
+
+ for (const workspaceStatus of statuses) {
+ lines.push(...this.formatWorkspaceStatus(workspaceStatus, true));
+ }
+ }
+ }
+
+ return lines;
+ }
+
+ private formatWorkspaceStatus(
+ status: WorkspaceStatus,
+ nested = false,
+ ): string[] {
+ const baseIndent = nested ? ' ' : ' ';
+ const detailIndent = nested ? ' ' : ' ';
+ const label = status.displayName
+ ? `${status.displayName} (${status.workspaceId})`
+ : status.workspaceId;
+
+ return [
+ chalk.bold(`${baseIndent}${label}`),
+ ...this.formatCursorStatus(status, detailIndent),
+ '',
+ ];
+ }
+
+ private formatCursorStatus(
+ status: MigrationCursorStatus,
+ indent = ' ',
+ ): string[] {
+ if (!status.latestCommand) {
+ return [`${indent}Status: ${HEALTH_LABELS[status.health]}`];
+ }
+
+ const { latestCommand } = status;
+
+ const lines: string[] = [
+ `${indent}Inferred version: ${status.inferredVersion ?? chalk.dim('unknown')}`,
+ `${indent}Latest command: ${latestCommand.name}`,
+ `${indent}Status: ${HEALTH_LABELS[status.health]}`,
+ `${indent}Executed by: ${latestCommand.executedByVersion}`,
+ `${indent}At: ${latestCommand.createdAt.toISOString()}`,
+ ];
+
+ if (latestCommand.status === 'failed' && latestCommand.errorMessage) {
+ lines.push(
+ chalk.red(`${indent}Error: ${latestCommand.errorMessage}`),
+ );
+ }
+
+ return lines;
+ }
+
+ private formatSummary(
+ instanceStatus: MigrationCursorStatus,
+ { upToDate, behind, failed }: GroupedWorkspaceStatuses,
+ ): string[] {
+ const lines: string[] = [chalk.bold.underline('Summary')];
+ const totalCount = upToDate.length + behind.length + failed.length;
+
+ lines.push(` Instance: ${HEALTH_LABELS[instanceStatus.health]}`);
+
+ if (totalCount === 0) {
+ lines.push(chalk.dim(' No workspaces'));
+
+ return lines;
+ }
+
+ const parts = [
+ chalk.green(`${upToDate.length} up to date`),
+ chalk.yellow(`${behind.length} behind`),
+ chalk.red(`${failed.length} failed`),
+ ];
+
+ lines.push(` Workspaces: ${parts.join(', ')} (${totalCount} total)`);
+
+ if (behind.length > 0) {
+ const behindCounts = new Map();
+
+ for (const status of behind) {
+ const commandName = status.latestCommand?.name ?? 'no commands';
+
+ behindCounts.set(commandName, (behindCounts.get(commandName) ?? 0) + 1);
+ }
+
+ for (const [commandName, count] of behindCounts) {
+ lines.push(chalk.yellow(` ${count} behind at: ${commandName}`));
+ }
+ }
+
+ if (failed.length > 0) {
+ const failureCounts = new Map();
+
+ for (const status of failed) {
+ const commandName = status.latestCommand?.name ?? 'unknown';
+
+ failureCounts.set(
+ commandName,
+ (failureCounts.get(commandName) ?? 0) + 1,
+ );
+ }
+
+ for (const [commandName, count] of failureCounts) {
+ lines.push(chalk.red(` ${count} failed at: ${commandName}`));
+ }
+ }
+
+ lines.push('');
+
+ return lines;
+ }
+
+ private groupWorkspaceStatusesByHealth(
+ workspaceStatuses: WorkspaceStatus[],
+ ): GroupedWorkspaceStatuses {
+ const upToDate: WorkspaceStatus[] = [];
+ const behind: WorkspaceStatus[] = [];
+ const failed: WorkspaceStatus[] = [];
+
+ for (const status of workspaceStatuses) {
+ switch (status.health) {
+ case 'up-to-date':
+ upToDate.push(status);
+ break;
+ case 'behind':
+ behind.push(status);
+ break;
+ case 'failed':
+ failed.push(status);
+ break;
+ }
+ }
+
+ return { upToDate, behind, failed };
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-status.service.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-status.service.spec.ts
new file mode 100644
index 0000000000..d9fc162f2e
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-status.service.spec.ts
@@ -0,0 +1,200 @@
+import { Test } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+
+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 { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
+import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+
+const LAST_INSTANCE_COMMAND = '1.23.0_LastInstanceCommand_1780000002000';
+const LAST_WORKSPACE_COMMAND = '1.23.0_LastWorkspaceCommand_1780000003000';
+const EARLIER_COMMAND = '1.22.0_EarlierCommand_1776000001000';
+
+const MOCK_SEQUENCE = [
+ { kind: 'fast-instance', name: EARLIER_COMMAND },
+ { kind: 'fast-instance', name: LAST_INSTANCE_COMMAND },
+ { kind: 'workspace', name: LAST_WORKSPACE_COMMAND },
+];
+
+describe('UpgradeStatusService', () => {
+ let service: UpgradeStatusService;
+ let getLastAttemptedInstanceCommand: jest.Mock;
+ let getWorkspaceLastAttemptedCommandName: jest.Mock;
+ let workspaceFind: jest.Mock;
+
+ beforeEach(async () => {
+ getLastAttemptedInstanceCommand = jest.fn();
+ getWorkspaceLastAttemptedCommandName = jest.fn();
+ workspaceFind = jest.fn();
+
+ const module = await Test.createTestingModule({
+ providers: [
+ UpgradeStatusService,
+ {
+ provide: UpgradeMigrationService,
+ useValue: {
+ getLastAttemptedInstanceCommand,
+ getWorkspaceLastAttemptedCommandName,
+ },
+ },
+ {
+ provide: UpgradeSequenceReaderService,
+ useValue: {
+ getUpgradeSequence: () => MOCK_SEQUENCE,
+ },
+ },
+ {
+ provide: getRepositoryToken(WorkspaceEntity),
+ useValue: { find: workspaceFind },
+ },
+ ],
+ }).compile();
+
+ service = module.get(UpgradeStatusService);
+ });
+
+ describe('getInstanceStatus', () => {
+ it('should return up-to-date when cursor is at last instance command', async () => {
+ getLastAttemptedInstanceCommand.mockResolvedValue({
+ name: LAST_INSTANCE_COMMAND,
+ status: 'completed',
+ executedByVersion: '1.23.0',
+ errorMessage: null,
+ createdAt: new Date('2025-06-01T00:00:00Z'),
+ });
+
+ const result = await service.getInstanceStatus();
+
+ expect(result.health).toBe('up-to-date');
+ expect(result.inferredVersion).toBe('1.23.0');
+ });
+
+ it('should return behind when cursor is before last instance command', async () => {
+ getLastAttemptedInstanceCommand.mockResolvedValue({
+ name: EARLIER_COMMAND,
+ status: 'completed',
+ executedByVersion: '1.22.0',
+ errorMessage: null,
+ createdAt: new Date('2025-06-01T00:00:00Z'),
+ });
+
+ const result = await service.getInstanceStatus();
+
+ expect(result.health).toBe('behind');
+ expect(result.inferredVersion).toBe('1.22.0');
+ });
+
+ it('should return failed when latest instance command failed', async () => {
+ getLastAttemptedInstanceCommand.mockResolvedValue({
+ name: LAST_INSTANCE_COMMAND,
+ status: 'failed',
+ executedByVersion: '1.23.0',
+ errorMessage: 'column does not exist',
+ createdAt: new Date('2025-06-01T01:00:00Z'),
+ });
+
+ const result = await service.getInstanceStatus();
+
+ expect(result.health).toBe('failed');
+ expect(result.latestCommand?.errorMessage).toBe('column does not exist');
+ });
+
+ it('should return behind when no migrations exist', async () => {
+ getLastAttemptedInstanceCommand.mockResolvedValue(null);
+
+ const result = await service.getInstanceStatus();
+
+ expect(result.health).toBe('behind');
+ expect(result.inferredVersion).toBeNull();
+ expect(result.latestCommand).toBeNull();
+ });
+ });
+
+ describe('getWorkspaceStatuses', () => {
+ it('should return up-to-date for workspace at last command', async () => {
+ workspaceFind.mockResolvedValue([{ id: 'ws-1', displayName: 'Apple' }]);
+
+ getWorkspaceLastAttemptedCommandName.mockResolvedValue(
+ new Map([
+ [
+ 'ws-1',
+ {
+ workspaceId: 'ws-1',
+ name: LAST_WORKSPACE_COMMAND,
+ status: 'completed',
+ executedByVersion: '1.23.0',
+ errorMessage: null,
+ createdAt: new Date('2025-06-01T00:00:00Z'),
+ },
+ ],
+ ]),
+ );
+
+ const results = await service.getWorkspaceStatuses();
+
+ expect(results).toHaveLength(1);
+ expect(results[0].health).toBe('up-to-date');
+ });
+
+ it('should return behind for workspace not at last command', async () => {
+ workspaceFind.mockResolvedValue([
+ { id: 'ws-1', displayName: 'Apple' },
+ { id: 'ws-2', displayName: 'Google' },
+ ]);
+
+ getWorkspaceLastAttemptedCommandName.mockResolvedValue(
+ new Map([
+ [
+ 'ws-1',
+ {
+ workspaceId: 'ws-1',
+ name: LAST_WORKSPACE_COMMAND,
+ status: 'completed',
+ executedByVersion: '1.23.0',
+ errorMessage: null,
+ createdAt: new Date('2025-06-01T00:00:00Z'),
+ },
+ ],
+ [
+ 'ws-2',
+ {
+ workspaceId: 'ws-2',
+ name: EARLIER_COMMAND,
+ status: 'completed',
+ executedByVersion: '1.22.0',
+ errorMessage: null,
+ createdAt: new Date('2025-05-01T00:00:00Z'),
+ },
+ ],
+ ]),
+ );
+
+ const results = await service.getWorkspaceStatuses();
+
+ expect(results).toHaveLength(2);
+ expect(results[0].health).toBe('up-to-date');
+ expect(results[1].health).toBe('behind');
+ });
+
+ it('should return behind for workspace with no migration history', async () => {
+ workspaceFind.mockResolvedValue([{ id: 'ws-1', displayName: 'Apple' }]);
+
+ getWorkspaceLastAttemptedCommandName.mockResolvedValue(new Map());
+
+ const results = await service.getWorkspaceStatuses();
+
+ expect(results).toHaveLength(1);
+ expect(results[0].health).toBe('behind');
+ expect(results[0].latestCommand).toBeNull();
+ });
+
+ it('should return empty array when no workspaces exist', async () => {
+ workspaceFind.mockResolvedValue([]);
+ getWorkspaceLastAttemptedCommandName.mockResolvedValue(new Map());
+
+ const results = await service.getWorkspaceStatuses();
+
+ expect(results).toHaveLength(0);
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts
index af8189325e..0299e6f248 100644
--- a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts
@@ -10,9 +10,13 @@ import {
} from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
import { formatUpgradeErrorForStorage } from 'src/engine/core-modules/upgrade/utils/format-upgrade-error-for-storage.util';
-export type WorkspaceCursor = {
+export type WorkspaceLastAttemptedCommand = {
+ workspaceId: string;
name: string;
status: UpgradeMigrationStatus;
+ executedByVersion: string;
+ errorMessage: string | null;
+ createdAt: Date;
isInitial: boolean;
};
@@ -195,19 +199,24 @@ export class UpgradeMigrationService {
return { name: migration.name, status: migration.status };
}
- async getWorkspaceLastAttemptedCommandNameOrThrow(
+ async getWorkspaceLastAttemptedCommandName(
workspaceIds: string[],
- ): Promise