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
with --verbose
---
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 (
<>