Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 36952cf81c fix(dns-manager): throw proper DnsManagerException when hostname not found in Cloudflare
https://sonarly.com/issue/33567?type=bug

When a user checks DNS records for their custom domain, and the hostname is not registered in Cloudflare's custom hostnames despite being set in the workspace database, `refreshHostname()` crashes with a generic "Value not defined" error instead of a proper domain-specific exception.
2026-05-02 19:11:16 +00:00
nitinandGitHub 4f439bbe43 [AI] Prefer batch tools in system prompts (#20173) 2026-05-01 14:55:15 +02:00
4fbd8b207d chore: sync AI model catalog from models.dev (#20178)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-05-01 08:50:48 +02:00
f7d812fca6 fix: disable sync on seeded message and calendar channels (#20168)
## Summary

The dev seeder creates `ConnectedAccount` records with fake OAuth tokens
(`'exampleRefreshToken'` / `'exampleAccessToken'`) and points
`MessageChannel` / `CalendarChannel` records at them with
`isSyncEnabled: true`. When the sync cron jobs run in the demo
workspace, they:

1. Pick up these seeded channels (filter is `isSyncEnabled: true` +
pending sync stage)
2. Try to refresh the fake OAuth tokens
3. Mark the channels as `FAILED_INSUFFICIENT_PERMISSIONS`
4. Surface a "Sync lost with mailbox X — please reconnect" banner in the
UI

This banner appears every time the demo workspace is loaded, even though
nothing is actually broken.

## Fix

Set `isSyncEnabled: false` on all 12 seeded channels (6 message, 6
calendar). This is the canonical "don't sync this channel" mechanism —
the same state a real user lands in when they toggle sync off in account
settings.

## Why this approach

- **ConnectedAccount records stay**: the demo workspace still shows Tim,
Jony, Phil, Jane as having connected their email/calendar — realistic
- **Pre-seeded messages and calendar events stay visible**: those don't
depend on `isSyncEnabled`
- **Crons no longer pick them up**: they filter on `isSyncEnabled:
true`, so `false` short-circuits the entire sync attempt — no failure,
no banner
- **Semantically correct**: the seeded accounts have fake tokens that
were never going to sync successfully; `isSyncEnabled: true` was
effectively a lie
- **No production code touched**: no `isDemo` flags, no magic-string
detection, no workspace-ID filters in the cron path

## Alternatives considered and rejected

- **Add an `isDemo` flag**: schema change, leaks demo knowledge into
production tables
- **Skip channels with fake tokens (`example*` pattern)**: hacky
magic-string detection in the auth refresh path
- **Filter demo workspace IDs in the cron**: production paths shouldn't
reference demo IDs
- **Don't activate demo workspaces**: breaks the demo workspace UX
entirely

## Test plan

- [ ] Reset the database and reseed (`npx nx database:reset
twenty-server`)
- [ ] Load the demo workspace — confirm no "Sync lost with mailbox"
banner appears
- [ ] Confirm seeded connected accounts still show in Settings →
Accounts
- [ ] Confirm pre-seeded messages and calendar events still appear in
the UI
- [ ] Confirm a real connected account (added via OAuth) still syncs
normally — its channel will have `isSyncEnabled: true` and the cron will
pick it up

## Related

Companion to https://github.com/twentyhq/twenty/pull/20167, which
removes the `--dev-mode` cron filter that was masking this banner issue
in the dev image.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 07:52:01 +02:00
c3a320c27b fix: register all cron jobs in twenty-app-dev image (#20167)
## Summary

The `twenty-app-dev` Docker image previously passed `--dev-mode` to
`cron:register:all`, which skipped all calendar, messaging, and workflow
sync cron jobs (only 4 generic crons were registered). This caused
periodic sync to silently stop after the initial import for community
members using the dev image as their actual instance.

## What changed

- Removed `--dev-mode` flag from
`packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/register-crons.sh`
so the dev image registers all cron jobs (matching production behavior)
- Removed the now-unused `--dev-mode` option, `DEV_MODE_COMMANDS` set,
and conditional filtering logic from `cron-register-all.command.ts`

## Why this is safe

- **No log noise**: cron jobs gracefully no-op when no connected
accounts exist — they query for pending channels, find zero, and exit
early
- **No false banner**: the "reconnect account" banner only shows when a
user explicitly connected an account whose OAuth later fails, which is
correct behavior. No seed/demo data creates connected accounts, so a
fresh dev instance won't see any banner
- **Hiding crons just hid the symptom**: silently breaking sync with no
user feedback is worse than showing the banner if OAuth is misconfigured

## Context

Surfaced by a community member who reported that calendar sync cron jobs
never appeared in the queue after restarting the dev image, and only the
initial import worked. `--dev-mode` was added in #19138 as an
optimization for development but it doesn't match how the dev image is
actually used by community members deploying Twenty.

## Test plan

- [ ] Build/run the `twenty-app-dev` image
- [ ] Confirm worker logs show all cron jobs registering (calendar,
messaging, workflow, etc.)
- [ ] With no connected accounts: confirm no errors or log noise
- [ ] With a connected Google calendar: confirm periodic sync triggers
after ~5 minutes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 07:51:21 +02:00
12 changed files with 140 additions and 62 deletions
@@ -4,6 +4,6 @@ set -e
echo "==> START Registering cron jobs"
cd /app/packages/twenty-server
yarn command:prod cron:register:all --dev-mode
yarn command:prod cron:register:all
echo "==> DONE"
@@ -1,6 +1,6 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import { Command, CommandRunner } from 'nest-commander';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command';
@@ -68,33 +68,8 @@ export class CronRegisterAllCommand extends CommandRunner {
super();
}
private devMode = false;
@Option({
flags: '--dev-mode',
description:
'Only register cron jobs relevant to app development (cron triggers, marketplace sync, version check, stale cleanup)',
required: false,
})
parseDevMode(): boolean {
this.devMode = true;
return true;
}
private static readonly DEV_MODE_COMMANDS = new Set([
'CronTrigger',
'MarketplaceCatalogSync',
'ApplicationVersionCheck',
'StaleRegistrationCleanup',
]);
async run(): Promise<void> {
this.logger.log(
this.devMode
? 'Registering app-dev cron jobs...'
: 'Registering all background sync cron jobs...',
);
this.logger.log('Registering all background sync cron jobs...');
const allCommands = [
{
@@ -195,18 +170,12 @@ export class CronRegisterAllCommand extends CommandRunner {
},
];
const commands = this.devMode
? allCommands.filter(({ name }) =>
CronRegisterAllCommand.DEV_MODE_COMMANDS.has(name),
)
: allCommands;
let successCount = 0;
let failureCount = 0;
const failures: string[] = [];
const successes: string[] = [];
for (const { name, command } of commands) {
for (const { name, command } of allCommands) {
try {
this.logger.log(`Registering ${name} cron job...`);
await command.run();
@@ -6,19 +6,26 @@ import {
DnsManagerException,
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
import {
BaseGraphQLError,
ConflictError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(DnsManagerException)
export class DnsManagerExceptionFilter implements ExceptionFilter {
catch(exception: DnsManagerException) {
switch (exception.code) {
case DnsManagerExceptionCode.INTERNAL_SERVER_ERROR:
case DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED:
case DnsManagerExceptionCode.HOSTNAME_NOT_REGISTERED:
case DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL:
throw new NotFoundError(exception);
case DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED:
throw new ConflictError(exception);
case DnsManagerExceptionCode.INVALID_INPUT_DATA:
case DnsManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED:
case DnsManagerExceptionCode.MULTIPLE_HOSTNAMES_FOUND:
case DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL:
throw exception;
case DnsManagerExceptionCode.INTERNAL_SERVER_ERROR:
throw new BaseGraphQLError(exception);
default: {
assertUnreachable(exception.code);
}
@@ -288,6 +288,63 @@ describe('DnsManagerService', () => {
});
});
describe('refreshHostname', () => {
it('should throw DnsManagerException when hostname is not found in Cloudflare', async () => {
const hostname = 'example.com';
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [] }),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(dnsManagerService as any).cloudflareClient = cloudflareMock;
await expect(
dnsManagerService.refreshHostname(hostname),
).rejects.toThrow(DnsManagerException);
});
it('should refresh and return hostname records when hostname exists', async () => {
const hostname = 'example.com';
const mockResult = {
id: 'custom-id',
hostname,
verification_errors: [],
ssl: {
dcv_delegation_records: [],
},
};
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [mockResult] }),
edit: jest.fn().mockResolvedValueOnce({}),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
jest
.spyOn(domainServerConfigService, 'getBaseUrl')
.mockReturnValue(new URL('https://front.domain'));
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result = await dnsManagerService.refreshHostname(hostname);
expect(result).toEqual({
id: 'custom-id',
domain: hostname,
records: expect.any(Array),
});
expect(cloudflareMock.customHostnames.edit).toHaveBeenCalledWith(
'custom-id',
{
zone_id: 'test-zone-id',
ssl: expect.any(Object),
},
);
});
});
describe('updateHostname', () => {
it('should update a custom domain and register a new one', async () => {
const fromHostname = 'old.com';
@@ -138,7 +138,16 @@ export class DnsManagerService {
options,
);
assertIsDefinedOrThrow(publicDomainWithRecords);
assertIsDefinedOrThrow(
publicDomainWithRecords,
new DnsManagerException(
'Hostname not found in Cloudflare',
DnsManagerExceptionCode.HOSTNAME_NOT_REGISTERED,
{
userFriendlyMessage: msg`Domain is not registered in Cloudflare`,
},
),
);
await this.cloudflareClient.customHostnames.edit(
publicDomainWithRecords.id,
@@ -8,6 +8,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -31,6 +32,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
)
@UsePipes(ResolverValidationPipe)
@UseFilters(
DnsManagerExceptionFilter,
PublicDomainExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
@@ -23,6 +23,7 @@ import { BillingEntitlementDTO } from 'src/engine/core-modules/billing/dtos/bill
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
@@ -82,6 +83,7 @@ const OriginHeader = createParamDecorator(
@MetadataResolver(() => WorkspaceEntity)
@UsePipes(ResolverValidationPipe)
@UseFilters(
DnsManagerExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
PermissionsGraphqlApiExceptionFilter,
)
@@ -7,6 +7,7 @@ export const WORKFLOW_SYSTEM_PROMPTS = {
Tool usage strategy:
- Chain multiple tools to solve complex tasks
- Prefer batch tools (\`create_many_*\`, \`update_many_*\`, etc.) over looping single-item calls
- If a tool fails, try alternative approaches
- Use results from one tool to inform the next
- Don't give up after first failure - be persistent
@@ -41,6 +41,7 @@ For simple CRUD operations (find/create/update/delete a record), you do NOT need
- Always apply filters to narrow results — don't fetch all records of a type.
- Fetch one type of data at a time and check if you have what you need before fetching more.
- Every record returned consumes context. Fetching too many records at once will cause failures.
- For multiple items of the same type, use batch tools (\`create_many_*\`, \`update_many_*\`, etc.) instead of looping single-item calls.
## Tool Strategy
@@ -1304,6 +1304,17 @@
"contextWindowTokens": 64000,
"maxOutputTokens": 64000
},
{
"name": "mistral-medium-2604",
"label": "Mistral Medium 3.5",
"modelFamily": "MISTRAL",
"inputCostPerMillionTokens": 1.5,
"outputCostPerMillionTokens": 7.5,
"contextWindowTokens": 262144,
"maxOutputTokens": 262144,
"modalities": ["image"],
"supportsReasoning": true
},
{
"name": "devstral-small-2505",
"label": "Devstral Small 2505",
@@ -1322,16 +1333,6 @@
"contextWindowTokens": 128000,
"maxOutputTokens": 128000
},
{
"name": "mistral-medium-latest",
"label": "Mistral Medium (latest)",
"modelFamily": "MISTRAL",
"inputCostPerMillionTokens": 0.4,
"outputCostPerMillionTokens": 2,
"contextWindowTokens": 128000,
"maxOutputTokens": 16384,
"modalities": ["image"]
},
{
"name": "open-mistral-7b",
"label": "Mistral 7B",
@@ -1378,6 +1379,17 @@
"contextWindowTokens": 262144,
"maxOutputTokens": 262144,
"modalities": ["image"]
},
{
"name": "mistral-medium-latest",
"label": "Mistral Medium (latest)",
"modelFamily": "MISTRAL",
"inputCostPerMillionTokens": 1.5,
"outputCostPerMillionTokens": 7.5,
"contextWindowTokens": 262144,
"maxOutputTokens": 262144,
"modalities": ["image"],
"supportsReasoning": true
}
]
},
@@ -1407,6 +1419,24 @@
"maxOutputTokens": 4096,
"modalities": ["image"]
},
{
"name": "grok-4.3",
"label": "Grok 4.3",
"modelFamily": "GROK",
"inputCostPerMillionTokens": 1.25,
"outputCostPerMillionTokens": 2.5,
"cachedInputCostPerMillionTokens": 0.2,
"longContextCost": {
"inputCostPerMillionTokens": 2.5,
"outputCostPerMillionTokens": 5,
"thresholdTokens": 200000,
"cachedInputCostPerMillionTokens": 0.4
},
"contextWindowTokens": 1000000,
"maxOutputTokens": 30000,
"modalities": ["image"],
"supportsReasoning": true
},
{
"name": "grok-3-mini-fast",
"label": "Grok 3 Mini Fast",
@@ -42,7 +42,7 @@ export const CALENDAR_CHANNEL_DATA_SEEDS: CalendarChannelDataSeed[] = [
handle: 'tim@apple.dev',
visibility: CalendarChannelVisibility.METADATA,
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
isSyncEnabled: false,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JONY,
@@ -50,7 +50,7 @@ export const CALENDAR_CHANNEL_DATA_SEEDS: CalendarChannelDataSeed[] = [
handle: 'jony@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
isSyncEnabled: false,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.PHIL,
@@ -58,7 +58,7 @@ export const CALENDAR_CHANNEL_DATA_SEEDS: CalendarChannelDataSeed[] = [
handle: 'phil@apple.dev',
visibility: CalendarChannelVisibility.METADATA,
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
isSyncEnabled: false,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JANE,
@@ -66,7 +66,7 @@ export const CALENDAR_CHANNEL_DATA_SEEDS: CalendarChannelDataSeed[] = [
handle: 'jane.austen@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
isSyncEnabled: false,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.COMPANY_MAIN,
@@ -74,7 +74,7 @@ export const CALENDAR_CHANNEL_DATA_SEEDS: CalendarChannelDataSeed[] = [
handle: 'company-main@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
isSyncEnabled: false,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.TEAM_CALENDAR,
@@ -82,6 +82,6 @@ export const CALENDAR_CHANNEL_DATA_SEEDS: CalendarChannelDataSeed[] = [
handle: 'team-calendar@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
isSyncEnabled: false,
},
];
@@ -59,7 +59,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
handle: 'tim@apple.dev',
isSyncEnabled: true,
isSyncEnabled: false,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},
@@ -72,7 +72,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
handle: 'jony.ive@apple.dev',
isSyncEnabled: true,
isSyncEnabled: false,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},
@@ -85,7 +85,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.PHIL,
handle: 'phil.schiler@apple.dev',
isSyncEnabled: true,
isSyncEnabled: false,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},
@@ -98,7 +98,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
isSyncEnabled: true,
isSyncEnabled: false,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},
@@ -111,7 +111,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM, // Use TIM's connected account for shared inbox
handle: 'support@apple.dev',
isSyncEnabled: true,
isSyncEnabled: false,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},
@@ -124,7 +124,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM, // Use TIM's connected account for shared inbox
handle: 'sales@apple.dev',
isSyncEnabled: true,
isSyncEnabled: false,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},