Surface structured validation errors during application install (#19787)

## Summary
- Add `WorkspaceMigrationGraphqlApiExceptionInterceptor` to
`MarketplaceResolver` and `ApplicationInstallResolver` so validation
failures during app install return `METADATA_VALIDATION_FAILED` with
structured `extensions.errors` instead of generic
`INTERNAL_SERVER_ERROR`
- Update SDK `installTarballApp()` to pass the full GraphQL error object
(including extensions) through the install flow
- Add `formatInstallValidationErrors` utility to format structured
validation errors for CLI output
- Add integration test verifying structured error responses for invalid
navigation menu items and view fields

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Charles Bochet
2026-04-17 11:29:33 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7aa60fd20b
commit 76ea0f37ed
9 changed files with 226 additions and 31 deletions
@@ -13,7 +13,7 @@ import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { ClientService } from '@/cli/utilities/client/client-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
import { runSafe } from '@/cli/utilities/run-safe';
@@ -197,7 +197,7 @@ const innerAppDevOnce = async (
if (!syncResult.success) {
const errorEvents = verbose
? null
: formatSyncErrorEvents(syncResult.error);
: formatManifestValidationErrors(syncResult.error);
const message = errorEvents
? errorEvents.map((event) => event.message).join('\n')
@@ -1,6 +1,8 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
@@ -34,16 +36,17 @@ const innerAppInstall = async (
});
if (!result.success) {
const errorMessage =
result.error instanceof Error
? result.error.message
: String(result.error ?? 'Unknown error');
const errorEvents = formatManifestValidationErrors(result.error);
const message = errorEvents
? errorEvents.map((event) => event.message).join('\n')
: `Install failed with error: ${serializeError(result.error)}`;
return {
success: false,
error: {
code: APP_ERROR_CODES.INSTALL_FAILED,
message: errorMessage,
message,
},
};
}
@@ -161,8 +161,7 @@ export class FileApi {
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message || 'Failed to install application',
error: response.data.errors[0] || 'Failed to install application',
};
}
@@ -7,7 +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 { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { type Manifest } from 'twenty-shared/application';
@@ -81,7 +81,7 @@ export class SyncApplicationOrchestratorStep {
const errorEvents = this.verbose
? null
: formatSyncErrorEvents(syncResult.error);
: formatManifestValidationErrors(syncResult.error);
if (errorEvents) {
events.push(...errorEvents);
@@ -1,26 +1,26 @@
import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
describe('formatSyncErrorEvents', () => {
describe('formatManifestValidationErrors', () => {
it('should return null for null input', () => {
expect(formatSyncErrorEvents(null)).toBeNull();
expect(formatManifestValidationErrors(null)).toBeNull();
});
it('should return null for undefined input', () => {
expect(formatSyncErrorEvents(undefined)).toBeNull();
expect(formatManifestValidationErrors(undefined)).toBeNull();
});
it('should return null for non-object input', () => {
expect(formatSyncErrorEvents('string error')).toBeNull();
expect(formatSyncErrorEvents(42)).toBeNull();
expect(formatManifestValidationErrors('string error')).toBeNull();
expect(formatManifestValidationErrors(42)).toBeNull();
});
it('should return null when extensions is missing', () => {
expect(formatSyncErrorEvents({ message: 'error' })).toBeNull();
expect(formatManifestValidationErrors({ message: 'error' })).toBeNull();
});
it('should return null when extensions.errors is missing', () => {
expect(
formatSyncErrorEvents({
formatManifestValidationErrors({
extensions: { summary: { totalErrors: 1 } },
}),
).toBeNull();
@@ -28,14 +28,14 @@ describe('formatSyncErrorEvents', () => {
it('should return null when extensions.summary is missing', () => {
expect(
formatSyncErrorEvents({
formatManifestValidationErrors({
extensions: { errors: {} },
}),
).toBeNull();
});
it('should format a single error', () => {
const events = formatSyncErrorEvents({
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
@@ -72,7 +72,7 @@ describe('formatSyncErrorEvents', () => {
});
it('should format multiple errors across metadata types', () => {
const events = formatSyncErrorEvents({
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
@@ -103,7 +103,7 @@ describe('formatSyncErrorEvents', () => {
});
it('should format errors with details for both objectMetadata and fieldMetadata', () => {
const events = formatSyncErrorEvents({
const events = formatManifestValidationErrors({
extensions: {
errors: {
objectMetadata: [
@@ -170,7 +170,7 @@ describe('formatSyncErrorEvents', () => {
});
it('should include value in details when present', () => {
const events = formatSyncErrorEvents({
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
@@ -194,7 +194,7 @@ describe('formatSyncErrorEvents', () => {
});
it('should omit details suffix when no value or universalIdentifier', () => {
const events = formatSyncErrorEvents({
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
@@ -212,7 +212,7 @@ describe('formatSyncErrorEvents', () => {
});
it('should fall back to entries.length when summary count is missing for a metadata type', () => {
const events = formatSyncErrorEvents({
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
@@ -233,7 +233,7 @@ describe('formatSyncErrorEvents', () => {
});
it('should pluralize correctly for singular and plural counts', () => {
const singleError = formatSyncErrorEvents({
const singleError = formatManifestValidationErrors({
extensions: {
errors: {
objectMetadata: [
@@ -249,7 +249,7 @@ describe('formatSyncErrorEvents', () => {
expect(singleError?.[0].message).toBe('Sync failed with 1 error');
expect(singleError?.[1].message).toBe('objectMetadata: 1 error');
const multipleErrors = formatSyncErrorEvents({
const multipleErrors = formatManifestValidationErrors({
extensions: {
errors: {
objectMetadata: [
@@ -15,7 +15,7 @@ type StructuredSyncError = {
};
};
export const formatSyncErrorEvents = (
export const formatManifestValidationErrors = (
error: unknown,
): OrchestratorStateStepEvent[] | null => {
if (!error || typeof error !== 'object') {