Compare commits

...

9 Commits

Author SHA1 Message Date
Weiko fb3cec8b8d Revert "chore: bump version to 2.11.0 (#21259)"
This reverts commit 2dbdd72e65.
2026-06-05 18:32:18 +02:00
Thomas Trompette 51e3eddb29 Add job queue latency histogram metric (#21260)
## Summary
- Records the time each job spends waiting in the queue (enqueue →
processing start) as an OpenTelemetry histogram
- Metric is broken down by `queue` and `job_name` attributes, enabling
per-queue p50/p95/p99 latency analysis
- Uses BullMQ's native `job.timestamp` for accurate measurement

## Test plan
- [x] Deploy to staging and verify `job/latency-ms` metric appears in
ClickHouse `otel_metrics_histogram` table
- [x] Confirm Grafana dashboard can query the histogram data
2026-06-05 16:04:54 +00:00
Etienne 27db8bbae4 fixes - remove billing cancellation at trial end + disable ai chat if suspended (#21261)
- Remove billing cancelation if trial is ended (subscription activated)
- Disable AI Chat use if workspace suspended
2026-06-05 15:34:12 +00:00
martmull 84ba7eb8dc fix(app-dev): serialize dev sync per workspace with a cache lock (#21250)
Split out of #21240. Stacked on #21249 (review/merge that first).

Concurrent `syncApplication` calls on the same workspace could
interleave their metadata migrations and leave metadata partially
applied. Wrap the manifest sync in a per-workspace cache lock
(`app-sync:<workspaceId>`), mirroring the install path. The rate-limit
throttle stays outside the lock.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-05 17:30:55 +02:00
Charles Bochet a3d73740a1 fix(server): guard role-permission cache against stripped permissionFlag relation during upgrade (#21257)
## Problem

Self-hosted upgrades that jump versions (e.g. `2.4 → 2.7/2.9`) abort
with:

```
TypeError: Cannot read properties of undefined (reading 'universalIdentifier')
  at WorkspaceRolesPermissionsCacheService.hasSettingsGatedObjectPermissions
  at WorkspaceRolesPermissionsCacheService.computeForCache
  at WorkspaceCacheService.recomputeDataFromProvider
```

Reported in #20841 (Failure #2). The sequence aborts mid-upgrade and
leaves the DB in a half-migrated state.

## Root cause

The per-workspace **cache recompute runs at a `2.5.0` workspace step —
before the `2.6` schema migrations apply**. At that cursor:

- `RolePermissionFlagEntity.permissionFlag` is
`@WasIntroducedInUpgrade('2.6.0_LinkRolePermissionFlagToPermissionFlag…')`,
so `UpgradeAwareRepositoryProxy` **strips the relation**
(`[upgrade-proxy] strip relation
RolePermissionFlagEntity.permissionFlag` in the logs) → `permissionFlag`
is `undefined`.
- `hasSettingsGatedObjectPermissions()` then does an **unguarded**
`rolePermissionFlag.permissionFlag.universalIdentifier` → throws.

The crash only manifests when a workspace has **≥1 `rolePermissionFlag`
row** (custom roles with gated settings perms / SDK `defineRole`). A
vanilla seed has an empty table, so `.find()` over `[]` never
dereferences anything — which is why it didn't reproduce on a clean
instance.

A null-safe fallback to the legacy `flag` column used to exist here; it
was dropped in #20730.

## Fix

Resolve the flag's universal identifier through a small helper that
falls back to the legacy `flag` column (only removed in `2.7.0`) when
the relation is unavailable:

```ts
private getRolePermissionFlagUniversalIdentifier(
  rolePermissionFlag: RolePermissionFlagEntity,
): string {
  // The `permissionFlag` relation is stripped during upgrades until the 2.6.0
  // cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
  return (
    rolePermissionFlag.permissionFlag?.universalIdentifier ??
    SystemPermissionFlag[rolePermissionFlag.flag]
  );
}
```

`SystemPermissionFlag[flag]` yields the same UUID the relation would, so
the comparison stays in a single space and the computed permission is
exact (not an over-grant). Correct at every transitional cursor:
pre-`2.6` (relation stripped → use `flag`), `2.6` (both present →
relation wins), post-`2.7` (`flag` removed → relation wins).

## Reproduction & validation

Locally jumped a real `2.4.0` DB → `v2.9.0` build via `yarn command:prod
upgrade`:

| Scenario | Result |
| --- | --- |
| Empty `permissionFlag` (vanilla seed) | passes (no crash) |
| **+1 flag row**, current code | `TypeError … universalIdentifier` →
**3 succeeded, 1 failed** |
| Same fixture, **this fix** | **16 succeeded, 0 failed**, DB fully
migrated to 2.9.0 |

`nx typecheck twenty-server` clean; existing cache-service unit tests
pass; app boots on the upgraded DB.

## Scope / follow-up

This fixes **Failure #2**. **Failure #1** in the same issue
(`viewFilter.relationTargetFieldMetadataId` selected before its column
exists) is a separate instance of the same theme — cache recompute
reading "future" schema before migrations run — and is worth a
follow-up. A more durable systemic fix would defer the workspace cache
recompute until after all schema-adding migrations; this PR is the
low-risk, backport-friendly fix for the immediate breakage.

> Note: an earlier bot branch
(`sonarly-39738-fixupgrade-guard-role-permission-flag-relation`)
proposed the same fallback inline. This PR supersedes it with a named
helper + a focused comment.

Fixes #20841

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:45:17 +00:00
Thomas Trompette 0d3c7a47af fix: guard against undefined logicFunctionId when destroying workflow CODE steps (#21256)
## Summary
- Adds `isDefined` guard in `handleLogicFunctionSubEntities` to skip
CODE steps with undefined `logicFunctionId` instead of crashing
- Adds same guard in `runWorkflowVersionStepDeletionSideEffects` for
consistency
- Rejects CODE steps in `create_complete_workflow` AI tool at runtime to
prevent creating workflows with missing logic functions in the first
place

Fixes `"Logic function with id undefined not found"`
INTERNAL_SERVER_ERROR when destroying workflows whose CODE steps were
created via `create_complete_workflow` without a proper logic function.

## Test plan
- [x] Destroy a workflow that has a CODE step with undefined
logicFunctionId → should succeed silently
- [x] Try creating a workflow with a CODE step via
`create_complete_workflow` tool → should return error message
- [x] Normal workflow destroy with valid CODE steps still deletes the
logic function
2026-06-05 14:30:19 +00:00
twenty-pr[bot] 2dbdd72e65 chore: bump version to 2.11.0 (#21259)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version

## Checklist

- [ ] Verify version constants are correct
- [ ] Verify npm package versions match

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-06-05 16:47:40 +02:00
martmull f20d04eb6e feat(app-dev): surface metadata diff in dev sync and name failing migration actions (#21249)
Split out of #21240.

- Render the applied metadata changes (created/updated/deleted +
identifiers) in the dev sync output instead of a bare `✓ Synced`.
- Include the failing entity's `universalIdentifier` in
`WorkspaceMigrationRunnerException` messages so conflicts are
diagnosable.

<img width="637" height="114" alt="image"
src="https://github.com/user-attachments/assets/61422a16-370c-4e9b-a2f6-c29ce17f3b1b"
/>

<img width="497" height="104" alt="image"
src="https://github.com/user-attachments/assets/d493c398-da29-49c9-ac5e-aa0f26cd7389"
/>

<img width="593" height="127" alt="image"
src="https://github.com/user-attachments/assets/15e26edc-c0e4-4427-bd34-909040e970c9"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-05 16:35:50 +02:00
nitin f72898063a upgrade command patch - warn instead of throw when no calendarEvent object found in the workspace (#21258) 2026-06-05 16:32:16 +02:00
26 changed files with 456 additions and 12 deletions
@@ -5,6 +5,7 @@ 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';
type ApiServiceOptions = {
disableInterceptors?: boolean;
@@ -72,7 +73,12 @@ export class ApiService {
return this.applicationApi.createDevelopmentApplication(...args);
}
syncApplication(manifest: Manifest): Promise<ApiResponse> {
syncApplication(manifest: Manifest): Promise<
ApiResponse<{
applicationUniversalIdentifier: string;
actions: SyncAction[];
}>
> {
return this.applicationApi.syncApplication(manifest);
}
@@ -1,6 +1,7 @@
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import { type Manifest } from 'twenty-shared/application';
import { type SyncAction } from 'twenty-shared/metadata';
export class ApplicationApi {
constructor(private readonly client: AxiosInstance) {}
@@ -251,7 +252,12 @@ export class ApplicationApi {
}
}
async syncApplication(manifest: Manifest): Promise<ApiResponse> {
async syncApplication(manifest: Manifest): Promise<
ApiResponse<{
applicationUniversalIdentifier: string;
actions: SyncAction[];
}>
> {
try {
const mutation = `
mutation SyncApplication($manifest: JSON!) {
@@ -0,0 +1,92 @@
import { type SyncAction } from 'twenty-shared/metadata';
import { describe, expect, it } from 'vitest';
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
describe('formatSyncActionsSummary', () => {
it('reports no changes when the actions list is empty', () => {
expect(formatSyncActionsSummary([])).toEqual([
{ message: 'No metadata changes', status: 'info' },
]);
});
it('summarizes created, updated and deleted actions with their identifiers', () => {
const events = formatSyncActionsSummary([
{
type: 'create',
metadataName: 'objectMetadata',
flatEntity: {
universalIdentifier: 'uid-object',
nameSingular: 'rocket',
},
},
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: 'uid-field',
name: 'timelineActivities',
},
},
{
type: 'update',
metadataName: 'fieldMetadata',
universalIdentifier: 'uid-updated-field',
},
{
type: 'delete',
metadataName: 'pageLayout',
universalIdentifier: 'uid-page-layout',
},
]);
expect(events).toEqual([
{
message: 'Metadata changes: 2 created, 1 updated, 1 deleted',
status: 'info',
},
{ message: ' created objectMetadata rocket', status: 'info' },
{ message: ' created fieldMetadata timelineActivities', status: 'info' },
{ message: ' updated fieldMetadata uid-updated-field', status: 'info' },
{ message: ' deleted pageLayout uid-page-layout', status: 'info' },
]);
});
it('falls back to the universal identifier when a created entity has no name', () => {
const events = formatSyncActionsSummary([
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: { universalIdentifier: 'uid-nameless' },
},
]);
expect(events).toEqual([
{ message: 'Metadata changes: 1 created', status: 'info' },
{ message: ' created fieldMetadata uid-nameless', status: 'info' },
]);
});
it('truncates the detail lines when there are more changes than the display limit', () => {
const actions: SyncAction[] = Array.from({ length: 55 }, (_, index) => ({
type: 'create' as const,
metadataName: 'fieldMetadata' as const,
flatEntity: {
universalIdentifier: `uid-${index}`,
name: `field${index}`,
},
}));
const events = formatSyncActionsSummary(actions);
expect(events[0]).toEqual({
message: 'Metadata changes: 55 created',
status: 'info',
});
expect(events).toHaveLength(52);
expect(events[51]).toEqual({
message: ' …and 5 more change(s)',
status: 'info',
});
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import { type ApiService } from '@/cli/utilities/api/api-service';
import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { type Manifest } from 'twenty-shared/application';
vi.mock('@/cli/utilities/build/manifest/manifest-update-checksums', () => ({
manifestUpdateChecksums: ({ manifest }: { manifest: unknown }) => manifest,
}));
vi.mock('@/cli/utilities/build/manifest/manifest-writer', () => ({
writeManifestToOutput: vi.fn(),
}));
const buildStep = (
syncApplication: ApiService['syncApplication'],
): { state: OrchestratorState; step: SyncApplicationOrchestratorStep } => {
const state = new OrchestratorState({ appPath: '/tmp/app' });
const apiService = { syncApplication } as unknown as ApiService;
const step = new SyncApplicationOrchestratorStep({
apiService,
state,
notify: () => {},
});
return { state, step };
};
const executeInput = {
manifest: {
application: { displayName: 'Demo' },
} as unknown as Manifest,
builtFileInfos: new Map(),
appPath: '/tmp/app',
};
describe('SyncApplicationOrchestratorStep', () => {
it('renders the applied metadata changes on a successful sync', async () => {
const syncApplication = vi.fn().mockResolvedValue({
success: true,
data: {
applicationUniversalIdentifier: 'app-uid',
actions: [
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: 'uid-field',
name: 'timelineActivities',
},
},
],
},
});
const { state, step } = buildStep(syncApplication);
await step.execute(executeInput);
const messages = state.events.map((event) => event.message);
expect(messages).toContain('Metadata changes: 1 created');
expect(messages).toContain(' created fieldMetadata timelineActivities');
expect(messages).toContain('✓ Synced');
});
it('reports no metadata changes when the sync applies nothing', async () => {
const syncApplication = vi.fn().mockResolvedValue({
success: true,
data: { applicationUniversalIdentifier: 'app-uid', actions: [] },
});
const { state, step } = buildStep(syncApplication);
await step.execute(executeInput);
const messages = state.events.map((event) => event.message);
expect(messages).toContain('No metadata changes');
expect(messages).toContain('✓ Synced');
});
});
@@ -0,0 +1,68 @@
import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { type SyncAction } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
const MAX_DETAIL_LINES = 50;
const VERB_BY_TYPE = {
create: 'created',
update: 'updated',
delete: 'deleted',
} as const;
const getActionLabel = (action: SyncAction): string => {
if (action.type === 'create') {
return (
action.flatEntity?.name ??
action.flatEntity?.nameSingular ??
action.flatEntity?.universalIdentifier ??
'unknown'
);
}
return action.universalIdentifier;
};
export const formatSyncActionsSummary = (
actions: SyncAction[],
): OrchestratorStateStepEvent[] => {
if (actions.length === 0) {
return [{ message: 'No metadata changes', status: 'info' }];
}
const counts = { create: 0, update: 0, delete: 0 };
for (const action of actions) {
counts[action.type] += 1;
}
const summaryParts = [
counts.create > 0 ? `${counts.create} created` : null,
counts.update > 0 ? `${counts.update} updated` : null,
counts.delete > 0 ? `${counts.delete} deleted` : null,
].filter(isDefined);
const events: OrchestratorStateStepEvent[] = [
{ message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' },
];
const visibleActions = actions.slice(0, MAX_DETAIL_LINES);
for (const action of visibleActions) {
events.push({
message: ` ${VERB_BY_TYPE[action.type]} ${action.metadataName} ${getActionLabel(action)}`,
status: 'info',
});
}
const hiddenCount = actions.length - visibleActions.length;
if (hiddenCount > 0) {
events.push({
message: ` …and ${hiddenCount} more change(s)`,
status: 'info',
});
}
return events;
};
@@ -7,6 +7,7 @@ import {
type OrchestratorStateStepEvent,
type OrchestratorStateSyncStatus,
} 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 { type Manifest } from 'twenty-shared/application';
@@ -69,6 +70,9 @@ export class SyncApplicationOrchestratorStep {
const syncResult = await this.apiService.syncApplication(manifest);
if (syncResult.success) {
const syncData = syncResult.data;
events.push(...formatSyncActionsSummary(syncData.actions));
events.push({ message: '✓ Synced', status: 'success' });
step.output = { syncStatus: 'synced', error: null };
step.status = 'done';
@@ -144,9 +144,11 @@ export class SyncCallRecordingStandardObjectsCommand extends ActiveOrSuspendedWo
];
if (!isDefined(calendarEventObjectMetadata)) {
throw new Error(
`calendarEvent object not found for workspace ${workspaceId}`,
this.logger.warn(
`calendarEvent object not found for workspace ${workspaceId}, skipping CallRecording standard metadata sync`,
);
return;
}
const { twentyStandardFlatApplication } =
@@ -5,6 +5,7 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
@@ -17,6 +18,7 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
ApplicationModule,
ApplicationManifestModule,
ApplicationRegistrationModule,
CacheLockModule,
FeatureFlagModule,
SdkClientModule,
TokenModule,
@@ -33,6 +33,7 @@ import {
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
@@ -52,6 +53,8 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
const APP_DEV_RATE_LIMIT_MAX = 30;
const APP_DEV_RATE_LIMIT_WINDOW_MS = 30_000;
const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@@ -71,6 +74,7 @@ export class ApplicationDevelopmentResolver {
private readonly sdkClientGenerationService: SdkClientGenerationService,
private readonly twentyConfigService: TwentyConfigService,
private readonly throttlerService: ThrottlerService,
private readonly cacheLockService: CacheLockService,
) {}
@Mutation(() => DevelopmentApplicationDTO)
@@ -137,6 +141,17 @@ export class ApplicationDevelopmentResolver {
workspaceId,
);
return this.cacheLockService.withLock(
() => this.applyManifestSync(manifest, workspaceId),
`app-sync:${workspaceId}`,
APP_SYNC_LOCK_OPTIONS,
);
}
private async applyManifestSync(
manifest: ApplicationInput['manifest'],
workspaceId: string,
): Promise<WorkspaceMigrationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
manifest.application.universalIdentifier,
);
@@ -1,6 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { type SyncAction } from 'twenty-shared/metadata';
@ObjectType('WorkspaceMigration')
export class WorkspaceMigrationDTO {
@@ -8,5 +9,5 @@ export class WorkspaceMigrationDTO {
applicationUniversalIdentifier: string;
@Field(() => GraphQLJSON)
actions: unknown[];
actions: SyncAction[];
}
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { BillingGaugeService } from 'src/engine/core-modules/billing/billing-gauge.service';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
@@ -49,6 +50,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
@Module({
imports: [
ClickHouseModule,
CoreEntityCacheModule,
FeatureFlagModule,
StripeModule,
MessageQueueModule,
@@ -263,6 +263,7 @@ export class BillingSubscriptionService {
billingSubscription.stripeSubscriptionId,
{
trial_end: 'now',
cancel_at_period_end: false,
},
);
@@ -3,10 +3,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { differenceInDays } from 'date-fns';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import {
BillingException,
BillingExceptionCode,
@@ -49,6 +51,7 @@ export class BillingUsageService {
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly clickHouseService: ClickHouseService,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {}
async canFeatureBeUsed(workspaceId: string): Promise<boolean> {
@@ -348,6 +351,18 @@ export class BillingUsageService {
return true;
}
const workspace = await this.coreEntityCacheService.get(
'workspaceEntity',
workspaceId,
);
if (
isDefined(workspace) &&
workspace.activationStatus === WorkspaceActivationStatus.SUSPENDED
) {
return false;
}
const { billingSubscription: subscription } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
@@ -114,6 +114,15 @@ export class BullMQDriver
Sentry.withIsolationScope(async () => {
applyWorkspaceSentryContextFromJobData(job.data);
const queueLatency = Math.max(0, Date.now() - job.timestamp);
this.metricsService.recordHistogram({
key: MetricsKeys.JobLatencyMs,
value: queueLatency,
unit: 'ms',
attributes: { queue: queueName, job_name: job.name },
});
// TODO: Correctly support for job.id
const timeStart = performance.now();
const workspaceId = job.data?.workspaceId;
@@ -40,6 +40,7 @@ export enum MetricsKeys {
JobCompleted = 'job/completed',
JobFailed = 'job/failed',
JobWaiting = 'job/waiting',
JobLatencyMs = 'job/latency-ms',
AiChatTurnLatencyMs = 'ai-chat/turn-latency-ms',
AiChatStepLatencyMs = 'ai-chat/step-latency-ms',
AiChatTtftMs = 'ai-chat/ttft-ms',
@@ -31,7 +31,7 @@ Report: {
`;
exports[`formatUpgradeErrorForStorage should format a WorkspaceMigrationRunnerException with EXECUTION_FAILED 1`] = `
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' failed
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' (universalIdentifier: test-object-uid) failed
Code: EXECUTION_FAILED
Action: create on objectMetadata
Metadata error: column "label" cannot be null
@@ -57,6 +57,7 @@ describe('formatUpgradeErrorForStorage', () => {
const action = {
type: 'create',
metadataName: 'objectMetadata',
flatEntity: { universalIdentifier: 'test-object-uid' },
} as unknown as AllUniversalWorkspaceMigrationAction;
const error = new WorkspaceMigrationRunnerException({
@@ -279,11 +279,22 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
const hasPermissionFromSettingPermissions = isDefined(
rolePermissionFlags.find(
(rolePermissionFlag) =>
rolePermissionFlag.permissionFlag.universalIdentifier ===
this.getRolePermissionFlagUniversalIdentifier(rolePermissionFlag) ===
permissionFlagUniversalIdentifier,
),
);
return hasPermissionFromRole || hasPermissionFromSettingPermissions;
}
private getRolePermissionFlagUniversalIdentifier(
rolePermissionFlag: RolePermissionFlagEntity,
): string {
// The `permissionFlag` relation is stripped during upgrades until the 2.6.0
// cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
return (
rolePermissionFlag.permissionFlag?.universalIdentifier ??
SystemPermissionFlag[rolePermissionFlag.flag]
);
}
}
@@ -0,0 +1,45 @@
import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
describe('WorkspaceMigrationRunnerException', () => {
it('includes the universal identifier of a failed create action in the message', () => {
const action = {
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5',
},
} as unknown as AllUniversalWorkspaceMigrationAction;
const exception = new WorkspaceMigrationRunnerException({
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
action,
errors: {},
});
expect(exception.message).toBe(
"Migration action 'create' for 'fieldMetadata' (universalIdentifier: 20202020-6736-4337-b5c4-8b39fae325a5) failed",
);
});
it('includes the universal identifier of a failed delete action in the message', () => {
const action = {
type: 'delete',
metadataName: 'pageLayout',
universalIdentifier: 'uid-page-layout',
} as unknown as AllUniversalWorkspaceMigrationAction;
const exception = new WorkspaceMigrationRunnerException({
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
action,
errors: {},
});
expect(exception.message).toBe(
"Migration action 'delete' for 'pageLayout' (universalIdentifier: uid-page-layout) failed",
);
});
});
@@ -34,6 +34,25 @@ export type WorkspaceMigrationRunnerExecutionErrors = {
actionTranspilation?: Error;
};
const getActionUniversalIdentifierOrThrow = (
action: AllUniversalWorkspaceMigrationAction,
): string => {
if (action.type === 'create') {
const universalIdentifier = action.flatEntity?.universalIdentifier;
if (!universalIdentifier) {
throw new WorkspaceMigrationRunnerException({
message: `Missing universalIdentifier on create action for '${action.metadataName}'`,
code: WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR,
});
}
return universalIdentifier;
}
return action.universalIdentifier;
};
const {
// oxlint-disable-next-line unused-imports/no-unused-vars
EXECUTION_FAILED: WorkspaceMigrationRunnerExceptionExecutionFailedCode,
@@ -62,8 +81,13 @@ export class WorkspaceMigrationRunnerException extends CustomError {
constructor(args: WorkspaceMigrationRunnerExceptionConstructorArgs) {
if (args.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED) {
const universalIdentifier = getActionUniversalIdentifierOrThrow(
args.action,
);
const identifierClause = ` (universalIdentifier: ${universalIdentifier})`;
super(
`Migration action '${args.action.type}' for '${args.action.metadataName}' failed`,
`Migration action '${args.action.type}' for '${args.action.metadataName}'${identifierClause} failed`,
);
this.code = args.code;
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
@@ -378,8 +378,17 @@ export class WorkflowCommonWorkspaceService {
for (const workflowVersion of workflowVersions) {
for (const step of workflowVersion.steps ?? []) {
if (step.type === WorkflowActionType.CODE) {
const logicFunctionId = step.settings.input.logicFunctionId;
if (!isValidUuid(logicFunctionId)) {
this.logger.warn(
`Skipping destroy for CODE step with undefined logicFunctionId in workflow ${workflowId}`,
);
continue;
}
await this.logicFunctionFromSourceService.deleteOneWithSource({
id: step.settings.input.logicFunctionId,
id: logicFunctionId,
workspaceId,
});
}
@@ -188,7 +188,7 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
nextStepIds: [],
settings: {
input: {
logicFunctionId: 'function-id',
logicFunctionId: '550e8400-e29b-41d4-a716-446655440000',
},
outputSchema: {},
errorHandlingOptions: {
@@ -206,7 +206,7 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
expect(
logicFunctionFromSourceService.deleteOneWithSource,
).toHaveBeenCalledWith({
id: 'function-id',
id: '550e8400-e29b-41d4-a716-446655440000',
workspaceId: mockWorkspaceId,
});
});
@@ -91,6 +91,10 @@ export class WorkflowVersionStepOperationsWorkspaceService {
}) {
switch (step.type) {
case WorkflowActionType.CODE: {
if (!isValidUuid(step.settings.input.logicFunctionId)) {
break;
}
await this.logicFunctionFromSourceService.deleteOneWithSource({
id: step.settings.input.logicFunctionId,
workspaceId,
@@ -9,6 +9,10 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import {
type WorkflowToolContext,
@@ -116,6 +120,16 @@ This is the most efficient way for AI to create workflows as it handles all the
activate?: boolean;
}) => {
try {
const codeSteps = parameters.steps.filter(
(step) => step.type === ('CODE' as string),
);
if (codeSteps.length > 0) {
throw new WorkflowVersionStepException(
'CODE steps cannot be created via create_complete_workflow because it does not create the underlying logic function. Use create_workflow_version_step instead.',
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const workflowId = await createWorkflow({
deps,
context,
@@ -25,6 +25,7 @@ export type {
MetadataValidationErrorResponse,
} from './types/MetadataValidationError';
export { WorkspaceMigrationV2ExceptionCode } from './types/MetadataValidationError';
export type { SyncAction } from './types/sync-action.type';
export { addCustomSuffixIfIsReserved } from './utils/add-custom-suffix-if-reserved.util';
export { computeMetadataNameFromLabel } from './utils/compute-metadata-name-from-label.util';
export { computeMetadataNamesFromLabelsOrThrow } from './utils/compute-metadata-names-from-labels-or-throw.util';
@@ -0,0 +1,26 @@
import { type AllMetadataName } from '@/metadata/types/all-metadata-name.type';
type SyncCreateAction = {
type: 'create';
metadataName: AllMetadataName;
flatEntity?: {
name?: string | null;
nameSingular?: string | null;
universalIdentifier?: string | null;
[key: string]: unknown;
};
};
type SyncUpdateAction = {
type: 'update';
metadataName: AllMetadataName;
universalIdentifier: string;
};
type SyncDeleteAction = {
type: 'delete';
metadataName: AllMetadataName;
universalIdentifier: string;
};
export type SyncAction = SyncCreateAction | SyncUpdateAction | SyncDeleteAction;