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 image ## `upgrade:status` Nearly use cases image ## `upgrade:status --failed-only` image ## `upgrade:status -w aa8fdcb1-8ee1-4012-98af-44a97caa7411 -w 20202020-1c25-4d02-bf25-6aeccf7ea419 -w 20202020-1c25-4d02-bf25-6aeccf7ea412` image --- .../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> { + ): Promise> { if (workspaceIds.length === 0) { return new Map(); } - const results = await this.upgradeMigrationRepository + const migrations = await this.upgradeMigrationRepository .createQueryBuilder('migration') - .select('migration.workspaceId', 'workspaceId') - .addSelect('migration.name', 'name') - .addSelect('migration.status', 'status') - .addSelect('migration.isInitial', 'isInitial') + .select([ + 'migration.workspaceId', + 'migration.name', + 'migration.status', + 'migration.executedByVersion', + 'migration.errorMessage', + 'migration.createdAt', + 'migration.isInitial', + ]) .where({ workspaceId: In(workspaceIds), }) @@ -222,23 +231,35 @@ export class UpgradeMigrationService { .orderBy('migration.workspaceId') .addOrderBy('migration.createdAt', 'DESC') .distinctOn(['migration.workspaceId']) - .getRawMany<{ - workspaceId: string; - name: string; - status: UpgradeMigrationStatus; - isInitial: boolean; - }>(); + .getMany(); - const cursors = new Map(); + const cursors = new Map(); - for (const row of results) { - cursors.set(row.workspaceId, { - name: row.name, - status: row.status, - isInitial: row.isInitial, + for (const migration of migrations) { + if (migration.workspaceId === null) { + continue; + } + + cursors.set(migration.workspaceId, { + workspaceId: migration.workspaceId, + name: migration.name, + status: migration.status, + executedByVersion: migration.executedByVersion, + errorMessage: migration.errorMessage, + createdAt: migration.createdAt, + isInitial: migration.isInitial, }); } + return cursors; + } + + async getWorkspaceLastAttemptedCommandNameOrThrow( + workspaceIds: string[], + ): Promise> { + const cursors = + await this.getWorkspaceLastAttemptedCommandName(workspaceIds); + const missingWorkspaceIds = workspaceIds.filter( (workspaceId) => !cursors.has(workspaceId), ); @@ -286,10 +307,19 @@ export class UpgradeMigrationService { async getLastAttemptedInstanceCommand(): Promise<{ name: string; status: UpgradeMigrationStatus; + executedByVersion: string; + errorMessage: string | null; + createdAt: Date; } | null> { const migration = await this.upgradeMigrationRepository .createQueryBuilder('migration') - .select(['migration.name', 'migration.status']) + .select([ + 'migration.name', + 'migration.status', + 'migration.executedByVersion', + 'migration.errorMessage', + 'migration.createdAt', + ]) .where('migration."workspaceId" IS NULL') .andWhere('migration."isInitial" = false') .andWhere( @@ -307,7 +337,13 @@ export class UpgradeMigrationService { return null; } - return { name: migration.name, status: migration.status }; + return { + name: migration.name, + status: migration.status, + executedByVersion: migration.executedByVersion, + errorMessage: migration.errorMessage, + createdAt: migration.createdAt, + }; } async getLastAttemptedInstanceCommandOrThrow(): Promise<{ diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service.ts index 8c6404140f..a1024af5a7 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service.ts @@ -7,8 +7,8 @@ import { import { type ParsedUpgradeCommandOptions } from 'src/database/commands/upgrade-version-command/upgrade.command'; import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service'; import { - type WorkspaceCursor, UpgradeMigrationService, + WorkspaceLastAttemptedCommand, } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; import { type InstanceUpgradeStep, @@ -233,7 +233,7 @@ export class UpgradeSequenceRunnerService { private async fetchWorkspaceCursors( allActiveOrSuspendedWorkspaceIds: string[], - ): Promise> { + ): Promise> { return this.upgradeMigrationService.getWorkspaceLastAttemptedCommandNameOrThrow( allActiveOrSuspendedWorkspaceIds, ); @@ -286,7 +286,7 @@ export class UpgradeSequenceRunnerService { options, }: { workspaceCommandsSegment: WorkspaceUpgradeStep[]; - workspaceCursors: Map; + workspaceCursors: Map; allActiveOrSuspendedWorkspaceIds: string[]; options: ParsedUpgradeCommandOptions; }): Promise { @@ -331,7 +331,7 @@ export class UpgradeSequenceRunnerService { }: { sequence: UpgradeStep[]; previousWorkspaceStep: WorkspaceUpgradeStep; - workspaceCursors: Map; + workspaceCursors: Map; }): void { const barrierCursor = this.upgradeSequenceReaderService.locateStepInSequenceOrThrow({ diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status.service.ts new file mode 100644 index 0000000000..7fbdb292d3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status.service.ts @@ -0,0 +1,159 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { In, Repository } from '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 { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; +import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +export type UpgradeHealth = 'up-to-date' | 'behind' | 'failed'; + +export type MigrationCursorStatus = { + inferredVersion: string | null; + health: UpgradeHealth; + latestCommand: { + name: string; + status: UpgradeMigrationStatus; + executedByVersion: string; + errorMessage: string | null; + createdAt: Date; + } | null; +}; + +export type WorkspaceStatus = MigrationCursorStatus & { + workspaceId: string; + displayName: string | null; +}; + +const deriveHealth = ( + migration: { name: string; status: UpgradeMigrationStatus }, + lastExpectedCommandName: string | null, +): UpgradeHealth => { + if (migration.status === 'failed') { + return 'failed'; + } + + if ( + lastExpectedCommandName !== null && + migration.name !== lastExpectedCommandName + ) { + return 'behind'; + } + + return 'up-to-date'; +}; + +@Injectable() +export class UpgradeStatusService { + private readonly logger = new Logger(UpgradeStatusService.name); + + constructor( + private readonly upgradeMigrationService: UpgradeMigrationService, + private readonly upgradeSequenceReaderService: UpgradeSequenceReaderService, + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, + ) {} + + async getInstanceStatus(): Promise { + const migration = + await this.upgradeMigrationService.getLastAttemptedInstanceCommand(); + + const sequence = this.upgradeSequenceReaderService.getUpgradeSequence(); + const lastInstanceStep = [...sequence] + .reverse() + .find( + (step) => + step.kind === 'fast-instance' || step.kind === 'slow-instance', + ); + + return this.buildCursorStatus(migration, lastInstanceStep?.name ?? null); + } + + async getWorkspaceStatuses( + filterWorkspaceIds?: string[], + ): Promise { + const workspaces = await this.loadWorkspaces(filterWorkspaceIds); + + if (filterWorkspaceIds) { + const foundIds = new Set(workspaces.map((workspace) => workspace.id)); + + for (const requestedId of filterWorkspaceIds) { + if (!foundIds.has(requestedId)) { + this.logger.warn( + `Workspace ${requestedId} not found or not active/suspended`, + ); + } + } + } + + const loadedWorkspaceIds = workspaces.map((workspace) => workspace.id); + const cursors = + await this.upgradeMigrationService.getWorkspaceLastAttemptedCommandName( + loadedWorkspaceIds, + ); + + const sequence = this.upgradeSequenceReaderService.getUpgradeSequence(); + const lastStepName = + sequence.length > 0 ? sequence[sequence.length - 1].name : null; + + return workspaces.map((workspace) => ({ + ...this.buildCursorStatus( + cursors.get(workspace.id) ?? null, + lastStepName, + ), + workspaceId: workspace.id, + displayName: workspace.displayName ?? null, + })); + } + + private buildCursorStatus( + migration: { + name: string; + status: UpgradeMigrationStatus; + executedByVersion: string; + errorMessage: string | null; + createdAt: Date; + } | null, + lastExpectedCommandName: string | null, + ): MigrationCursorStatus { + if (!migration) { + return { inferredVersion: null, health: 'behind', latestCommand: null }; + } + + const health = deriveHealth(migration, lastExpectedCommandName); + + return { + inferredVersion: extractVersionFromCommandName(migration.name), + health, + latestCommand: { + name: migration.name, + status: migration.status, + executedByVersion: migration.executedByVersion, + errorMessage: migration.errorMessage, + createdAt: migration.createdAt, + }, + }; + } + + private async loadWorkspaces( + workspaceIds?: string[], + ): Promise[]> { + return this.workspaceRepository.find({ + select: ['id', 'displayName'], + where: { + ...(workspaceIds && workspaceIds.length > 0 + ? { id: In(workspaceIds) } + : {}), + activationStatus: In([ + WorkspaceActivationStatus.ACTIVE, + WorkspaceActivationStatus.SUSPENDED, + ]), + }, + order: { id: 'ASC' }, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts index fbdc70831b..bb023e391d 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts @@ -10,6 +10,7 @@ import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/s 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 { UpgradeSequenceRunnerService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service'; +import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service'; import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/workspace-command-runner.service'; import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -31,6 +32,7 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v UpgradeCommandRegistryService, UpgradeSequenceReaderService, UpgradeSequenceRunnerService, + UpgradeStatusService, ], exports: [ UpgradeMigrationService, @@ -39,6 +41,7 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v UpgradeCommandRegistryService, UpgradeSequenceReaderService, UpgradeSequenceRunnerService, + UpgradeStatusService, ], }) export class UpgradeModule {} diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name.util.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name.util.spec.ts new file mode 100644 index 0000000000..153dcab121 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name.util.spec.ts @@ -0,0 +1,25 @@ +import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util'; + +describe('extractVersionFromCommandName', () => { + it('should extract version from standard command name', () => { + expect( + extractVersionFromCommandName( + '1.21.0_BackfillDatasourceCommand_1775500003000', + ), + ).toBe('1.21.0'); + }); + + it('should extract version with different version numbers', () => { + expect( + extractVersionFromCommandName('1.22.0_SomeCommand_1780000001000'), + ).toBe('1.22.0'); + }); + + it('should return null for names without underscores', () => { + expect(extractVersionFromCommandName('nounderscores')).toBeNull(); + }); + + it('should handle empty string', () => { + expect(extractVersionFromCommandName('')).toBeNull(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util.ts new file mode 100644 index 0000000000..ae589dc307 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util.ts @@ -0,0 +1,9 @@ +export const extractVersionFromCommandName = (name: string): string | null => { + const firstUnderscore = name.indexOf('_'); + + if (firstUnderscore === -1) { + return null; + } + + return name.substring(0, firstUnderscore); +};