From 77d1e8ced6002fed2486446982f367bdf15113ed Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 8 Jun 2026 17:43:28 +0200 Subject: [PATCH] feat(app-dev): sync error hints, flatEntity labels, dev-mode summary UI, and docs (#21252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of #21240 — all remaining app-dev improvements. Stacked on #21251 (review/merge that first). - Actionable recovery hints on failed syncs; unified diff renderer; `--dry-run` guard. - Return `flatEntity` on update/delete sync actions and unify the diff label. - Summarize the dev-mode entity list unless `--verbose`. - Docs: syncing & recovery guide + dry-run + open-an-issue prompt. - Live execution mode for synced logic functions; clearer manifest warnings. image --- .../apps/getting-started/quick-start.mdx | 4 +- .../extend/apps/operations/overview.mdx | 3 + .../apps/operations/sync-and-recovery.mdx | 111 +++++++ packages/twenty-docs/docs.json | 14 + .../navigation/base-structure.json | 1 + .../twenty-sdk/src/cli/commands/dev/dev.ts | 2 +- .../twenty-sdk/src/cli/commands/dev/index.ts | 11 +- .../twenty-sdk/src/cli/operations/dev-once.ts | 21 +- .../twenty-sdk/src/cli/operations/install.ts | 3 +- .../cli/utilities/api/api-response-type.ts | 14 +- .../src/cli/utilities/api/api-service.ts | 16 +- .../src/cli/utilities/api/application-api.ts | 46 ++- .../src/cli/utilities/api/file-api.ts | 17 +- .../__tests__/manifest-validate.spec.ts | 54 +++- .../build/manifest/manifest-validate.ts | 18 +- .../dev/orchestrator/dev-mode-orchestrator.ts | 2 +- .../sync-application-orchestrator-step.ts | 10 +- .../summarize-entity-statuses.spec.ts | 35 +++ .../components/dev-ui-application-panel.tsx | 19 +- .../ui/components/dev-ui-entity-section.tsx | 30 ++ .../utilities/dev/ui/components/dev-ui.tsx | 10 +- .../cli/utilities/dev/ui/dev-ui-constants.ts | 52 +++- .../format-manifest-validation-errors.spec.ts | 286 +++++++++--------- .../get-sync-error-recovery-hint.spec.ts | 36 +++ .../format-manifest-validation-errors.ts | 50 ++- .../error/get-sync-error-recovery-hint.ts | 19 ++ .../application-sync.service.ts | 29 +- ...t-to-universal-flat-logic-function.util.ts | 2 +- .../types/universal-flat-entity-diff.type.ts | 11 + ...kspace-entity-migration-builder.service.ts | 25 +- ...-delete-workspace-migration-action.type.ts | 2 + ...-update-workspace-migration-action.type.ts | 4 + ...rkspace-migration.integration-spec.ts.snap | 49 ++- .../src/constants/DocumentationPaths.ts | 2 + 34 files changed, 716 insertions(+), 292 deletions(-) create mode 100644 packages/twenty-docs/developers/extend/apps/operations/sync-and-recovery.mdx create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/ui/__tests__/summarize-entity-statuses.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/error/__tests__/get-sync-error-recovery-hint.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/error/get-sync-error-recovery-hint.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-diff.type.ts diff --git a/packages/twenty-docs/developers/extend/apps/getting-started/quick-start.mdx b/packages/twenty-docs/developers/extend/apps/getting-started/quick-start.mdx index a3bf941762..74e15e5f6e 100644 --- a/packages/twenty-docs/developers/extend/apps/getting-started/quick-start.mdx +++ b/packages/twenty-docs/developers/extend/apps/getting-started/quick-start.mdx @@ -127,14 +127,16 @@ yarn twenty dev --once |---------|----------|-------------| | `yarn twenty dev` | Watches and re-syncs on every change. Runs until you stop it. | Interactive local development. | | `yarn twenty dev --once` | Single build + sync, exits `0` on success, `1` on failure. | CI, pre-commit hooks, AI agents, scripted workflows. | +| `yarn twenty dev --once --dry-run` | Builds and prints the metadata changes **without applying them**. | Inspecting what a sync would change before committing to it. | -Both modes need an authenticated remote. +Both modes need an authenticated remote. See [Syncing & recovery](/developers/extend/apps/operations/sync-and-recovery#previewing-changes-dry-run) for more on `--dry-run`. ### Dev mode options | Flag | Description | |------|-------------| | `--once` | Build and sync once, then exit. | +| `--dry-run` | With `--once`, preview the metadata changes without applying them. Writes nothing. | | `--debounceMs ` | Set the file-change debounce delay in milliseconds (default: `2000`). | | `--verbose` / `--debug` | Show detailed build logs, sync requests, and error traces. | diff --git a/packages/twenty-docs/developers/extend/apps/operations/overview.mdx b/packages/twenty-docs/developers/extend/apps/operations/overview.mdx index dacf74948f..61b04833b2 100644 --- a/packages/twenty-docs/developers/extend/apps/operations/overview.mdx +++ b/packages/twenty-docs/developers/extend/apps/operations/overview.mdx @@ -20,6 +20,9 @@ The **operations layer** is everything you do *to* your app rather than *with* i `yarn twenty` reference — exec, logs, uninstall, remotes. + + Which command when, reading the sync diff, and a recovery ladder. + Vitest setup, integration tests, type checking, CI workflow. diff --git a/packages/twenty-docs/developers/extend/apps/operations/sync-and-recovery.mdx b/packages/twenty-docs/developers/extend/apps/operations/sync-and-recovery.mdx new file mode 100644 index 0000000000..f5ed5c0e7b --- /dev/null +++ b/packages/twenty-docs/developers/extend/apps/operations/sync-and-recovery.mdx @@ -0,0 +1,111 @@ +--- +title: Syncing & recovery +description: Which command to use when, how to read the sync output, and a recovery ladder for when local metadata drifts — before reaching a full reset. +icon: "compass" +--- + +Local app development revolves around **syncing**: the CLI rebuilds your manifest and the server applies only the difference between it and the metadata already in your workspace. This page covers which command to reach for, how to read what a sync changed, and what to do — in order — when local state looks inconsistent. + +## Which command, when + + +For day-to-day local iteration you almost always want `yarn twenty dev`. Deploying and publishing are for shipping releases, **not** for the local loop. + + +| You want to… | Command | Notes | +| --- | --- | --- | +| Iterate locally with live sync | `yarn twenty dev` | Watches your files and syncs on every change. | +| Sync once and exit (CI, scripts, hooks) | `yarn twenty dev --once` | One build + sync, then exits. | +| Preview changes **without applying them** | `yarn twenty dev --once --dry-run` | Computes and prints the diff; writes nothing. | +| Remove the app from the workspace | `yarn twenty app:uninstall` | Add `--yes` to skip the prompt. | +| Ship a tarball to a server | `yarn twenty app:publish --private` | Requires a **strictly higher** `package.json` version — see [Publishing](/developers/extend/apps/operations/publishing). | +| Publish to the marketplace (npm) | `yarn twenty app:publish` | — | +| Install / upgrade a deployed version | `yarn twenty app:install` | Installs the version currently deployed. | +| Wipe the local server and start clean | `yarn twenty docker:reset` | Deletes **all** local data — last resort. | + +### Local sync does not need a version bump + +The strictly-increasing `version` rule (`VERSION_ALREADY_EXISTS` on deploy, `APP_ALREADY_INSTALLED` / `CANNOT_DOWNGRADE_APPLICATION` on install) applies to **`app:publish` / `app:install`** — the release path. `yarn twenty dev` syncs your manifest in place and never requires a version change, so you don't need to touch `package.json` to iterate. If you find yourself bumping the version to test a local change, you're using the release path when you want the dev loop. + +## Reading the sync output + +Every sync prints the metadata changes it applied (or would apply, with `--dry-run`): + +```text filename="Terminal" +Metadata changes: 2 created, 1 updated, 1 deleted + created objectMetadata rocket + created fieldMetadata timelineActivities + updated fieldMetadata launchedAt + deleted pageLayout legacyTab +✓ Synced +``` + +This is your first diagnostic: it tells you exactly which objects, fields, and layouts changed, so you can confirm a sync did what you expected before checking the UI. + +When a sync fails on a single entity, the error names the offending entity and its `universalIdentifier`, for example: + +```text +Migration action 'create' for 'fieldMetadata' (universalIdentifier: 2020...4337) failed +``` + +Use that identifier to find the entity in your manifest (and, if needed, in the workspace) instead of guessing which one conflicts. + +## Previewing changes (dry run) + +`yarn twenty dev --once --dry-run` builds your manifest, asks the server for the migration plan, and prints it — **without applying anything**. It's the safe way to answer "what would this sync change?" before committing to it. + +```bash filename="Terminal" +yarn twenty dev --once --dry-run +``` + +```text filename="Terminal" +Building manifest... +Computing metadata diff (dry run, nothing will be applied)... +Metadata changes: 1 created, 1 updated + created fieldMetadata timelineActivities + updated objectMetadata rocket +✓ Dry run complete for My App — no changes were applied +``` + +A dry run: + +- **Writes nothing** — no metadata migration, no application record update, no default role/tab changes, and no API client generation. +- Returns the **same diff** a real sync would apply, so you can review created/updated/deleted entities up front. +- Is useful before a risky change, when reviewing an AI-generated change, or in a script that should fail if an unexpected change is about to land. + + +A dry run only previews **metadata** changes, and it requires the app to have been synced at least once (so the workspace knows about it). If you run it against an app that was never synced, the server reports that the app is not installed — run `yarn twenty dev` once first. + + +## Recovery ladder + +When local metadata looks wrong, escalate in this order and stop as soon as you're unblocked. Each step is more disruptive than the last. + +1. **Re-sync.** Run `yarn twenty dev --once` again. Syncs are idempotent — re-running a clean manifest is safe and often resolves a transient hiccup. +2. **Preview the plan.** Run `yarn twenty dev --once --dry-run` to see exactly what the next sync intends to change, without applying it. +3. **Read the named error.** If a sync fails, note the metadata type and `universalIdentifier` in the message (see above) and locate that entity in your manifest. A conflict usually points to a duplicated or re-used identifier. +4. **Uninstall and reinstall.** `yarn twenty app:uninstall`, then sync again (`yarn twenty dev`). This rebuilds the app's metadata from a clean slate while keeping the rest of your workspace intact. +5. **Full reset (last resort).** `yarn twenty docker:reset`, then re-seed and re-sync. + + +`yarn twenty docker:reset` deletes **all** data in your local instance — every workspace, record, and app. Only use it once the earlier steps have failed. + + + +Hit a metadata error? Please [open an issue](https://github.com/twentyhq/twenty/issues/new/choose) and include the failing migration message (with its metadata type and `universalIdentifier`), the `Metadata changes` output from the sync, and the commands you ran. + + +## Avoid concurrent syncs on one workspace + +Syncing applies metadata migrations. Running several sync, deploy, or install operations against the **same workspace at the same time** — for example, multiple terminals or AI agents iterating in parallel — can interleave those migrations and leave metadata in a partially-applied state. + +The server serializes syncs per workspace to prevent this, but you should still funnel sensitive metadata operations through a **single** process rather than firing them concurrently. If you orchestrate development with multiple agents, route their sync/deploy/install calls through one queue so only one runs at a time. + +## Telling failures apart + +When something goes wrong, the metadata diff and named errors let you place the failure: + +- **Manifest build error** — the CLI fails before syncing (`MANIFEST_BUILD_FAILED`, `TYPECHECK_FAILED`); fix your app source. +- **Sync / migration error** — the build succeeds but applying the diff fails, naming the entity and `universalIdentifier`; fix the conflicting metadata. +- **App code runtime error** — the sync succeeds but your logic functions or components misbehave at runtime; check [function logs](/developers/extend/apps/operations/cli). +- **Local instance state** — none of the above and the workspace still looks wrong; work down the recovery ladder. diff --git a/packages/twenty-docs/docs.json b/packages/twenty-docs/docs.json index 59dfbb7804..c46f011fb8 100644 --- a/packages/twenty-docs/docs.json +++ b/packages/twenty-docs/docs.json @@ -429,6 +429,7 @@ "pages": [ "developers/extend/apps/operations/overview", "developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] @@ -861,6 +862,7 @@ "pages": [ "developers/extend/apps/operations/overview", "developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] @@ -1293,6 +1295,7 @@ "pages": [ "developers/extend/apps/operations/overview", "developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] @@ -1725,6 +1728,7 @@ "pages": [ "developers/extend/apps/operations/overview", "developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] @@ -2157,6 +2161,7 @@ "pages": [ "l/de/developers/extend/apps/operations/overview", "l/de/developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "l/de/developers/extend/apps/operations/testing", "l/de/developers/extend/apps/operations/publishing" ] @@ -2589,6 +2594,7 @@ "pages": [ "developers/extend/apps/operations/overview", "developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] @@ -3021,6 +3027,7 @@ "pages": [ "developers/extend/apps/operations/overview", "developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] @@ -3453,6 +3460,7 @@ "pages": [ "developers/extend/apps/operations/overview", "developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] @@ -3885,6 +3893,7 @@ "pages": [ "developers/extend/apps/operations/overview", "developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] @@ -4317,6 +4326,7 @@ "pages": [ "l/pt/developers/extend/apps/operations/overview", "l/pt/developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "l/pt/developers/extend/apps/operations/testing", "l/pt/developers/extend/apps/operations/publishing" ] @@ -4749,6 +4759,7 @@ "pages": [ "l/ro/developers/extend/apps/operations/overview", "l/ro/developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "l/ro/developers/extend/apps/operations/testing", "l/ro/developers/extend/apps/operations/publishing" ] @@ -5181,6 +5192,7 @@ "pages": [ "l/ru/developers/extend/apps/operations/overview", "l/ru/developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "l/ru/developers/extend/apps/operations/testing", "l/ru/developers/extend/apps/operations/publishing" ] @@ -5613,6 +5625,7 @@ "pages": [ "l/tr/developers/extend/apps/operations/overview", "l/tr/developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "l/tr/developers/extend/apps/operations/testing", "l/tr/developers/extend/apps/operations/publishing" ] @@ -6045,6 +6058,7 @@ "pages": [ "l/zh/developers/extend/apps/operations/overview", "l/zh/developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "l/zh/developers/extend/apps/operations/testing", "l/zh/developers/extend/apps/operations/publishing" ] diff --git a/packages/twenty-docs/navigation/base-structure.json b/packages/twenty-docs/navigation/base-structure.json index e71ebc0081..88dc4862b9 100644 --- a/packages/twenty-docs/navigation/base-structure.json +++ b/packages/twenty-docs/navigation/base-structure.json @@ -431,6 +431,7 @@ "pages": [ "developers/extend/apps/operations/overview", "developers/extend/apps/operations/cli", + "developers/extend/apps/operations/sync-and-recovery", "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] diff --git a/packages/twenty-sdk/src/cli/commands/dev/dev.ts b/packages/twenty-sdk/src/cli/commands/dev/dev.ts index b808633865..70e6c76412 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/dev.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/dev.ts @@ -45,7 +45,7 @@ export class AppDevCommand { orchestratorState.onChange = () => uiStateManager.notify(); - const { unmount } = await renderDevUI(uiStateManager); + const { unmount } = await renderDevUI(uiStateManager, options.verbose); this.unmountUI = unmount; diff --git a/packages/twenty-sdk/src/cli/commands/dev/index.ts b/packages/twenty-sdk/src/cli/commands/dev/index.ts index 283cfb7b53..9fd9144ac1 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/index.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/index.ts @@ -1,4 +1,5 @@ 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'; @@ -25,6 +26,14 @@ export const registerDevCommands = (program: Command): void => { dryRun?: boolean; }, ) => { + if (options.dryRun && !options.once) { + console.warn( + chalk.yellow( + '--dry-run only applies with --once. Ignoring it; run `yarn twenty dev --once --dry-run` to preview changes.', + ), + ); + } + const commonOptions = { appPath: formatPath(appPath), verbose: options.verbose || options.debug, @@ -56,7 +65,7 @@ export const registerDevCommands = (program: Command): void => { '--dry-run', 'Preview the metadata changes without applying them (requires --once)', ) - .option('--debounceMs ', 'Debounce in ms (default: 2 000)') + .option('--debounceMs ', 'Debounce in ms (default: 1 000)') .option('-v, --verbose', 'Show detailed logs') .option('-d, --debug', 'Show detailed logs (alias for --verbose)') .action(devAction); diff --git a/packages/twenty-sdk/src/cli/operations/dev-once.ts b/packages/twenty-sdk/src/cli/operations/dev-once.ts index e5a4c85583..0fbcc870ab 100644 --- a/packages/twenty-sdk/src/cli/operations/dev-once.ts +++ b/packages/twenty-sdk/src/cli/operations/dev-once.ts @@ -16,10 +16,12 @@ import { ClientService } from '@/cli/utilities/client/client-service'; import { ConfigService } from '@/cli/utilities/config/config-service'; import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary'; import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors'; +import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint'; import { serializeError } from '@/cli/utilities/error/serialize-error'; import { FileUploader } from '@/cli/utilities/file/file-uploader'; import { runSafe } from '@/cli/utilities/run-safe'; import { APP_ERROR_CODES, type CommandResult } from '@/cli/types'; +import chalk from 'chalk'; export type AppDevOnceOptions = { appPath: string; @@ -44,6 +46,15 @@ const reportMetadataChanges = ( } }; +const appendRecoveryHint = ( + message: string, + errorMessage: string | undefined, +): string => { + const hint = getSyncErrorRecoveryHint(errorMessage); + + return hint ? `${message}\n\n${hint}` : message; +}; + const innerAppDevOnce = async ( options: AppDevOnceOptions, ): Promise> => { @@ -95,7 +106,7 @@ const innerAppDevOnce = async ( } for (const warning of manifestResult.warnings) { - onProgress?.(`⚠ ${warning}`); + onProgress?.(chalk.yellow(`⚠ ${warning}`)); } onProgress?.('Building application files...'); @@ -148,13 +159,13 @@ const innerAppDevOnce = async ( const message = errorEvents ? errorEvents.map((event) => event.message).join('\n') - : `Dry run failed with error: ${serializeError(dryRunResult.error)}`; + : `Dry run failed with error: ${dryRunResult.message ?? 'Unknown error'}`; return { success: false, error: { code: APP_ERROR_CODES.SYNC_FAILED, - message, + message: appendRecoveryHint(message, dryRunResult.message), }, }; } @@ -254,13 +265,13 @@ const innerAppDevOnce = async ( const message = errorEvents ? errorEvents.map((event) => event.message).join('\n') - : `Sync failed with error: ${serializeError(syncResult.error)}`; + : `Sync failed with error: ${syncResult.message ?? 'Unknown error'}`; return { success: false, error: { code: APP_ERROR_CODES.SYNC_FAILED, - message, + message: appendRecoveryHint(message, syncResult.message), }, }; } diff --git a/packages/twenty-sdk/src/cli/operations/install.ts b/packages/twenty-sdk/src/cli/operations/install.ts index 7a5379839f..a9b2925b2f 100644 --- a/packages/twenty-sdk/src/cli/operations/install.ts +++ b/packages/twenty-sdk/src/cli/operations/install.ts @@ -2,7 +2,6 @@ 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'; @@ -40,7 +39,7 @@ const innerAppInstall = async ( const message = errorEvents ? errorEvents.map((event) => event.message).join('\n') - : `Install failed with error: ${serializeError(result.error)}`; + : `Install failed with error: ${result.message ?? 'Unknown error'}`; return { success: false, diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts b/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts index ed65e535f1..a05fee44ce 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts @@ -1,13 +1,13 @@ -type SuccessfulApiResponse = { +type SuccessfulApiResponse = { success: true; - data: T; + data: TData; message?: string; }; -type FailingApiResponse = { +type FailingApiResponse = { success: false; - error?: unknown; + error?: TError; message?: string; }; -export type ApiResponse = - | SuccessfulApiResponse - | FailingApiResponse; +export type ApiResponse = + | SuccessfulApiResponse + | FailingApiResponse; diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts index 0e2a3bbf05..e17075d03c 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -5,7 +5,10 @@ import { FileApi } from '@/cli/utilities/api/file-api'; import { LogicFunctionApi } from '@/cli/utilities/api/logic-function-api'; import { SchemaApi } from '@/cli/utilities/api/schema-api'; import { type Manifest } from 'twenty-shared/application'; -import { type SyncAction } from 'twenty-shared/metadata'; +import { + type MetadataValidationErrorResponse, + type SyncAction, +} from 'twenty-shared/metadata'; type ApiServiceOptions = { disableInterceptors?: boolean; @@ -77,10 +80,13 @@ export class ApiService { manifest: Manifest, options?: { dryRun?: boolean }, ): Promise< - ApiResponse<{ - applicationUniversalIdentifier: string; - actions: SyncAction[]; - }> + ApiResponse< + { + applicationUniversalIdentifier: string; + actions: SyncAction[]; + }, + MetadataValidationErrorResponse + > > { return this.applicationApi.syncApplication(manifest, options); } diff --git a/packages/twenty-sdk/src/cli/utilities/api/application-api.ts b/packages/twenty-sdk/src/cli/utilities/api/application-api.ts index e2f5f7b699..2e579db2d2 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/application-api.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/application-api.ts @@ -1,7 +1,11 @@ import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; -import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import { serializeError } from '@/cli/utilities/error/serialize-error'; +import axios, { type AxiosInstance } from 'axios'; import { type Manifest } from 'twenty-shared/application'; -import { type SyncAction } from 'twenty-shared/metadata'; +import { + type MetadataValidationErrorResponse, + type SyncAction, +} from 'twenty-shared/metadata'; export class ApplicationApi { constructor(private readonly client: AxiosInstance) {} @@ -256,10 +260,13 @@ export class ApplicationApi { manifest: Manifest, options?: { dryRun?: boolean }, ): Promise< - ApiResponse<{ - applicationUniversalIdentifier: string; - actions: SyncAction[]; - }> + ApiResponse< + { + applicationUniversalIdentifier: string; + actions: SyncAction[]; + }, + MetadataValidationErrorResponse + > > { try { const mutation = ` @@ -273,7 +280,7 @@ export class ApplicationApi { const variables = { manifest, dryRun: options?.dryRun ?? false }; - const response: AxiosResponse = await this.client.post( + const response = await this.client.post( '/metadata', { query: mutation, @@ -290,7 +297,8 @@ export class ApplicationApi { if (response.data.errors) { return { success: false, - error: response.data.errors[0], + error: response.data.errors[0]?.extensions, + message: response.data.errors[0]?.message, }; } @@ -300,27 +308,9 @@ export class ApplicationApi { message: `Successfully synced application: ${manifest.application.displayName}`, }; } catch (error) { - if (axios.isAxiosError(error) && error.response) { - const graphqlErrors = error.response.data?.errors; - - if (Array.isArray(graphqlErrors) && graphqlErrors.length > 0) { - return { - success: false, - error: graphqlErrors[0]?.message || error.message, - }; - } - - return { - success: false, - error: - error.response.data?.message || - `HTTP ${error.response.status}: ${error.message}`, - }; - } - return { success: false, - error: error instanceof Error ? error.message : error, + message: serializeError(error), }; } } @@ -337,7 +327,7 @@ export class ApplicationApi { const variables = { universalIdentifier }; - const response: AxiosResponse = await this.client.post( + const response = await this.client.post( '/metadata', { query: mutation, diff --git a/packages/twenty-sdk/src/cli/utilities/api/file-api.ts b/packages/twenty-sdk/src/cli/utilities/api/file-api.ts index 2e6f486774..ab25a12f7c 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/file-api.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/file-api.ts @@ -1,7 +1,9 @@ import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; +import { serializeError } from '@/cli/utilities/error/serialize-error'; import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; import * as fs from 'fs'; import * as path from 'path'; +import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata'; import { type FileFolder } from 'twenty-shared/types'; import { pascalCase } from 'twenty-shared/utils'; @@ -136,7 +138,7 @@ export class FileApi { universalIdentifier, }: { universalIdentifier: string; - }): Promise> { + }): Promise> { try { const mutation = ` mutation InstallApplication($universalIdentifier: String!) { @@ -163,7 +165,9 @@ export class FileApi { if (response.data.errors) { return { success: false, - error: response.data.errors[0] || 'Failed to install application', + error: response.data.errors[0]?.extensions, + message: + response.data.errors[0]?.message || 'Failed to install application', }; } @@ -172,16 +176,9 @@ export class FileApi { data: response.data.data.installApplication, }; } catch (error) { - if (axios.isAxiosError(error) && error.response) { - return { - success: false, - error: error.response.data?.errors?.[0]?.message || error.message, - }; - } - return { success: false, - error, + message: serializeError(error), }; } } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts index 96817cd97d..e6f2c759d8 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts @@ -149,9 +149,6 @@ describe('manifestValidate', () => { expect(result.errors).toContain( 'Duplicate universal identifiers: 550e8400-e29b-41d4-a716-446655440001', ); - expect(result.warnings).toContain('No object defined'); - expect(result.warnings).toContain('No logic function defined'); - expect(result.warnings).toContain('No front component defined'); }); it('should fail when extension field ID conflicts with object field ID', () => { @@ -192,9 +189,6 @@ describe('manifestValidate', () => { expect(result.errors).toContain( 'Duplicate universal identifiers: 550e8400-e29b-41d4-a716-446655440001', ); - expect(result.warnings).not.toContain('No object defined'); - expect(result.warnings).toContain('No logic function defined'); - expect(result.warnings).toContain('No front component defined'); }); }); @@ -366,6 +360,54 @@ describe('manifestValidate', () => { }); }); + describe('agent responseFormat validation', () => { + it('should warn for each agent without a responseFormat', () => { + const result = manifestValidate({ + ...validManifest, + agents: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440040', + name: 'agentWithoutFormat', + label: 'Agent Without Format', + prompt: 'Do something', + }, + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440041', + name: 'anotherAgentWithoutFormat', + label: 'Another Agent Without Format', + prompt: 'Do something else', + }, + ], + }); + + expect(result.warnings).toContain( + 'Agent "agentWithoutFormat" has no responseFormat defined', + ); + expect(result.warnings).toContain( + 'Agent "anotherAgentWithoutFormat" has no responseFormat defined', + ); + }); + + it('should not warn for an agent that has a responseFormat', () => { + const result = manifestValidate({ + ...validManifest, + agents: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440042', + name: 'agentWithFormat', + label: 'Agent With Format', + prompt: 'Do something', + responseFormat: { type: 'text' }, + }, + ], + }); + + expect(result.warnings).not.toContain( + 'Agent "agentWithFormat" has no responseFormat defined', + ); + }); + }); + describe('UUID version validation', () => { it('should pass with UUID v4 identifiers', () => { const result = manifestValidate({ diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts index 721802e11b..7657fbaea3 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts @@ -2,7 +2,7 @@ import { validate as uuidValidate, version as uuidVersion } from 'uuid'; import { type FieldManifest, type Manifest } from 'twenty-shared/application'; import { FieldMetadataType, RelationType } from 'twenty-shared/types'; -import { isNonEmptyArray } from 'twenty-shared/utils'; +import { isDefined } from 'twenty-shared/utils'; const MIN_UUID_VERSION = 4; @@ -150,20 +150,14 @@ export const manifestValidate = (manifest: Manifest) => { if (invalidUniversalIdentifiers.length > 0) { errors.push( - `Duplicate universal identifiers: ${invalidUniversalIdentifiers.join(', ')}`, + `Invalid universal identifiers: ${invalidUniversalIdentifiers.join(', ')}`, ); } - if (!isNonEmptyArray(manifest.objects)) { - warnings.push('No object defined'); - } - - if (!isNonEmptyArray(manifest.logicFunctions)) { - warnings.push('No logic function defined'); - } - - if (!isNonEmptyArray(manifest.frontComponents)) { - warnings.push('No front component defined'); + for (const agent of manifest.agents) { + if (!isDefined(agent.responseFormat)) { + warnings.push(`Agent "${agent.name}" has no responseFormat defined`); + } } const allFields: Pick< 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 8a10435355..d5b46733a1 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 @@ -42,7 +42,7 @@ export class DevModeOrchestrator { private startWatchersStep: StartWatchersOrchestratorStep; constructor(options: DevModeOrchestratorOptions) { - this.debounceMs = options.debounceMs ?? 2_000; + this.debounceMs = options.debounceMs ?? 1_000; this.state = options.state; this.verbose = options.verbose ?? false; 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 d70f4ba81b..59f6dd3eb5 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 @@ -9,7 +9,7 @@ import { } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary'; import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors'; -import { serializeError } from '@/cli/utilities/error/serialize-error'; +import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint'; import { type Manifest } from 'twenty-shared/application'; export type SyncApplicationOrchestratorStepOutput = { @@ -95,11 +95,17 @@ export class SyncApplicationOrchestratorStep { }); } else { events.push({ - message: `Sync failed with error: ${serializeError(syncResult.error)}`, + message: `Sync failed with error: ${syncResult.message ?? 'Sync failed'}`, status: 'error', }); } + const recoveryHint = getSyncErrorRecoveryHint(syncResult.message); + + if (recoveryHint) { + events.push({ message: recoveryHint, status: 'info' }); + } + const summaryMessage = errorEvents ? errorEvents[0].message : 'Sync failed'; step.output = { syncStatus: 'error', error: summaryMessage }; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/__tests__/summarize-entity-statuses.spec.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/__tests__/summarize-entity-statuses.spec.ts new file mode 100644 index 0000000000..e14a1f09ec --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/__tests__/summarize-entity-statuses.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { type OrchestratorStateEntityInfo } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; +import { summarizeEntityStatuses } from '@/cli/utilities/dev/ui/dev-ui-constants'; + +const entity = ( + name: string, + status: OrchestratorStateEntityInfo['status'], +): OrchestratorStateEntityInfo => ({ name, path: name, status }); + +describe('summarizeEntityStatuses', () => { + it('counts every status in display order', () => { + const parts = summarizeEntityStatuses([ + entity('a', 'success'), + entity('b', 'success'), + entity('c', 'error'), + entity('d', 'building'), + ]); + + expect(parts).toEqual([ + { status: 'success', count: 2, label: 'synced' }, + { status: 'building', count: 1, label: 'building' }, + { status: 'error', count: 1, label: 'error' }, + ]); + }); + + it('returns only the non-zero statuses', () => { + const parts = summarizeEntityStatuses([ + entity('a', 'success'), + entity('b', 'success'), + ]); + + expect(parts).toEqual([{ status: 'success', count: 2, label: 'synced' }]); + }); +}); 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 ac1c75db06..43296d19b0 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 @@ -15,6 +15,7 @@ import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks'; import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context'; import { DevUiEntitySection, + DevUiEntitySummary, ENTITY_ORDER, } from '@/cli/utilities/dev/ui/components/dev-ui-entity-section'; import { DevUiVersionRow } from '@/cli/utilities/dev/ui/components/dev-ui-version-row'; @@ -62,8 +63,10 @@ export const DevUiStepStatusLabel = ({ export const DevUiApplicationPanel = ({ state, + verbose = false, }: { state: OrchestratorState; + verbose?: boolean; }): React.ReactElement => { const { Box, Text } = useInk(); const groupedEntities = groupEntitiesByType(state.entities); @@ -111,13 +114,17 @@ export const DevUiApplicationPanel = ({ - {ENTITY_ORDER.map((type) => { - const entities = groupedEntities.get(type) ?? []; + {verbose ? ( + ENTITY_ORDER.map((type) => { + const entities = groupedEntities.get(type) ?? []; - return ( - - ); - })} + return ( + + ); + }) + ) : ( + + )} ); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-entity-section.tsx b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-entity-section.tsx index 4ad94ad8b4..cd89f7cdc0 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-entity-section.tsx +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-entity-section.tsx @@ -8,6 +8,7 @@ import { UPLOAD_FRAMES, mapFileStatusToDevUiStatus, shortenPath, + summarizeEntityStatuses, } from '@/cli/utilities/dev/ui/dev-ui-constants'; import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks'; import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context'; @@ -67,6 +68,35 @@ export const DevUiEntitySection = ({ ); }; +export const DevUiEntitySummary = ({ + entities, +}: { + entities: OrchestratorStateEntityInfo[]; +}): React.ReactElement | null => { + const { Box, Text } = useInk(); + + if (entities.length === 0) return null; + + const parts = summarizeEntityStatuses(entities); + + return ( + + + Entities{' '} + + {parts.map((part, index) => ( + + {index > 0 && · } + + + {part.count} {part.label} + + + ))} + + ); +}; + export const DevUiEntityLegend = (): React.ReactElement => { const { Box, Text } = useInk(); 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 58b63ba2ff..a089e33ad6 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 @@ -18,8 +18,10 @@ const SETTLE_DELAY_MS = 80; const DevUI = ({ uiStateManager, + verbose, }: { uiStateManager: DevUiStateManager; + verbose: boolean; }): React.ReactElement => { const { Box, Static } = useInk(); @@ -85,8 +87,8 @@ const DevUI = ({ - - + + {verbose && } ); @@ -94,15 +96,15 @@ const DevUI = ({ export const renderDevUI = async ( uiStateManager: DevUiStateManager, + verbose = false, ): Promise<{ unmount: () => void }> => { const ink = await import('ink'); const { render, Box, Text, Static } = ink; const { unmount } = render( - + , - { incrementalRendering: true }, ); return { unmount }; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts index 3560a7ecf6..a0030d4de9 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts @@ -78,20 +78,9 @@ export const SYNC_STATUS_LABELS: Record = { error: 'Error', }; -export const SPINNER_FRAMES = [ - '⠋', - '⠙', - '⠹', - '⠸', - '⠼', - '⠴', - '⠦', - '⠧', - '⠇', - '⠏', -]; +export const SPINNER_FRAMES = ['◐', '◓', '◑', '◒']; -export const UPLOAD_FRAMES = ['↑', '⇡', '↟', '⤒']; +export const UPLOAD_FRAMES = ['↑', '⇡', '↑', '⇡']; export const ENTITY_LABELS: Record = { [SyncableEntity.Object]: 'Objects', @@ -158,6 +147,43 @@ export const groupEntitiesByType = ( return grouped; }; +export type DevUiEntityStatusSummaryPart = { + status: OrchestratorStateFileStatus; + count: number; + label: string; +}; + +const ENTITY_STATUS_SUMMARY_ORDER: { + status: OrchestratorStateFileStatus; + label: string; +}[] = [ + { status: 'success', label: 'synced' }, + { status: 'building', label: 'building' }, + { status: 'uploading', label: 'uploading' }, + { status: 'pending', label: 'pending' }, + { status: 'error', label: 'error' }, +]; + +export const summarizeEntityStatuses = ( + entities: OrchestratorStateEntityInfo[], +): DevUiEntityStatusSummaryPart[] => { + const counts: Record = { + pending: 0, + building: 0, + uploading: 0, + success: 0, + error: 0, + }; + + for (const entity of entities) { + counts[entity.status] += 1; + } + + return ENTITY_STATUS_SUMMARY_ORDER.filter( + ({ status }) => counts[status] > 0, + ).map(({ status, label }) => ({ status, count: counts[status], label })); +}; + export const getApplicationUrl = (state: OrchestratorState): string | null => { const applicationId = state.steps.resolveApplication.output.applicationId; diff --git a/packages/twenty-sdk/src/cli/utilities/error/__tests__/format-manifest-validation-errors.spec.ts b/packages/twenty-sdk/src/cli/utilities/error/__tests__/format-manifest-validation-errors.spec.ts index 138270adbc..ac1a6d9965 100644 --- a/packages/twenty-sdk/src/cli/utilities/error/__tests__/format-manifest-validation-errors.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/error/__tests__/format-manifest-validation-errors.spec.ts @@ -1,59 +1,42 @@ +import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata'; + import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors'; describe('formatManifestValidationErrors', () => { - it('should return null for null input', () => { - expect(formatManifestValidationErrors(null)).toBeNull(); - }); - it('should return null for undefined input', () => { expect(formatManifestValidationErrors(undefined)).toBeNull(); }); - it('should return null for non-object input', () => { - expect(formatManifestValidationErrors('string error')).toBeNull(); - expect(formatManifestValidationErrors(42)).toBeNull(); - }); - - it('should return null when extensions is missing', () => { - expect(formatManifestValidationErrors({ message: 'error' })).toBeNull(); - }); - - it('should return null when extensions.errors is missing', () => { + it('should return null when errors or summary is missing', () => { expect( - formatManifestValidationErrors({ - extensions: { summary: { totalErrors: 1 } }, - }), + formatManifestValidationErrors({} as MetadataValidationErrorResponse), ).toBeNull(); - }); - - it('should return null when extensions.summary is missing', () => { expect( formatManifestValidationErrors({ - extensions: { errors: {} }, - }), + summary: { totalErrors: 1 }, + } as MetadataValidationErrorResponse), ).toBeNull(); }); it('should format a single error', () => { const events = formatManifestValidationErrors({ - extensions: { - errors: { - fieldMetadata: [ - { - flatEntityMinimalInformation: { - universalIdentifier: 'field-uuid-1', - }, - errors: [ - { - code: 'INVALID_NAME', - message: 'Field name is invalid', - }, - ], + errors: { + fieldMetadata: [ + { + type: 'fieldMetadata', + flatEntityMinimalInformation: { + universalIdentifier: 'field-uuid-1', }, - ], - }, - summary: { fieldMetadata: 1, totalErrors: 1 }, + errors: [ + { + code: 'INVALID_NAME', + message: 'Field name is invalid', + }, + ], + }, + ], }, + summary: { fieldMetadata: 1, totalErrors: 1 }, }); expect(events).not.toBeNull(); @@ -73,24 +56,26 @@ describe('formatManifestValidationErrors', () => { it('should format multiple errors across metadata types', () => { const events = formatManifestValidationErrors({ - 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 }, + errors: { + fieldMetadata: [ + { + type: 'fieldMetadata', + flatEntityMinimalInformation: {}, + errors: [ + { code: 'ERR_1', message: 'First error' }, + { code: 'ERR_2', message: 'Second error' }, + ], + }, + ], + objectMetadata: [ + { + type: 'objectMetadata', + flatEntityMinimalInformation: {}, + errors: [{ code: 'ERR_3', message: 'Third error' }], + }, + ], }, + summary: { fieldMetadata: 2, objectMetadata: 1, totalErrors: 3 }, }); expect(events).not.toBeNull(); @@ -104,50 +89,51 @@ describe('formatManifestValidationErrors', () => { it('should format errors with details for both objectMetadata and fieldMetadata', () => { const events = formatManifestValidationErrors({ - extensions: { - errors: { - objectMetadata: [ - { - flatEntityMinimalInformation: { - universalIdentifier: 'obj-uuid-1', - }, - errors: [ - { - code: 'DUPLICATE_NAME', - message: 'An object with this name already exists', - value: 'postCard', - }, - ], + errors: { + objectMetadata: [ + { + type: 'objectMetadata', + flatEntityMinimalInformation: { + universalIdentifier: 'obj-uuid-1', }, - ], - fieldMetadata: [ - { - flatEntityMinimalInformation: { - universalIdentifier: 'field-uuid-1', + errors: [ + { + code: 'DUPLICATE_NAME', + message: 'An object with this name already exists', + value: 'postCard', }, - errors: [ - { - code: 'INVALID_TYPE', - message: 'Field type is not supported', - value: 'UNKNOWN_TYPE', - }, - ], + ], + }, + ], + fieldMetadata: [ + { + type: 'fieldMetadata', + flatEntityMinimalInformation: { + universalIdentifier: 'field-uuid-1', }, - { - flatEntityMinimalInformation: { - universalIdentifier: 'field-uuid-2', + errors: [ + { + code: 'INVALID_TYPE', + message: 'Field type is not supported', + value: 'UNKNOWN_TYPE', }, - errors: [ - { - code: 'MISSING_RELATION_TARGET', - message: 'Relation target object not found', - }, - ], + ], + }, + { + type: 'fieldMetadata', + flatEntityMinimalInformation: { + universalIdentifier: 'field-uuid-2', }, - ], - }, - summary: { objectMetadata: 1, fieldMetadata: 2, totalErrors: 3 }, + errors: [ + { + code: 'MISSING_RELATION_TARGET', + message: 'Relation target object not found', + }, + ], + }, + ], }, + summary: { objectMetadata: 1, fieldMetadata: 2, totalErrors: 3 }, }); expect(events).not.toBeNull(); @@ -171,22 +157,22 @@ describe('formatManifestValidationErrors', () => { it('should include value in details when present', () => { const events = formatManifestValidationErrors({ - extensions: { - errors: { - fieldMetadata: [ - { - errors: [ - { - code: 'INVALID_VALUE', - message: 'Bad value', - value: 'some-bad-value', - }, - ], - }, - ], - }, - summary: { fieldMetadata: 1, totalErrors: 1 }, + errors: { + fieldMetadata: [ + { + type: 'fieldMetadata', + flatEntityMinimalInformation: {}, + errors: [ + { + code: 'INVALID_VALUE', + message: 'Bad value', + value: 'some-bad-value', + }, + ], + }, + ], }, + summary: { fieldMetadata: 1, totalErrors: 1 }, }); expect(events).not.toBeNull(); @@ -195,16 +181,16 @@ describe('formatManifestValidationErrors', () => { it('should omit details suffix when no value or universalIdentifier', () => { const events = formatManifestValidationErrors({ - extensions: { - errors: { - fieldMetadata: [ - { - errors: [{ code: 'ERR', message: 'Something failed' }], - }, - ], - }, - summary: { fieldMetadata: 1, totalErrors: 1 }, + errors: { + fieldMetadata: [ + { + type: 'fieldMetadata', + flatEntityMinimalInformation: {}, + errors: [{ code: 'ERR', message: 'Something failed' }], + }, + ], }, + summary: { fieldMetadata: 1, totalErrors: 1 }, }); expect(events).not.toBeNull(); @@ -213,19 +199,19 @@ describe('formatManifestValidationErrors', () => { it('should fall back to entries.length when summary count is missing for a metadata type', () => { const events = formatManifestValidationErrors({ - extensions: { - errors: { - fieldMetadata: [ - { - errors: [ - { code: 'ERR_1', message: 'Error one' }, - { code: 'ERR_2', message: 'Error two' }, - ], - }, - ], - }, - summary: { totalErrors: 2 }, + errors: { + fieldMetadata: [ + { + type: 'fieldMetadata', + flatEntityMinimalInformation: {}, + errors: [ + { code: 'ERR_1', message: 'Error one' }, + { code: 'ERR_2', message: 'Error two' }, + ], + }, + ], }, + summary: { totalErrors: 2 }, }); expect(events).not.toBeNull(); @@ -234,35 +220,35 @@ describe('formatManifestValidationErrors', () => { it('should pluralize correctly for singular and plural counts', () => { const singleError = formatManifestValidationErrors({ - extensions: { - errors: { - objectMetadata: [ - { - errors: [{ code: 'ERR', message: 'Error' }], - }, - ], - }, - summary: { objectMetadata: 1, totalErrors: 1 }, + errors: { + objectMetadata: [ + { + type: 'objectMetadata', + flatEntityMinimalInformation: {}, + 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 = formatManifestValidationErrors({ - extensions: { - errors: { - objectMetadata: [ - { - errors: [ - { code: 'ERR_1', message: 'Error 1' }, - { code: 'ERR_2', message: 'Error 2' }, - ], - }, - ], - }, - summary: { objectMetadata: 5, totalErrors: 5 }, + errors: { + objectMetadata: [ + { + type: 'objectMetadata', + flatEntityMinimalInformation: {}, + 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'); diff --git a/packages/twenty-sdk/src/cli/utilities/error/__tests__/get-sync-error-recovery-hint.spec.ts b/packages/twenty-sdk/src/cli/utilities/error/__tests__/get-sync-error-recovery-hint.spec.ts new file mode 100644 index 0000000000..e33d992f15 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/error/__tests__/get-sync-error-recovery-hint.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint'; + +describe('getSyncErrorRecoveryHint', () => { + it('suggests an initial sync when the app is not installed', () => { + const hint = getSyncErrorRecoveryHint( + 'Application "x" is not installed in workspace "y". Install it first.', + ); + + expect(hint).toContain('yarn twenty dev --once'); + expect(hint).toContain('register'); + }); + + it('suggests previewing and reinstalling on a metadata conflict', () => { + const hint = getSyncErrorRecoveryHint( + "Migration action 'create' for 'fieldMetadata' (universalIdentifier: 2020) failed", + ); + + expect(hint).toContain('yarn twenty dev --once --dry-run'); + expect(hint).toContain('yarn twenty app:uninstall -y'); + }); + + it('suggests previewing on an already-exists error', () => { + const hint = getSyncErrorRecoveryHint( + 'Field with same universal identifier already exists in object', + ); + + expect(hint).toContain('yarn twenty dev --once --dry-run'); + }); + + it('returns undefined for an unrecognized error', () => { + expect(getSyncErrorRecoveryHint('Network request failed')).toBeUndefined(); + expect(getSyncErrorRecoveryHint(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/error/format-manifest-validation-errors.ts b/packages/twenty-sdk/src/cli/utilities/error/format-manifest-validation-errors.ts index a7e042b174..afa346f352 100644 --- a/packages/twenty-sdk/src/cli/utilities/error/format-manifest-validation-errors.ts +++ b/packages/twenty-sdk/src/cli/utilities/error/format-manifest-validation-errors.ts @@ -1,44 +1,34 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { + type AllMetadataName, + type MetadataValidationErrorResponse, +} from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + 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 formatManifestValidationErrors = ( - error: unknown, + error: MetadataValidationErrorResponse | undefined, ): OrchestratorStateStepEvent[] | null => { - if (!error || typeof error !== 'object') { - return null; - } - - const syncError = error as StructuredSyncError; - const extensions = syncError.extensions; - - if (!extensions?.errors || !extensions?.summary) { + if (!isDefined(error?.errors) || !isDefined(error?.summary)) { return null; } const events: OrchestratorStateStepEvent[] = []; - const totalErrors = extensions.summary.totalErrors; + const totalErrors = error.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; + for (const [metadataName, entries] of Object.entries(error.errors)) { + if (!isDefined(entries)) { + continue; + } + + const count = + error.summary[metadataName as AllMetadataName] ?? entries.length; events.push({ message: `${metadataName}: ${count} error${count !== 1 ? 's' : ''}`, @@ -54,11 +44,11 @@ export const formatManifestValidationErrors = ( for (const entryError of entry.errors) { const details: string[] = []; - if (entryError.value) { - details.push(`value: ${entryError.value}`); + if (isDefined(entryError.value)) { + details.push(`value: ${String(entryError.value)}`); } - if (universalIdentifier) { + if (isNonEmptyString(universalIdentifier)) { details.push(`universalIdentifier: ${universalIdentifier}`); } diff --git a/packages/twenty-sdk/src/cli/utilities/error/get-sync-error-recovery-hint.ts b/packages/twenty-sdk/src/cli/utilities/error/get-sync-error-recovery-hint.ts new file mode 100644 index 0000000000..9c595448eb --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/error/get-sync-error-recovery-hint.ts @@ -0,0 +1,19 @@ +export const getSyncErrorRecoveryHint = ( + message: string | undefined, +): string | undefined => { + const normalizedMessage = (message ?? '').toLowerCase(); + + if (normalizedMessage.includes('not installed')) { + return 'Hint: run `yarn twenty dev --once` to register the app in this workspace, then retry.'; + } + + if ( + normalizedMessage.includes('already exists') || + normalizedMessage.includes('universalidentifier') || + /migration action .* failed/.test(normalizedMessage) + ) { + return 'Hint: a metadata conflict was detected. Preview the plan with `yarn twenty dev --once --dry-run`; if it persists, run `yarn twenty app:uninstall -y` then sync again.'; + } + + return undefined; +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts index 74acaee185..37adddc1a9 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts @@ -52,10 +52,7 @@ export class ApplicationSyncService { hasSchemaMetadataChanged: boolean; }> { const ownerFlatApplication: FlatApplication = dryRun - ? await this.applicationService.findOneApplicationOrThrow({ - universalIdentifier: manifest.application.universalIdentifier, - workspaceId, - }) + ? await this.findInstalledApplicationOrThrow({ workspaceId, manifest }) : await this.syncApplication({ workspaceId, manifest, @@ -77,6 +74,30 @@ export class ApplicationSyncService { return syncResult; } + private async findInstalledApplicationOrThrow({ + workspaceId, + manifest, + }: { + workspaceId: string; + manifest: Manifest; + }): Promise { + const application = await this.applicationService.findByUniversalIdentifier( + { + universalIdentifier: manifest.application.universalIdentifier, + workspaceId, + }, + ); + + if (!application) { + throw new ApplicationException( + `Application "${manifest.application.universalIdentifier}" is not installed in workspace "${workspaceId}". Install it first.`, + ApplicationExceptionCode.APP_NOT_INSTALLED, + ); + } + + return application; + } + // Registers the application + only the pre-install logic function in // workspace metadata so the pre-install hook can resolve and execute it // before the main synchronizeFromManifest runs the full migrations. diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util.ts index 6769337604..1685e4a90b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util.ts @@ -40,7 +40,7 @@ export const fromLogicFunctionManifestToUniversalFlatLogicFunction = ({ workflowActionTriggerSettings: logicFunctionManifest.workflowActionTriggerSettings ?? null, isBuildUpToDate: true, - executionMode: LogicFunctionExecutionMode.PREBUILT, + executionMode: LogicFunctionExecutionMode.LIVE, createdAt: now, updatedAt: now, deletedAt: null, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-diff.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-diff.type.ts new file mode 100644 index 0000000000..93bfa90011 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-diff.type.ts @@ -0,0 +1,11 @@ +import { type AllMetadataName } from 'twenty-shared/metadata'; + +import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type'; +import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type'; + +export type UniversalFlatEntityDiff = { + [K in MetadataUniversalFlatEntityPropertiesToCompare]?: { + before: MetadataUniversalFlatEntity[K]; + after: MetadataUniversalFlatEntity[K]; + }; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts index 0acf999a0a..86355697ee 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts @@ -18,6 +18,7 @@ import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-e import { WorkspaceMigrationBuilderAdditionalCacheDataMaps } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-builder-additional-cache-data-maps.type'; import { AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; import { MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type'; +import { UniversalFlatEntityDiff } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-diff.type'; import { UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type'; import { addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/add-universal-flat-entity-to-universal-flat-entity-and-related-entity-maps-through-mutation-or-throw.util'; import { deleteUniversalFlatEntityForeignKeyAggregators } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/delete-universal-flat-entity-foreign-key-aggregators.util'; @@ -164,7 +165,11 @@ export abstract class WorkspaceEntityMigrationBuilderService< actionsResult.delete.push( ...(Array.isArray(validationResult.action) ? validationResult.action - : [validationResult.action]), + : [validationResult.action] + ).map((action) => ({ + ...action, + flatEntity: universalFlatEntityToDelete, + })), ); } @@ -221,6 +226,17 @@ export abstract class WorkspaceEntityMigrationBuilderService< ...flatEntityUpdate.update, }; + const diff = Object.fromEntries( + Object.entries(flatEntityUpdate.update).map(([key, after]) => [ + key, + { + before: + existingFlatEntity[key as keyof MetadataUniversalFlatEntity], + after, + }, + ]), + ) as UniversalFlatEntityDiff; + replaceUniversalFlatEntityInUniversalFlatEntityMapsThroughMutationOrThrow( { universalFlatEntity: updatedFlatEntity, @@ -232,7 +248,12 @@ export abstract class WorkspaceEntityMigrationBuilderService< actionsResult.update.push( ...(Array.isArray(validationResult.action) ? validationResult.action - : [validationResult.action]), + : [validationResult.action] + ).map((action) => ({ + ...action, + flatEntity: updatedFlatEntity, + diff, + })), ); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-delete-workspace-migration-action.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-delete-workspace-migration-action.type.ts index 772a1ee92c..8cb96798ae 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-delete-workspace-migration-action.type.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-delete-workspace-migration-action.type.ts @@ -1,5 +1,6 @@ import { type AllMetadataName } from 'twenty-shared/metadata'; +import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type'; import { type WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant'; export type BaseUniversalDeleteWorkspaceMigrationAction< @@ -8,4 +9,5 @@ export type BaseUniversalDeleteWorkspaceMigrationAction< universalIdentifier: string; type: typeof WORKSPACE_MIGRATION_ACTION_TYPE.delete; metadataName: T; + flatEntity?: MetadataUniversalFlatEntity; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-update-workspace-migration-action.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-update-workspace-migration-action.type.ts index 30b0490a53..eae15d1f53 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-update-workspace-migration-action.type.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-update-workspace-migration-action.type.ts @@ -1,5 +1,7 @@ import { type AllMetadataName } from 'twenty-shared/metadata'; +import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type'; +import { type UniversalFlatEntityDiff } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-diff.type'; import { type UniversalFlatEntityUpdate } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-update.type'; import { type WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant'; @@ -10,4 +12,6 @@ export type BaseUniversalUpdateWorkspaceMigrationAction< metadataName: T; universalIdentifier: string; update: UniversalFlatEntityUpdate; + flatEntity?: MetadataUniversalFlatEntity; + diff?: UniversalFlatEntityDiff; }; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap index 45755ad97a..f225b1b81a 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`syncApplication should create a TEXT field on the standard Company object 1`] = ` { @@ -382,6 +382,53 @@ exports[`syncApplication should delete old field and create equivalent one when "syncApplication": { "actions": [ { + "flatEntity": { + "applicationId": Any, + "applicationUniversalIdentifier": Any, + "calendarViewIds": [], + "calendarViewUniversalIdentifiers": [], + "createdAt": Any, + "defaultValue": null, + "description": "Ticket description", + "fieldPermissionIds": [], + "fieldPermissionUniversalIdentifiers": [], + "icon": "IconFileDescription", + "id": Any, + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": false, + "isUIReadOnly": false, + "isUnique": false, + "kanbanAggregateOperationViewIds": [], + "kanbanAggregateOperationViewUniversalIdentifiers": [], + "label": "Description", + "mainGroupByFieldMetadataViewIds": [], + "mainGroupByFieldMetadataViewUniversalIdentifiers": [], + "morphId": null, + "name": "description", + "objectMetadataId": Any, + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataId": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataId": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "settings": null, + "standardOverrides": null, + "type": "TEXT", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + "viewFieldIds": [], + "viewFieldUniversalIdentifiers": [], + "viewFilterIds": [], + "viewFilterUniversalIdentifiers": [], + "viewSortIds": [], + "viewSortUniversalIdentifiers": [], + "workspaceId": Any, + }, "metadataName": "fieldMetadata", "type": "delete", "universalIdentifier": Any, diff --git a/packages/twenty-shared/src/constants/DocumentationPaths.ts b/packages/twenty-shared/src/constants/DocumentationPaths.ts index 93a11288c4..1df0060cd9 100644 --- a/packages/twenty-shared/src/constants/DocumentationPaths.ts +++ b/packages/twenty-shared/src/constants/DocumentationPaths.ts @@ -69,6 +69,8 @@ export const DOCUMENTATION_PATHS = { '/developers/extend/apps/operations/overview', DEVELOPERS_EXTEND_APPS_OPERATIONS_PUBLISHING: '/developers/extend/apps/operations/publishing', + DEVELOPERS_EXTEND_APPS_OPERATIONS_SYNC_AND_RECOVERY: + '/developers/extend/apps/operations/sync-and-recovery', DEVELOPERS_EXTEND_APPS_OPERATIONS_TESTING: '/developers/extend/apps/operations/testing', DEVELOPERS_EXTEND_OAUTH: '/developers/extend/oauth',