From aa0ea9658281b4548f33c12bf5aab3975835cfc7 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:46:37 +0200 Subject: [PATCH] Improve app errors logs at sync (#19174) 1. Fix scrollbar Before https://github.com/user-attachments/assets/29792a71-b2dd-49f6-bb90-9d15feeb95aa After https://github.com/user-attachments/assets/939a000a-b787-4ea5-a9f0-61fbac886025 2. Introduce verbose vs non-verbose verbose = what we have today (very detailed) non-verbose = summarized (with a log to say add --verbose for full logs!) without --verbose updated_non_verbose with --verbose verbose_logs --- packages/twenty-sdk/src/cli/cli.ts | 3 - .../src/cli/commands/app-command.ts | 18 +- packages/twenty-sdk/src/cli/commands/dev.ts | 2 + .../build/manifest/manifest-watcher.ts | 4 + .../dev-mode-orchestrator-state.ts | 11 + .../dev/orchestrator/dev-mode-orchestrator.ts | 24 +- .../format-sync-error-events.spec.ts | 271 ++++++++++++++++++ .../steps/format-sync-error-events.ts | 77 +++++ .../steps/start-watchers-orchestrator-step.ts | 14 +- .../sync-application-orchestrator-step.ts | 29 +- .../steps/upload-files-orchestrator-step.ts | 61 +++- .../components/dev-ui-application-panel.tsx | 1 - .../utilities/dev/ui/components/dev-ui.tsx | 69 ++++- 13 files changed, 541 insertions(+), 43 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-error-events.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-error-events.ts diff --git a/packages/twenty-sdk/src/cli/cli.ts b/packages/twenty-sdk/src/cli/cli.ts index c635c7197a..a566847a91 100644 --- a/packages/twenty-sdk/src/cli/cli.ts +++ b/packages/twenty-sdk/src/cli/cli.ts @@ -40,9 +40,6 @@ registerCommands(program); program.exitOverride(); -const isExitPromptError = (error: unknown): boolean => - error instanceof Error && error.name === 'ExitPromptError'; - try { program.parse(); } catch (error) { diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts index 3703c1eca1..1786b1d490 100644 --- a/packages/twenty-sdk/src/cli/commands/app-command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-command.ts @@ -1,20 +1,20 @@ import { formatPath } from '@/cli/utilities/file/file-path'; import chalk from 'chalk'; import type { Command } from 'commander'; +import { SyncableEntity } from 'twenty-shared/application'; +import { EntityAddCommand } from './add'; import { AppBuildCommand } from './build'; -import { AppDevCommand } from './dev'; -import { AppInstallCommand } from './install'; -import { AppPublishCommand } from './publish'; -import { AppTypecheckCommand } from './typecheck'; -import { AppUninstallCommand } from './uninstall'; import { CatalogSyncCommand } from './catalog-sync'; import { DeployCommand } from './deploy'; +import { AppDevCommand } from './dev'; import { LogicFunctionExecuteCommand } from './exec'; +import { AppInstallCommand } from './install'; import { LogicFunctionLogsCommand } from './logs'; -import { EntityAddCommand } from './add'; +import { AppPublishCommand } from './publish'; import { registerRemoteCommands } from './remote'; import { registerServerCommands } from './server'; -import { SyncableEntity } from 'twenty-shared/application'; +import { AppTypecheckCommand } from './typecheck'; +import { AppUninstallCommand } from './uninstall'; export const registerCommands = (program: Command): void => { const buildCommand = new AppBuildCommand(); @@ -32,9 +32,11 @@ export const registerCommands = (program: Command): void => { program .command('dev [appPath]') .description('Watch and sync local application changes') - .action(async (appPath) => { + .option('-v, --verbose', 'Show detailed logs') + .action(async (appPath, options) => { await devCommand.execute({ appPath: formatPath(appPath), + verbose: options.verbose, }); }); diff --git a/packages/twenty-sdk/src/cli/commands/dev.ts b/packages/twenty-sdk/src/cli/commands/dev.ts index cb2c65c765..e23f3a450b 100644 --- a/packages/twenty-sdk/src/cli/commands/dev.ts +++ b/packages/twenty-sdk/src/cli/commands/dev.ts @@ -8,6 +8,7 @@ import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk- export type AppDevOptions = { appPath?: string; headless?: boolean; + verbose?: boolean; }; export class AppDevCommand { @@ -45,6 +46,7 @@ export class AppDevCommand { this.orchestrator = new DevModeOrchestrator({ state: orchestratorState, + verbose: options.verbose, }); await this.orchestrator.start(); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts index 4b44285662..aa5d662077 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts @@ -6,6 +6,7 @@ import { ASSETS_DIR } from 'twenty-shared/application'; export type ManifestWatcherOptions = { appPath: string; handleChangeDetected: (filePath: string) => void; + verbose?: boolean; }; const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', 'dist', '.twenty']); @@ -13,17 +14,20 @@ const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', 'dist', '.twenty']); export class ManifestWatcher { private appPath: string; private handleChangeDetected: (filePath: string, event: EventName) => void; + private verbose: boolean; private watcher: FSWatcher | null = null; constructor(options: ManifestWatcherOptions) { this.appPath = options.appPath; this.handleChangeDetected = options.handleChangeDetected; + this.verbose = options.verbose ?? false; } async start(): Promise { const appPath = this.appPath; this.watcher = chokidar.watch(this.appPath, { + ignoreInitial: !this.verbose, ignored: (filePath: string) => { const relativePath = relative(appPath, filePath); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts index c9aa00d195..93801f5f46 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts @@ -271,6 +271,17 @@ export class OrchestratorState { }); } + for (const [filePath, syncableEntity] of entityTypeMap) { + if (!entities.has(filePath)) { + entities.set(filePath, { + name: filePath, + path: filePath, + type: syncableEntity, + status: 'pending', + }); + } + } + this.entities = entities; } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts index dd600edf95..dbc4a31458 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts @@ -20,6 +20,7 @@ import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application'; export type DevModeOrchestratorOptions = { state: OrchestratorState; debounceMs?: number; + verbose?: boolean; }; export class DevModeOrchestrator { @@ -30,6 +31,7 @@ export class DevModeOrchestrator { private apiService: ApiService; private clientService: ClientService; + private verbose: boolean; private skipTypecheck = true; private checkServerStep: CheckServerOrchestratorStep; private buildManifestStep: BuildManifestOrchestratorStep; @@ -42,6 +44,7 @@ export class DevModeOrchestrator { constructor(options: DevModeOrchestratorOptions) { this.debounceMs = options.debounceMs ?? 200; this.state = options.state; + this.verbose = options.verbose ?? false; this.apiService = new ApiService({ disableInterceptors: true }); const apiService = this.apiService; @@ -59,7 +62,10 @@ export class DevModeOrchestrator { apiService, configService, }); - this.uploadFilesStep = new UploadFilesOrchestratorStep(stepDeps); + this.uploadFilesStep = new UploadFilesOrchestratorStep({ + ...stepDeps, + verbose: this.verbose, + }); this.generateApiClientStep = new GenerateApiClientOrchestratorStep({ ...stepDeps, clientService: this.clientService, @@ -68,12 +74,14 @@ export class DevModeOrchestrator { this.syncApplicationStep = new SyncApplicationOrchestratorStep({ ...stepDeps, apiService, + verbose: this.verbose, }); this.startWatchersStep = new StartWatchersOrchestratorStep({ ...stepDeps, scheduleSync: this.scheduleSync.bind(this), onFileBuilt: this.handleFileBuilt.bind(this), shouldSkipTypecheck: () => this.skipTypecheck, + verbose: this.verbose, }); } @@ -83,6 +91,14 @@ export class DevModeOrchestrator { await ensureDir(outputDir); await emptyDir(outputDir); + if (!this.verbose) { + this.state.addEvent({ + message: 'Add --verbose to see fully detailed logs', + status: 'info', + }); + this.state.notify(); + } + await this.startWatchersStep.start(); this.serverCheckInterval = setInterval(() => { @@ -160,6 +176,8 @@ export class DevModeOrchestrator { return; } + this.state.steps.ensureValidTokens.status = 'done'; + const buildResult = await this.buildManifestStep.execute({ appPath: this.state.appPath, }); @@ -190,6 +208,10 @@ export class DevModeOrchestrator { appPath: this.state.appPath, }); + if (this.state.steps.syncApplication.status === 'error') { + return; + } + if (objectsOrFieldsChanged) { await this.generateApiClientStep.execute({ appPath: this.state.appPath, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-error-events.spec.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-error-events.spec.ts new file mode 100644 index 0000000000..2721376855 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-error-events.spec.ts @@ -0,0 +1,271 @@ +import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events'; + +describe('formatSyncErrorEvents', () => { + it('should return null for null input', () => { + expect(formatSyncErrorEvents(null)).toBeNull(); + }); + + it('should return null for undefined input', () => { + expect(formatSyncErrorEvents(undefined)).toBeNull(); + }); + + it('should return null for non-object input', () => { + expect(formatSyncErrorEvents('string error')).toBeNull(); + expect(formatSyncErrorEvents(42)).toBeNull(); + }); + + it('should return null when extensions is missing', () => { + expect(formatSyncErrorEvents({ message: 'error' })).toBeNull(); + }); + + it('should return null when extensions.errors is missing', () => { + expect( + formatSyncErrorEvents({ + extensions: { summary: { totalErrors: 1 } }, + }), + ).toBeNull(); + }); + + it('should return null when extensions.summary is missing', () => { + expect( + formatSyncErrorEvents({ + extensions: { errors: {} }, + }), + ).toBeNull(); + }); + + it('should format a single error', () => { + const events = formatSyncErrorEvents({ + extensions: { + errors: { + fieldMetadata: [ + { + flatEntityMinimalInformation: { + universalIdentifier: 'field-uuid-1', + }, + errors: [ + { + code: 'INVALID_NAME', + message: 'Field name is invalid', + }, + ], + }, + ], + }, + summary: { fieldMetadata: 1, totalErrors: 1 }, + }, + }); + + expect(events).not.toBeNull(); + expect(events).toHaveLength(3); + expect(events?.[0]).toEqual({ + message: 'Sync failed with 1 error', + status: 'error', + }); + expect(events?.[1]).toEqual({ + message: 'fieldMetadata: 1 error', + status: 'error', + }); + expect(events?.[2].message).toContain('INVALID_NAME'); + expect(events?.[2].message).toContain('Field name is invalid'); + expect(events?.[2].message).toContain('field-uuid-1'); + }); + + it('should format multiple errors across metadata types', () => { + const events = formatSyncErrorEvents({ + extensions: { + errors: { + fieldMetadata: [ + { + errors: [ + { code: 'ERR_1', message: 'First error' }, + { code: 'ERR_2', message: 'Second error' }, + ], + }, + ], + objectMetadata: [ + { + errors: [{ code: 'ERR_3', message: 'Third error' }], + }, + ], + }, + summary: { fieldMetadata: 2, objectMetadata: 1, totalErrors: 3 }, + }, + }); + + expect(events).not.toBeNull(); + expect(events?.[0].message).toBe('Sync failed with 3 errors'); + expect(events?.[1].message).toBe('fieldMetadata: 2 errors'); + expect(events?.[2].message).toContain('1. ERR_1'); + expect(events?.[3].message).toContain('2. ERR_2'); + expect(events?.[4].message).toBe('objectMetadata: 1 error'); + expect(events?.[5].message).toContain('1. ERR_3'); + }); + + it('should format errors with details for both objectMetadata and fieldMetadata', () => { + const events = formatSyncErrorEvents({ + extensions: { + errors: { + objectMetadata: [ + { + flatEntityMinimalInformation: { + universalIdentifier: 'obj-uuid-1', + }, + errors: [ + { + code: 'DUPLICATE_NAME', + message: 'An object with this name already exists', + value: 'postCard', + }, + ], + }, + ], + fieldMetadata: [ + { + flatEntityMinimalInformation: { + universalIdentifier: 'field-uuid-1', + }, + errors: [ + { + code: 'INVALID_TYPE', + message: 'Field type is not supported', + value: 'UNKNOWN_TYPE', + }, + ], + }, + { + flatEntityMinimalInformation: { + universalIdentifier: 'field-uuid-2', + }, + errors: [ + { + code: 'MISSING_RELATION_TARGET', + message: 'Relation target object not found', + }, + ], + }, + ], + }, + summary: { objectMetadata: 1, fieldMetadata: 2, totalErrors: 3 }, + }, + }); + + expect(events).not.toBeNull(); + expect(events).toHaveLength(6); + + expect(events?.[0].message).toBe('Sync failed with 3 errors'); + + expect(events?.[1].message).toBe('objectMetadata: 1 error'); + expect(events?.[2].message).toContain('DUPLICATE_NAME'); + expect(events?.[2].message).toContain('value: postCard'); + expect(events?.[2].message).toContain('universalIdentifier: obj-uuid-1'); + + expect(events?.[3].message).toBe('fieldMetadata: 2 errors'); + expect(events?.[4].message).toContain('INVALID_TYPE'); + expect(events?.[4].message).toContain('value: UNKNOWN_TYPE'); + expect(events?.[4].message).toContain('universalIdentifier: field-uuid-1'); + expect(events?.[5].message).toContain('MISSING_RELATION_TARGET'); + expect(events?.[5].message).toContain('universalIdentifier: field-uuid-2'); + expect(events?.[5].message).not.toContain('value:'); + }); + + it('should include value in details when present', () => { + const events = formatSyncErrorEvents({ + extensions: { + errors: { + fieldMetadata: [ + { + errors: [ + { + code: 'INVALID_VALUE', + message: 'Bad value', + value: 'some-bad-value', + }, + ], + }, + ], + }, + summary: { fieldMetadata: 1, totalErrors: 1 }, + }, + }); + + expect(events).not.toBeNull(); + expect(events?.[2].message).toContain('value: some-bad-value'); + }); + + it('should omit details suffix when no value or universalIdentifier', () => { + const events = formatSyncErrorEvents({ + extensions: { + errors: { + fieldMetadata: [ + { + errors: [{ code: 'ERR', message: 'Something failed' }], + }, + ], + }, + summary: { fieldMetadata: 1, totalErrors: 1 }, + }, + }); + + expect(events).not.toBeNull(); + expect(events?.[2].message).toBe(' 1. ERR: Something failed'); + }); + + it('should fall back to entries.length when summary count is missing for a metadata type', () => { + const events = formatSyncErrorEvents({ + extensions: { + errors: { + fieldMetadata: [ + { + errors: [ + { code: 'ERR_1', message: 'Error one' }, + { code: 'ERR_2', message: 'Error two' }, + ], + }, + ], + }, + summary: { totalErrors: 2 }, + }, + }); + + expect(events).not.toBeNull(); + expect(events?.[1].message).toBe('fieldMetadata: 1 error'); + }); + + it('should pluralize correctly for singular and plural counts', () => { + const singleError = formatSyncErrorEvents({ + extensions: { + errors: { + objectMetadata: [ + { + errors: [{ code: 'ERR', message: 'Error' }], + }, + ], + }, + summary: { objectMetadata: 1, totalErrors: 1 }, + }, + }); + + expect(singleError?.[0].message).toBe('Sync failed with 1 error'); + expect(singleError?.[1].message).toBe('objectMetadata: 1 error'); + + const multipleErrors = formatSyncErrorEvents({ + extensions: { + errors: { + objectMetadata: [ + { + errors: [ + { code: 'ERR_1', message: 'Error 1' }, + { code: 'ERR_2', message: 'Error 2' }, + ], + }, + ], + }, + summary: { objectMetadata: 5, totalErrors: 5 }, + }, + }); + + expect(multipleErrors?.[0].message).toBe('Sync failed with 5 errors'); + expect(multipleErrors?.[1].message).toBe('objectMetadata: 5 errors'); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-error-events.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-error-events.ts new file mode 100644 index 0000000000..34fc9f9417 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-error-events.ts @@ -0,0 +1,77 @@ +import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; + +type SyncValidationEntry = { + flatEntityMinimalInformation?: { universalIdentifier?: string }; + errors: { code: string; message: string; value?: string }[]; +}; + +type StructuredSyncError = { + message?: string; + extensions?: { + code?: string; + errors?: Record; + summary?: Record & { totalErrors: number }; + message?: string; + }; +}; + +export const formatSyncErrorEvents = ( + error: unknown, +): OrchestratorStateStepEvent[] | null => { + if (!error || typeof error !== 'object') { + return null; + } + + const syncError = error as StructuredSyncError; + const extensions = syncError.extensions; + + if (!extensions?.errors || !extensions?.summary) { + return null; + } + + const events: OrchestratorStateStepEvent[] = []; + const totalErrors = extensions.summary.totalErrors; + + events.push({ + message: `Sync failed with ${totalErrors} error${totalErrors !== 1 ? 's' : ''}`, + status: 'error', + }); + + for (const [metadataName, entries] of Object.entries(extensions.errors)) { + const count = extensions.summary[metadataName] ?? entries.length; + + events.push({ + message: `${metadataName}: ${count} error${count !== 1 ? 's' : ''}`, + status: 'error', + }); + + let errorIndex = 1; + + for (const entry of entries) { + const universalIdentifier = + entry.flatEntityMinimalInformation?.universalIdentifier; + + for (const entryError of entry.errors) { + const details: string[] = []; + + if (entryError.value) { + details.push(`value: ${entryError.value}`); + } + + if (universalIdentifier) { + details.push(`universalIdentifier: ${universalIdentifier}`); + } + + const suffix = details.length > 0 ? ` (${details.join(', ')})` : ''; + + events.push({ + message: ` ${errorIndex}. ${entryError.code}: ${entryError.message}${suffix}`, + status: 'error', + }); + errorIndex++; + } + } + } + + return events; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step.ts index 42fcf2f2e3..a917cee015 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step.ts @@ -32,6 +32,7 @@ export class StartWatchersOrchestratorStep { private notify: () => void; private onFileBuilt: (event: FileBuiltEvent) => void; private shouldSkipTypecheck: () => boolean; + private verbose: boolean; private manifestWatcher: ManifestWatcher | null = null; private logicFunctionsWatcher: EsbuildWatcher | null = null; @@ -46,12 +47,14 @@ export class StartWatchersOrchestratorStep { notify: () => void; onFileBuilt: (event: FileBuiltEvent) => void; shouldSkipTypecheck: () => boolean; + verbose?: boolean; }) { this.state = options.state; this.scheduleSync = options.scheduleSync; this.notify = options.notify; this.onFileBuilt = options.onFileBuilt; this.shouldSkipTypecheck = options.shouldSkipTypecheck; + this.verbose = options.verbose ?? false; } async start(): Promise { @@ -61,6 +64,7 @@ export class StartWatchersOrchestratorStep { this.manifestWatcher = new ManifestWatcher({ appPath: this.state.appPath, handleChangeDetected: this.handleChangeDetected.bind(this), + verbose: this.verbose, }); await this.manifestWatcher.start(); @@ -133,10 +137,12 @@ export class StartWatchersOrchestratorStep { } private handleFileBuilt(event: FileBuiltEvent): void { - this.state.addEvent({ - message: `Successfully built ${event.builtPath}`, - status: 'success', - }); + if (this.verbose) { + this.state.addEvent({ + message: `Successfully built ${event.builtPath}`, + status: 'success', + }); + } this.state.steps.uploadFiles.output.builtFileInfos.set(event.builtPath, { checksum: event.checksum, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts index c9e5976714..16a0845623 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts @@ -7,6 +7,7 @@ import { type OrchestratorStateStepEvent, type OrchestratorStateSyncStatus, } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; +import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events'; import { serializeError } from '@/cli/utilities/error/serialize-error'; import { type Manifest } from 'twenty-shared/application'; @@ -19,19 +20,23 @@ export class SyncApplicationOrchestratorStep { private apiService: ApiService; private state: OrchestratorState; private notify: () => void; + private verbose: boolean; constructor({ apiService, state, notify, + verbose, }: { apiService: ApiService; state: OrchestratorState; notify: () => void; + verbose?: boolean; }) { this.apiService = apiService; this.state = state; this.notify = notify; + this.verbose = verbose ?? false; } async execute(input: { @@ -74,12 +79,28 @@ export class SyncApplicationOrchestratorStep { return; } - const errorMessage = `Sync failed with error: ${serializeError(syncResult.error)}`; + const errorEvents = this.verbose + ? null + : formatSyncErrorEvents(syncResult.error); - events.push({ message: errorMessage, status: 'error' }); - step.output = { syncStatus: 'error', error: errorMessage }; + if (errorEvents) { + events.push(...errorEvents); + events.push({ + message: 'Add --verbose to see full error log', + status: 'info', + }); + } else { + events.push({ + message: `Sync failed with error: ${serializeError(syncResult.error)}`, + status: 'error', + }); + } + + const summaryMessage = errorEvents ? errorEvents[0].message : 'Sync failed'; + + step.output = { syncStatus: 'error', error: summaryMessage }; step.status = 'error'; - this.state.updatePipeline({ status: 'error', error: errorMessage }); + this.state.updatePipeline({ status: 'error', error: summaryMessage }); this.state.updateAllEntitiesStatus('error'); this.state.applyStepEvents(events); } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts index b5ee8a5315..0a19df8421 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts @@ -14,16 +14,23 @@ export type UploadFilesOrchestratorStepOutput = { export class UploadFilesOrchestratorStep { private state: OrchestratorState; private notify: () => void; + private verbose: boolean; + private uploadedCount = 0; + private failedCount = 0; + private totalQueued = 0; constructor({ state, notify, + verbose, }: { state: OrchestratorState; notify: () => void; + verbose?: boolean; }) { this.state = state; this.notify = notify; + this.verbose = verbose ?? false; } get isInitialized(): boolean { @@ -58,11 +65,14 @@ export class UploadFilesOrchestratorStep { } step.status = 'in_progress'; + this.totalQueued++; - this.state.addEvent({ - message: `Uploading ${builtPath}`, - status: 'info', - }); + if (this.verbose) { + this.state.addEvent({ + message: `Uploading ${builtPath}`, + status: 'info', + }); + } this.state.updateEntityStatus(sourcePath, 'uploading'); this.notify(); @@ -70,12 +80,17 @@ export class UploadFilesOrchestratorStep { .uploadFile({ builtPath, fileFolder }) .then((result) => { if (result.success) { - this.state.addEvent({ - message: `Successfully uploaded ${builtPath}`, - status: 'success', - }); + this.uploadedCount++; + + if (this.verbose) { + this.state.addEvent({ + message: `Successfully uploaded ${builtPath}`, + status: 'success', + }); + } this.state.updateEntityStatus(sourcePath, 'success'); } else { + this.failedCount++; this.state.addEvent({ message: `Failed to upload ${builtPath}: ${result.error}`, status: 'error', @@ -83,6 +98,7 @@ export class UploadFilesOrchestratorStep { } }) .catch((error) => { + this.failedCount++; this.state.addEvent({ message: `Upload failed for ${builtPath}: ${error}`, status: 'error', @@ -92,6 +108,7 @@ export class UploadFilesOrchestratorStep { step.output.activeUploads.delete(uploadPromise); if (step.output.activeUploads.size === 0) { + this.logUploadSummary(); step.status = 'done'; this.notify(); } @@ -111,6 +128,34 @@ export class UploadFilesOrchestratorStep { this.notify(); } + private logUploadSummary(): void { + if (this.totalQueued === 0) { + this.resetCounters(); + + return; + } + + if (this.failedCount > 0) { + this.state.addEvent({ + message: `Uploaded ${this.uploadedCount}/${this.totalQueued} files (${this.failedCount} failed)`, + status: 'error', + }); + } + + this.state.addEvent({ + message: `Successfully uploaded ${this.uploadedCount} file${this.uploadedCount !== 1 ? 's' : ''}`, + status: 'success', + }); + + this.resetCounters(); + } + + private resetCounters(): void { + this.uploadedCount = 0; + this.failedCount = 0; + this.totalQueued = 0; + } + private uploadPendingFiles(): void { for (const [ builtPath, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-application-panel.tsx b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-application-panel.tsx index 026664d0f2..a89ef8858e 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-application-panel.tsx +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-application-panel.tsx @@ -33,7 +33,6 @@ export const DevUiSyncStatusIndicator = ({ return ( {icon} {label} - {state.pipeline.error && `: ${state.pipeline.error}`} ); }; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui.tsx b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui.tsx index 47f046a3b4..58b63ba2ff 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui.tsx +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui.tsx @@ -1,13 +1,20 @@ -import { type OrchestratorStateEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; +import { + type OrchestratorStateEvent, + type OrchestratorStateSyncStatus, +} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; import { DevUiApplicationPanel } from '@/cli/utilities/dev/ui/components/dev-ui-application-panel'; import { DevUiEntityLegend } from '@/cli/utilities/dev/ui/components/dev-ui-entity-section'; import { DevUiEventItem } from '@/cli/utilities/dev/ui/components/dev-ui-event-log'; import { InkProvider, useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context'; import { type DevUiStateManager } from '@/cli/utilities/dev/ui/dev-ui-state-manager'; -import React, { useReducer, useEffect } from 'react'; +import React, { useCallback, useEffect, useReducer, useRef } from 'react'; -const ACTIVE_PIPELINE_STATUSES = new Set(['building', 'syncing']); +const ACTIVE_PIPELINE_STATUSES = new Set([ + 'building', + 'syncing', +]); const ANIMATION_TICK_MS = 120; +const SETTLE_DELAY_MS = 80; const DevUI = ({ uiStateManager, @@ -18,22 +25,56 @@ const DevUI = ({ const [, forceRender] = useReducer((tick: number) => tick + 1, 0); - useEffect(() => { - return uiStateManager.subscribe(() => forceRender()); - }, [uiStateManager]); + const settleTimerRef = useRef | null>(null); + const lastStateRenderRef = useRef(0); - const state = uiStateManager.getSnapshot(); - const isActive = ACTIVE_PIPELINE_STATUSES.has(state.pipeline.status); - - useEffect(() => { - if (!isActive) { - return; + const scheduleSettledRender = useCallback(() => { + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); } - const timer = setInterval(() => forceRender(), ANIMATION_TICK_MS); + settleTimerRef.current = setTimeout(() => { + settleTimerRef.current = null; + lastStateRenderRef.current = Date.now(); + forceRender(); + }, SETTLE_DELAY_MS); + }, []); + + useEffect(() => { + return uiStateManager.subscribe(() => { + scheduleSettledRender(); + }); + }, [uiStateManager, scheduleSettledRender]); + + useEffect(() => { + const timer = setInterval(() => { + const snapshot = uiStateManager.getSnapshot(); + + if (!ACTIVE_PIPELINE_STATUSES.has(snapshot.pipeline.status)) { + return; + } + + // Skip if a state-change render happened recently to avoid + // double-rendering while Static items are being added. + if (Date.now() - lastStateRenderRef.current < ANIMATION_TICK_MS) { + return; + } + + forceRender(); + }, ANIMATION_TICK_MS); return () => clearInterval(timer); - }, [isActive]); + }, [uiStateManager]); + + useEffect(() => { + return () => { + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); + } + }; + }, []); + + const state = uiStateManager.getSnapshot(); return ( <>