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
<img width="1256" height="876" alt="updated_non_verbose"
src="https://github.com/user-attachments/assets/d6194c41-2366-4297-a7ac-b3f3b27e08dd"
/>


with --verbose
<img width="422" height="819" alt="verbose_logs"
src="https://github.com/user-attachments/assets/409e2e88-ec3d-4bab-957c-ef319895f8c5"
/>
This commit is contained in:
Marie
2026-04-01 15:46:37 +02:00
committed by GitHub
parent 36dece43c7
commit aa0ea96582
13 changed files with 541 additions and 43 deletions
@@ -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;
}
@@ -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,
@@ -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');
});
});
@@ -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<string, SyncValidationEntry[]>;
summary?: Record<string, number> & { 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;
};
@@ -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<void> {
@@ -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,
@@ -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);
}
@@ -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,
@@ -33,7 +33,6 @@ export const DevUiSyncStatusIndicator = ({
return (
<Text color={config.color}>
{icon} {label}
{state.pipeline.error && `: ${state.pipeline.error}`}
</Text>
);
};
@@ -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<OrchestratorStateSyncStatus>([
'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<ReturnType<typeof setTimeout> | 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 (
<>