Commit Graph

5500 Commits

Author SHA1 Message Date
Weiko ae0ffb1373 perf: use cache for view entity lookups (#23384)
## Context

View child mutation guards resolve a parent view before checking access.
The lookup service queried PostgreSQL for a single `viewId`, even though
the same relationship already exists in the workspace flat-map cache.

With 15 guards using this service, each guarded mutation could add an
unnecessary database round trip.

## What changed

- Replace the five workspace-scoped repositories with
`WorkspaceManyOrAllFlatEntityMapsCacheService`
- Load only the flat map matching the requested child kind
- Resolve view fields, filters, filter groups, groups, and sorts by ID
- Preserve the existing `null` behavior for missing entities

Each lookup is keyed by entity ID, no workspace-wide filtering is
introduced.

## Expected impact

On a warm workspace cache, permission guards resolve the parent `viewId`
without querying PostgreSQL. Cold caches retain the normal workspace
cache recomputation behavior.

## Validation

- Typecheck reports no errors in the changed file
- Existing lookup semantics are preserved for all supported entity kinds
and missing IDs

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23384?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 07:54:24 +00:00
Weiko 19903f89e5 Add indexes for channel webhook subscription external IDs (#23386)
## Context

Incoming Microsoft messaging, Microsoft calendar, and Google calendar
webhook notifications resolve their channel through
`webhookSubscriptionExternalId`.

The column was added without an index, so PostgreSQL has to scan the
corresponding channel table for every notification. Under sustained
webhook traffic, these repeated scans add unnecessary database work and
keep core database connections occupied longer.

## What changed

- Add a partial B-tree index on
`messageChannel.webhookSubscriptionExternalId`
- Add the equivalent index on
`calendarChannel.webhookSubscriptionExternalId`
- Register both indexes in the TypeORM entity metadata
- Add an idempotent 2.25 fast instance upgrade command to create and
remove them

The webhook handlers and their queries remain unchanged.

## Why this design

- The indexes contain only non-null subscription IDs, channels without
an active subscription do not add index entries
- A single-column index supports both the equality lookup used by Google
and the `IN` lookup used by Microsoft
- The indexes are intentionally non-unique, this preserves existing
behavior and avoids making the upgrade fail if historical duplicate
values exist
- Subscription IDs are read much more often than they are updated, so
index maintenance overhead should be negligible

## Expected impact

Webhook channel resolution should require a targeted index lookup
instead of a table scan. This reduces database work, shortens connection
occupancy, and improves latency on webhook notification paths.

This is a targeted database optimization. It complements the database
pool changes, but is not expected to resolve every source of API tail
latency by itself.

## Validation

- Server typecheck passes
- Oxlint and formatting checks pass
- Upgrade command uses idempotent `CREATE INDEX IF NOT EXISTS` and `DROP
INDEX IF EXISTS` statements

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23386?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 07:54:15 +00:00
Weiko cc5ff4869d perf: use cache for role validation (#23383)
## Context

Role assignment validation queried PostgreSQL only to check whether a
role exists and whether `canBeAssignedToUsers` is enabled. Both values
already exist in `flatRoleMaps`.

This validation runs when inviting users and assigning a role to a user
workspace.

## What changed

- Replace the role repository lookup with `flatRoleMaps`
- Resolve the role through the existing keyed flat-map helper
- Preserve the existing role-not-found and role-not-assignable errors
- Replace unused TypeORM module wiring with the flat entity cache module

## Expected impact

On a warm workspace cache, role assignment validation uses an O(1) map
lookup and avoids a PostgreSQL round trip. Cold caches retain the normal
workspace cache recomputation behavior.

## Validation

- Typecheck reports no errors in the changed files
- Existing validation outcomes and exception codes are preserved

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23383?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 07:53:42 +00:00
Weiko 8481c76bfb perf: use cache for webhook reads (#23382)
## Context

Webhook reads queried both the webhook and application tables, then
rebuilt the same flat webhook representation already maintained by the
workspace cache.

This affected REST, GraphQL, and the webhook listing tool.

## What changed

- Read `findAll` and `findById` from `flatWebhookMaps`
- Keep `findById` as a keyed ID lookup
- Preserve `findAll` ordering by `createdAt`
- Remove unused webhook and application repository wiring

The flat webhook cache contains only active webhooks and already
includes the application universal identifier needed by the DTO
conversion.

## Expected impact

On a warm workspace cache, webhook reads avoid queries to both the
webhook and application tables. `findAll` still iterates over every
returned webhook, matching the original query's result cardinality,
while `findById` uses a keyed lookup.

## Validation

- Typecheck reports no errors in the changed files
- `findAll` ordering and `findById` missing-record behavior are
preserved

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23382?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 07:53:29 +00:00
Paul Rastoin 49e2272fb9 fix(workspace-migration): stop leaking workspace ids in delete action payloads (#23377)
Closes https://github.com/twentyhq/core-team-issues/issues/2732

## Problem

Workspace migration delete actions embedded the raw workspace-cache flat
entity as their `flatEntity` payload, leaking:

- `id`, `workspaceId`, `applicationId`
- raw many-to-one join columns (`objectMetadataId`,
`relationTargetFieldMetadataId`, ...)
- raw FK aggregators (`viewFieldIds`, ...)
- raw jsonb properties containing serialized relations (`settings`,
`overrides`, `configuration`)

Create actions already expose universal identifiers only. The asymmetry
made identical migrations non-portable across workspaces (payloads embed
random workspace primary keys) and caused snapshot flakiness in
integration suites.

## Fix

- Add `deleteFlatEntityForeignKeyAggregators` (raw-side counterpart of
`deleteUniversalFlatEntityForeignKeyAggregators`, following the
`flatEntityForeignKeyAggregator` /
`universalFlatEntityForeignKeyAggregator` naming of
`ALL_ONE_TO_MANY_METADATA_RELATIONS`). It strips base workspace-scoped
properties, every property registered with a `universalProperty`
counterpart in `ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME`
(covers raw join columns and serialized jsonb, including cases not
modeled as many-to-one relations like `labelIdentifierFieldMetadataId`),
and raw one-to-many `...Ids` aggregators. Its scope is disjoint from the
universal-side util.
- Apply it in the delete branch of
`WorkspaceEntityMigrationBuilderService` — the single point where
delete-action `flatEntity` is attached — so the payload matches its
`MetadataUniversalFlatEntity<T>` type at runtime. Universal
`...UniversalIdentifiers` aggregators are kept (they are portable), so
`BaseUniversalDeleteWorkspaceMigrationAction` needs no type change.
- Regenerate the affected
`successful-sync-application-workspace-migration` snapshot: the delete
payload now only carries universal identifiers.

Safe downstream: the runner resolves delete targets via
`universalIdentifier` lookups in current maps and metadata events fetch
the deleted entity from maps by `entityId`; no consumer reads the
stripped properties (only create handlers consume `action.flatEntity`).

Note: the `normalizeIdCollections` mitigation flag mentioned in the
issue does not exist on `main`, so there was nothing to remove.

## Tests

- New snapshot-based unit spec for the strip util (objectMetadata and
fieldMetadata shapes, plus input immutability).
- Full twenty-server unit suite: 6922 passed.
- Integration with live DB: full `metadata/suites/application` (50
suites), object/field/index/agent metadata suites, all 26
`graphql/suites/view` suites, `failing-agent-deletion`,
`object-identifier-update-side-effect-on-view-field` — all green, no
other snapshot changes.
2026-07-27 17:30:29 +00:00
Weiko d7b1ccaa21 Cache field metadata while processing common query results (#23353)
## Context

`CommonResultGettersService` post-processes API query results after they
are loaded. It recursively walks records and relations, identifies field
metadata, and runs result handlers such as file URL signing.

Production profiling of tail-latency requests showed local CPU
concentrated in this service when processing large nested result sets.

Before this change:

- Field name maps were rebuilt for each recursive record-array call. A
repeated one-to-many relation rebuilt the same child-object map once per
parent.
- Every record's keys were scanned three times, once for handlers, once
for relations, and once for the metadata passed to handlers.
- Each scan resolved names through an ID lookup. ORM-only keys such as
join columns have no matching field metadata, and the non-throwing
lookup handled those misses by throwing and catching an exception
internally.

This work is small for one record, but multiplies across every nested
record and can block the Node.js event loop for large responses.

## What changed

- Create an invocation-local processing context from the metadata maps
already supplied to the service.
- Build a `field name -> field metadata` map once per distinct object
type.
- Share that context across root records and recursive relation
processing.
- Scan each record once and reuse the resolved metadata for handlers and
relations.
- Resolve record keys with direct `Map.get` calls, so keys without
metadata are skipped without entering an exception path.

For example, when the same child object type is visited under 200 parent
records, field-map preparation drops from 201 builds to 2, one for each
distinct object type. Per-record metadata scans drop from three to one.

## Why this is safe

- The cache exists only for one public `processRecord` or
`processRecordArray` invocation. It is not stored on the singleton
service and cannot retain metadata across requests or workspaces.
- Handler selection, execution order, and existing duplicate-handler
behavior are preserved.
- Relation traversal order and relation-type behavior are unchanged.
- Record keys without field metadata remain in the returned object.
- Query selection, database access, pagination, and response shape are
unchanged.

## Expected impact

This removes repeated metadata preparation, array allocation, and
exception construction from the hot path. The improvement should be most
visible in API tail latency and event-loop delay for wide nested
responses. Small responses should see little change.

This does not reduce database time or the size of large responses.
Response fan-out remains a separate concern if those requests are still
too expensive after this optimization.

## Tests

- Verify field handlers still run and fields without metadata are
preserved.
- Verify nested one-to-many records keep their output and ordering.
- Verify field metadata is resolved once per distinct object type within
a single invocation, and rebuilt on the next invocation.
2026-07-27 16:55:10 +00:00
Weiko 24ccdb9b5d Replace Redis key scanning with sorted-set event stream tracking (#23326)
## Context

The `twenty_event_streams_live_total` gauge counted live streams by
SCANning every `workspace:*:activeStreams` key and summing set
cardinalities. A full metric refresh walks the entire Redis keyspace, on
every server instance, and its cost grows with unrelated cache data
rather than with the number of streams.

## What changed

Adds a metric-only sorted set, `activeStreamExpirations`. Members are
`workspaceId:eventStreamChannelId`, scores are expiration timestamps:

- Create and successful heartbeat refresh: `ZADD` with score `now +
EVENT_STREAM_TTL_MS`
- Destroy and stale cleanup: `ZREM`
- Gauge read: `ZREMRANGEBYSCORE` + `ZCARD` in one transaction

The scan-and-count cache helper is removed.

Scores are written at the same moments the stream key TTL is set, so a
member expires exactly when its stream key would. Any missed cleanup
(crashed pod, failed heartbeat) resolves itself at the next gauge read.
Metric writes are best effort: failures are logged and never affect
stream creation, refresh, or cleanup. Existing stream keys and
application behavior are unchanged, and no migration is needed.

## Tradeoffs

- One extra `ZADD` per 30-second heartbeat
- During a rolling deploy, streams owned by old pods appear in the gauge
after their next heartbeat (undercount bounded by one heartbeat
interval)
- A destroy racing a concurrent refresh can leave one orphaned member
until its score lapses (gauge over-counts by 1 for at most one TTL)

## Testing

- Unit coverage for the sorted-set cache helpers and the stream
lifecycle (create, refresh success/failure, destroy, stale cleanup)
- `npx nx typecheck twenty-server`, targeted Oxlint, 14 tests passing
2026-07-27 16:35:54 +00:00
Weiko a66aacfb82 perf: deduplicate tool permission role loads (#23366)
## Context

Building the tool catalog asks every provider whether it is available
for the current role configuration. Several providers perform multiple
permission checks, so one catalog build can evaluate the same roles
repeatedly.

Previously, each `checkRolesPermissions` or `hasToolPermission` call
loaded the configured roles and their permission flags from PostgreSQL.
These queries returned data that already exists in the workspace cache:

- `flatRoleMaps` contains role settings and the IDs of assigned
permission flags
- `flatRolePermissionFlagMaps` links those assignments to permission
flag universal identifiers

This created redundant database round trips on the latency-sensitive
tool discovery path.

## What changed

Permission checks now evaluate roles from the existing workspace cache
instead of loading `RoleEntity` records and relations from PostgreSQL.

The new flow:

1. Load `flatRoleMaps` and `flatRolePermissionFlagMaps` through
`WorkspaceCacheService`
2. Resolve every role ID from `flatRoleMaps`
3. Check `canAccessAllTools` or `canUpdateAllSettings`
4. If needed, check explicit permission flags with
`flatRoleHasPermissionFlag`
5. Apply the existing union or intersection rule

This also benefits callers outside the tool registry, without adding
provider parameters or request-scoped context plumbing.

The direct agent-only role deletion path now invalidates and recomputes
the two consumed cache maps after deleting a role. This prevents that
path from leaving stale permission data behind.

## Why this is safe

The authorization behavior remains unchanged:

- `shouldBypassPermissionChecks` still grants access without loading
permission data
- A union grants access when at least one role grants it
- An intersection grants access only when every role grants it
- Base role permissions and explicitly assigned permission flags are
both supported
- Empty, duplicate, or missing role IDs fail closed
- Cache failures fail closed

This PR does not introduce a separate permission cache. It reuses the
existing workspace metadata cache and its invalidation model.

## Expected impact

On a warm workspace cache, these permission checks no longer query the
role tables. Repeated checks during catalog and schema construction
become in-memory cache lookups, reducing database pressure and avoiding
repeated network round trips.

A cold cache can still require its normal database recomputation.
Subsequent permission checks reuse the populated workspace cache.

## Test coverage

The permission service tests cover:

- Union and intersection behavior
- Base role grants
- Explicit permission flag grants
- Unrelated permission flags
- Permission bypass
- Empty, duplicate, and missing roles
- Cache failures
- No role repository query during cached evaluation

The agent-role tests also verify that deleting an unused agent-only role
refreshes the relevant cache maps, while a role that remains assigned
does not trigger deletion or invalidation.
2026-07-27 16:32:54 +00:00
Thomas Trompette 2c49c4169c feat(workflow): remove async workflowVersion core dual-write listener (#23374)
## What

Removes `WorkflowVersionCoreDualWriteListener` (and its now-empty
module), replacing the last async best-effort core writes for workflow
versions with synchronous mirrors. Adds one missing synchronous funnel
so nothing is left uncovered.

## Why

After #23356, the listener's `handleRestored` / `handleDeleted` /
`handleDestroyed` handlers are redundant with the synchronous lifecycle
mirror, so the async path (which can silently drift on failure) can go.

While removing it I found one path the listener was **not** redundant
on: **direct `deleteOneWorkflowVersion` (discard draft)** is an allowed
operation (discard a DRAFT version that isn't the only version, via the
`DISCARD_DRAFT_WORKFLOW` command) and had **no** synchronous post-hook.
The async listener was the sole thing deleting its core row. Removing
the listener without a replacement would have drifted on every draft
discard.

So this PR also adds a `workflowVersion.deleteOne` post-hook that
mirrors the deletion to core.

## Coverage after this change

| version lifecycle path | synchronous coverage |
| --- | --- |
| `deleteOneWorkflowVersion` (discard draft) | **new**
`workflowVersion.deleteOne` post-hook |
| delete via workflow cascade | `handleWorkflowSubEntities` ->
`deleteCoreVersionsByWorkflowIds` (#23356) |
| restore via workflow cascade | `handleWorkflowSubEntities` ->
`recreateCoreVersionsByWorkflowId` (#23356) |
| destroy via workflow | `workflow.destroy*` post-hooks (#23356) |
| `deleteMany` / `destroyOne|Many` / `restoreOne|Many` version | blocked
by pre-hooks ("Method not allowed") |

## Notes

- The new post-hook re-fetches the version (`withDeleted`) to resolve
its `coreWorkflowVersionId`, because the delete post-hook payload only
carries the columns the client selected (the delete `RETURNING` set is
built from `selectedFieldsResult.select`), so `coreWorkflowVersionId` is
not reliably present.
- `deleteCoreVersionsByWorkspaceVersionIds` deletes precisely by
`coreWorkflowVersionId` (not by `workflowId`), so discarding one draft
does not touch the core rows of the workflow's other versions.
- The workflow-side `WorkflowCoreSyncModule` listener is intentionally
left in place (separate migration track).

## Verification

- `nx typecheck twenty-server` green
- `nx lint:diff-with-main twenty-server` green
- Added integration test `workflow-version-discard-draft-core-mirror`:
activate v1, create a draft, discard it, assert only the draft's core
row is removed and the active version's core row remains.
- Live run on a dev instance still pending.

## Merge gate

Per the migration plan, removing the async backstop should land only
after the drift cron reports zero drift over a soak period.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23374?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-27 15:52:15 +00:00
Thomas Trompette 79bf20515d feat(workflow): mirror version delete/restore/destroy to core transactionally (#23356)
Next step in the workflowVersion -> core soft-ref migration. The
transactional mirror (#23243) made **content** writes (create/update)
drift-free. This does the same for the **lifecycle** events (delete /
restore / destroy), which were still handled only by the async
best-effort listener. It's the prerequisite for dropping that listener.

## What changed
`delete` and `restore` already soft-delete / restore the workflow's
versions inside `handleWorkflowSubEntities` (twenty doesn't cascade
soft-deletes, so it does each sub-entity explicitly). So the core
delete/recreate just sits next to the existing version write:

- **delete** — after `workflowVersionRepository.softDelete({ workflowId
})`, `deleteCoreVersionsByWorkflowIds` removes the
`core.workflowVersion` rows (`workflowId IN (...)`).
(`deactivateVersionOnDelete` no longer re-mirrors the deactivated
version — that was recreating the core row it just deleted; it only
flips the workspace status to `DEACTIVATED` so a restore comes back
deactivated.)
- **restore** — after `workflowVersionRepository.restore({ workflowId
})`, `recreateCoreVersionsByWorkflowId` re-reads the restored versions
and reuses the existing `upsertToCore` (which reuses the stored
`coreWorkflowVersionId` soft-ref, so rows come back with their original
ids and current status).
- **destroy** — version destroy isn't done in
`handleWorkflowSubEntities` (it happens via the generic cascade), so
there's no existing place to hang the core delete. New
`workflow.destroyOne`/`destroyMany` **post**-hooks call
`deleteCoreVersionsByWorkflowIds` (batched `IN`) only after the destroy
commits, so a rejected destroy can't remove core rows while the
workspace versions survive.

No new transactional wrappers or raw SQL — the delete/recreate reuse the
existing `WorkflowVersionCoreSyncService` methods
(`deleteFromCore`-style delete, `upsertToCore`). The async listener
stays as an idempotent backstop until the cron soaks zero drift.

## Async listener kept as backstop
`handleRestored` / `handleDeleted` / `handleDestroyed` stay for now.
Both paths are idempotent (delete-of-deleted is a no-op; upsert
converges), so they don't conflict. Those handlers come out in a
follow-up once the consistency cron soaks zero drift - which this PR
unblocks.

## Verification
Lifecycle integration test — creates a workflow, **activates** the
version (the active path is where the delete re-mirror bug bit), then
asserts the core row: present -> gone after delete -> back after restore
(as `DEACTIVATED`, same id) -> gone after destroy. Plus the existing
`workflow-resolver` delete/restore suite (regression, since
`handleWorkflowSubEntities` is shared).

Also verified **live** on a running instance against the real DB: the
full active-version lifecycle above, plus a batched `destroyWorkflows`
on two workflows removing both core rows in one `IN` delete. `nx
typecheck` + oxlint + oxfmt clean.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23356?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-27 14:43:59 +00:00
Weiko 5c23ddb1ac Dedupe common result handlers (#23364)
## Context

`CommonResultGettersService` selects field handlers by mapping every
returned record field to the handler registered for its metadata type.

Handlers are shared instances, and each handler already receives the
complete field metadata list. This means that when multiple fields have
the same handled type, the same handler is added and executed multiple
times.

For example, a record with two `FILES` fields previously produced this
execution list:

```text
[objectHandler, filesHandler, filesHandler]
```

Each `filesHandler` execution processes both `FILES` fields. As a
result, both file URLs were signed twice. With several fields of the
same type, this can make the work grow quadratically.

The same duplication can affect rich-text processing, including JSON
parsing, serialization, and embedded file URL signing.

## What changed

Field handler instances are collected in an insertion-ordered `Set`
before execution:

```text
[objectHandler, filesHandler]
```

Each distinct field handler now runs once per record and continues to
process every matching field.

## Why this is safe

- The object-specific handler still runs first.
- Different field-handler types keep their first-seen order.
- No field is skipped, handlers still receive the complete field
metadata list.
- Requests with zero or one field for a handled type are unchanged.
- No cache or cross-request state is introduced.

## Expected impact

This removes repeated synchronous file-token signing and repeated
rich-text transformations for records with multiple fields of the same
handled type. It also reduces event-loop blocking when large result sets
contain several file or rich-text fields.

## Tests

Added a regression test with two `FILES` fields. It verifies that both
fields are processed while `signFileByIdUrl` is called exactly once per
file, two calls instead of the previous four.

Validated with:

```bash
yarn nx jest twenty-server --runInBand --runTestsByPath src/engine/api/common/common-result-getters/__tests__/common-result-getters.service.spec.ts
```

Touched files also pass type-aware Oxlint and Oxfmt.
2026-07-27 14:02:55 +00:00
Weiko 0c57d7c108 perf: reuse Google webhook OAuth client (#23361)
## Context
A new client currently downloads signing certificates for every webhook

## Fix
reuse same OAuth client instance

## Impact
Probably small but not really risky to merge imho
2026-07-27 13:42:59 +00:00
Raphaël Bosi 2899058b5f Warn users before front components navigate to an external site (#23270)
https://github.com/user-attachments/assets/af3fb042-d066-4e0c-9348-f86ea92a6fcd



Front component anchors render a real host `<a>`, so clicking a link to
another domain performed an uncontrolled full-page navigation. This adds
a phishing-resistant "you're leaving Twenty" confirmation modal before
navigating to an external origin (Fixes
[#23260](https://github.com/twentyhq/twenty/issues/23260)).

The renderer intercepts external anchor clicks in
`createHtmlHostWrapper` and hands the destination to a host callback via
context; twenty-front owns the modal (reuses `ConfirmationModal`) and a
per-application list of trusted origins persisted in localStorage. A
"Don't ask again for this site" checkbox (checked by default) skips the
modal next time for that app.

Scope is external cross-origin http(s) links only; same-origin links
keep native behavior. External links always open in a new tab, so a
component can never navigate the Twenty tab away, even once its origin
is trusted. The modal is rendered by the trusted host, so components
cannot style or suppress it.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23270?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-27 13:21:38 +00:00
github-actions[bot] 7804111e6c i18n - translations (#23360)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23360?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-27 15:07:33 +02:00
Raphaël Bosi 56245a35af Stop leaking the refresh token in the social SSO redirect URL (#23061)
The Google/Microsoft callback for a sign-in with no target workspace
redirected to `/sign-in-up?tokenPair={...}`, putting a 60-day refresh
token in a query string. Those persist in browser history, `Referer`
headers and access logs.

It now carries a single-use, 5-minute opaque token in the URL fragment,
which the frontend exchanges over POST. Browsers never send the fragment
on the wire, so the token stays out of access logs, proxies and
`Referer` headers entirely. Redemption claims the row with a `DELETE`
guarded on `revokedAt`/`deletedAt` being null, so concurrent requests
cannot each mint a refresh token and a revoked token cannot redeem.
Enterprise SSO (OIDC/SAML) already used a POST exchange and is
unchanged.

```mermaid
sequenceDiagram
  participant Browser
  participant Server
  participant DB

  Note over Browser,Server: before, the redirect carried access + 60-day refresh in ?tokenPair
  Browser->>Server: GET /auth/google/redirect
  Server->>DB: store sha256(token), expires in 5 min
  Server-->>Browser: 302 /sign-in-up#ssoExchangeToken=opaque
  Note over Browser: fragment never sent back to any server
  Browser->>Server: POST getAuthTokensFromSSOExchangeToken
  Server->>DB: guarded DELETE, single-use claim
  Server-->>Browser: access + refresh token, in the response body
```

Since the token is single-use, the refresh token is minted at redemption
instead of at callback, so an abandoned redirect leaves an inert expired
hash rather than a live credential.

Redemption lives in its own `SignInUpSSOExchangeTokenEffect` +
`useRedeemSSOExchangeToken`, mirroring the existing
`VerifyLoginTokenEffect` + `useVerifyLogin` pair, so
`SignInUpGlobalScopeFormEffect` only loses the vulnerable branch. Like
`useVerifyLogin`, the hook clears any stale token pair before
exchanging. The effect reads `window.location.hash` live and strips it
synchronously, which doubles as the StrictMode double-invocation latch.

Remaining exposure is the browser itself (history until the synchronous
strip, client-side scripts), same as any fragment-based OAuth response.
`loginToken` on the workspace-targeted branch still travels as
`/verify?loginToken=` and is replayable for 15 minutes; moving it to the
fragment too is a separate change.

A fast instance command adds a unique partial index on `("type",
"value")` for live SSO exchange tokens, so redemption is an index lookup
instead of a full scan of the shared token table and at most one row can
ever match.
2026-07-27 12:58:42 +00:00
github-actions[bot] 88f20a3731 i18n - translations (#23352)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-27 12:34:28 +02:00
Weiko cdd78462b9 fix: block route triggers for suspended workspaces (#23347)
## Problem

Logic function route triggers (app HTTP endpoints served under `/s/*`
and public domains) kept serving traffic for suspended workspaces. A
workspace suspended for non-payment (`activationStatus = SUSPENDED`)
still served its route triggers for the entire suspension window until
soft-deletion removed it from domain lookup. Every other trigger path
gates on activation status — cron triggers only process `ACTIVE`
workspaces — but the route trigger path had no check at all.

## Fix

`RouteTriggerService.getLogicFunctionWithPathParamsOrFail` now rejects
requests when the resolved workspace has `activationStatus = SUSPENDED`,
throwing a `RouteTriggerException` with a new `WORKSPACE_SUSPENDED` code
mapped to `403 Forbidden` in the REST exception filter. Scope is
intentionally limited to `SUSPENDED`; other non-active statuses and the
DB event trigger path are untouched.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23347?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-27 10:25:34 +00:00
Guillaume Flambard 710d4da4b1 fix(emails): bump @react-email/render to ^2.0.6 to fix empty transactional email bodies (#23323)
## Problem

Fixes #23307. Every transactional email (workspace invite, password
reset,
email verification, etc.) is delivered with an **empty body** — no
title, text,
or CTA.

## Root cause

`twenty-server` pins `@react-email/render` directly at `^1.2.3`:

```jsonc
// packages/twenty-server/package.json
"@react-email/render": "^1.2.3",
```

In 1.2.3, `render()` reads `renderToReadableStream` **before** the email
template's async Suspense boundary (i18n/locale load) has resolved. The
result
is the Suspense fallback marker instead of the real markup:

```html
<!DOCTYPE html ...><!--$!--><template></template><!--/$-->
```

This was fixed upstream in `@react-email/render@2.0.6`
(*"await stream.allReady before reading renderToReadableStream
output"*).
`twenty-emails` already resolves a 2.x render via `react-email@6.5.0`,
so the
server's direct pin was simply stale — the two were out of sync.

## Fix

Bump the direct pin to `^2.0.6` (resolves to `2.1.0`) and regenerate the
lockfile. The server's `render()` imports now use the fixed 2.x.

> Note: a `1.2.3` entry remains in `yarn.lock` — it is an internal
transitive
> pin of `@react-email/components@0.5.3`, not the server render path, so
it is
> expected and harmless.

## Verification

Rendering `SendInviteLinkEmail` through the real `render()` (Node 24)
now
returns full markup (5.7 kB) with no Suspense marker and the resolved
invite
link + workspace content, instead of the empty fallback.

A jest unit test was intentionally not added: `@react-email/render` 2.x
uses a
dynamic import that jest's CJS runtime rejects ("A dynamic import
callback was
invoked without --experimental-vm-modules") — which is exactly why the
existing
email specs mock `render`. The fix was verified with a standalone Node
script.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23323?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-07-27 11:37:09 +02:00
Thomas Trompette 46a3a83866 feat(workflow): mirror all workflowVersion writes to core in-transaction, drop async create/update dual-write (#23243)
Switches the workflowVersion -> core dual-write from the async,
best-effort listener to a **transactional mirror**, wires every
content-write funnel through it, and drops the async create/update
handlers. Core can no longer drift from the workspace on any covered
path: the core copy commits or rolls back atomically with the workspace
write.

## Helper (`WorkflowVersionCoreSyncService`)
Two entry points, both writing `core.workflowVersion` and stamping the
`coreWorkflowVersionId` soft-ref on the **caller's transaction
manager**:

- `writeWorkflowVersionAndMirror(workspaceId, write)` - for funnels that
don't own a transaction. Opens a workspace queryRunner, runs the
caller's workspace write on that manager, re-reads the row, mirrors to
core in the same tx, commits, then invalidates the trigger-map cache
post-commit.
- `mirrorWorkflowVersionWrite({ workspaceId, entityManager,
workflowVersion })` - for funnels that already own a queryRunner tx
(activation / deactivation / delete-cascade); they just gain this one
call on their existing manager before commit.

`invalidateAutomatedTriggerMaps` is public so tx-owning callers run it
post-commit.

## Funnels wired (all content writes now mirror in-transaction)
- `updateWorkflowVersionStepsAndTrigger` (central builder step/trigger
edit)
- edge create/delete (4 trigger/step writes)
- `createDraftFromWorkflowVersion` (update + insert),
`duplicateWorkflow` (content update), `updateWorkflowVersionPositions`
- iterator / if-else empty-node step writes
- `workflow.createOne` / `createMany` post-hooks (v1 draft insert)
- AI `create_complete_workflow` tool (v1 insert)
- activation / deactivation status writes (ACTIVE / ARCHIVED /
DEACTIVATED) on their existing tx
- `deactivateVersionOnDelete` cascade (ACTIVE -> DEACTIVATED) on its
existing tx

Direct `workflowVersion.createOne/createMany` is forbidden by a
pre-hook, so there is no un-funneled create path.

## Async listener trimmed
`handleCreated` and `handleUpdated` are removed: every create/update now
mirrors in-transaction, so the post-commit handlers were redundant and
were the source of the rollback-drift (an edit that rolls back still
emitted an `UPDATED` event carrying the uncommitted payload, which the
async listener wrote to core).

`handleRestored`, `handleDeleted`, `handleDestroyed` are **kept**.
Soft-delete/restore go through the generic ORM (not a funnel):
`handleDeleted` drops the core row on soft-delete, and `handleRestored`
recreates it on restore (the restore path just calls
`workflowVersionRepository.restore()`). Removing the restore handler
would leave restored versions with no core row, so the delete/restore
pair stays async.

## Why the core write is raw SQL
`core.workflowVersion` is on the core DataSource, not the workspace
DataSource, so a repository can't be pointed at it from the workspace
queryRunner's manager. But both schemas are one Postgres DB and a
queryRunner is a single connection, so a schema-qualified `INSERT INTO
core."workflowVersion" ... ON CONFLICT` on that manager participates in
the workspace transaction (the prefill util's pattern). The workspace
write and soft-ref write-back go through the ORM's
`repository.update(criteria, data, undefined, queryRunner.manager)`, so
the source-of-truth workspace write keeps its ORM machinery (actor
stamping, search vector, events); only the dumb core mirror is raw,
confined to the helper.

**jsonb:** the raw insert has no entity transformer, so
`triggers`/`steps` are passed as JSON strings (Postgres parses them) -
the inverse of the prefill core insert (the #23204 double-encode trap),
covered by the test. `universalIdentifier` is minted only for a new
link; on conflict only `triggers`/`steps`/`status` are updated.

## Test
In-process integration test: opens a workspace queryRunner, calls the
helper, asserts the core row is visible inside the tx with correct
native jsonb, rolls back, asserts the core row is gone - empirically
confirming one workspace queryRunner writes `core.*` in the same tx.

## Review feedback addressed
- **Duplicate atomicity:** `duplicateWorkflow` now inserts the draft
version with its final steps/trigger inside
`writeWorkflowVersionAndMirror`, so a mirror failure rolls back the
whole version instead of leaving an unmirrored empty draft.
- **Delete cascade:** `deactivateVersionOnDelete` moved its command-menu
cleanup after commit, so a rolled-back deactivation can no longer strip
the menu item from a still-active version.
- **Rollback drift / unlinked rows:** resolved structurally by the
in-transaction mirror + removal of the async CREATED/UPDATED handlers,
and by the soft-ref-field guard that skips mirroring (returns null) on
workspaces without `coreWorkflowVersionId`.
- **Unit suites:** the step-helpers, step-operations and edge suites now
provide a `WorkflowVersionCoreSyncService` mock whose
`writeWorkflowVersionAndMirror` runs the write callback against the test
repo; all three pass (35 tests).

## Verification
Rebased onto `origin/main`. `nx typecheck twenty-server` is green, the
three affected unit suites pass (35 tests), and every changed file
passes type-aware oxlint + oxfmt. Integration suites were failing only
on behind-main DB-init drift (`core.keyValuePair` / data-migration),
which the rebase resolves. Live end-to-end test on a running instance
still pending.
2026-07-27 09:17:17 +00:00
Weiko a96dc335ab fix: apply configured pool size to core database (#23322)
## Context

`PG_POOL_MAX_CONNECTIONS` is the server setting for the maximum number
of PostgreSQL clients in a connection pool. The workspace primary and
replica data sources already apply this setting, but the core TypeORM
data source did not.

Without an explicit `poolSize`, `node-postgres` uses its default limit
of 10. As a result, deployments configured with a larger pool still kept
the core pool at 10 connections per server process.

During bursts of core database work, requests could therefore wait for a
local pool connection even when PostgreSQL itself still had available
capacity. That acquisition queue adds latency before the query starts,
so database-level utilization alone does not reveal the bottleneck.

## What changes

The core data source now applies:

```ts
poolSize: Number(process.env.PG_POOL_MAX_CONNECTIONS ?? 10)
```

This makes the core data source consistent with the workspace data
sources and with the documented meaning of `PG_POOL_MAX_CONNECTIONS`.

## Expected impact

Deployments that configure a value above 10 can use that capacity for
core database operations instead of queueing behind the driver's default
limit. This targets short acquisition spikes affecting operations backed
by the core database.

The pool remains lazy, so this changes the maximum number of connections
available to each process, it does not eagerly open every configured
connection.

## Safety

- Deployments without `PG_POOL_MAX_CONNECTIONS` keep the previous limit
of 10.
- Query behavior, transaction behavior, and timeouts are unchanged.
- Workspace pool configuration is unchanged.
- Operators remain responsible for choosing a value compatible with
their total PostgreSQL connection budget and maximum server replica
count.

## Scope

This removes an unintended local connection-pool bottleneck. It does not
address the source of synchronized database bursts, which should be
handled separately by reducing unnecessary work.

## Testing

Added a regression test that loads the core data source with
`PG_POOL_MAX_CONNECTIONS=40` and verifies that TypeORM receives
`poolSize: 40`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23322?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-27 08:58:41 +00:00
Weiko 42c598a33a Filter stale sync workspaces by active channels (#23321)
## Context

The hourly calendar and messaging stale-sync crons currently load every
active workspace and enqueue one recovery job per workspace. Each
recovery job then enters the workspace context and queries its channels,
even when that workspace has no stale sync.

As the number of workspaces grows, the cost of this check grows with
every workspace rather than with the number of syncs that actually need
recovery. Because both crons run on the hour, this also creates a
synchronized burst of mostly unnecessary queue, database, and
workspace-context work.

## What changes

The crons now use TypeORM repository `find` operations on
`calendarChannel` and `messageChannel` before enqueueing recovery jobs:

- Select only `workspaceId` from matching channels.
- Keep only active, non-deleted workspaces.
- Keep only the scheduled and ongoing stages handled by the existing
recovery jobs.
- Treat a sync as stale when `syncStageStartedAt` is null or older than
the existing 30-minute timeout.
- Deduplicate workspace IDs in memory before enqueueing.
- Enqueue the existing recovery job only for matching workspaces.
- Share the stage definitions between candidate selection and recovery
so they cannot drift independently.

## Before and after

Before:

1. Load every active workspace.
2. Enqueue a calendar job and a messaging job for each workspace.
3. Enter every workspace context.
4. Query its channels.
5. Usually find nothing to recover.

After:

1. Run one candidate lookup for calendar channels and one for message
channels.
2. Return only the workspace ID for each potentially stale channel.
3. Deduplicate those IDs and enqueue one job per affected workspace.
4. Let the existing recovery jobs recheck and recover those channels.

Database reads now scale with stale channel candidates, while queue and
workspace-context work scale with affected workspaces rather than the
total workspace count.

## Why deduplicate in memory

TypeORM repository find options do not provide `DISTINCT`, so these
lookups can return the same workspace ID once per stale channel. A `Set`
removes those duplicates before jobs are created.

This is a deliberate tradeoff:

- The database returns only UUIDs, not full channel records.
- The scans run hourly against channel tables, and stale candidates
should remain sparse.
- It keeps the query expressed through the typed repository API.
- It avoids adding a database sort or hash aggregation for `DISTINCT`.

If candidate volume becomes large enough for result transfer or
in-process deduplication to matter, database-side deduplication can be
moved into a dedicated repository query. A practical signal would be
tens of thousands of candidates per run, a high duplicate ratio, or
measurable lookup and event-loop latency.

## Safety

- The recovery jobs and sync-state transitions are unchanged.
- The candidate lookup uses the same stage lists and timeout constants
as the recovery jobs.
- Recovery jobs still recheck staleness after dequeueing, which protects
against a channel recovering between candidate selection and execution.
- Jobs are still enqueued sequentially with per-workspace exception
handling.
- Disabled and group channels are not newly excluded, preserving the
previous recovery behavior.

## Scope

This only changes hourly stale-sync detection. It does not change normal
calendar or messaging scheduling, imports, retry policies, or recovery
state transitions.

## Testing

Added unit coverage for:

- active and non-deleted workspace filtering,
- selecting only workspace IDs,
- scheduled and ongoing stage filtering,
- null or expired `syncStageStartedAt`,
- the configured stale timeout,
- application-side workspace deduplication,
- enqueueing one recovery job per affected workspace.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23321?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-27 08:56:28 +00:00
Abdullah. 97bb56d471 fix: bump tar and brace-expansion in seed-dependencies (Dependabot) (#23333)
## Summary

Bumps **tar 7.5.20 -> 7.5.21** and **brace-expansion 5.0.7 -> 5.0.8** in
the `application-package/constants/seed-dependencies` fixture:

| Severity | Advisory | Package | Alert |
|---|---|---|---|
| medium | GHSA-r292-9mhp-454m | tar (`<= 7.5.20`) |
[1850](https://github.com/twentyhq/twenty/security/dependabot/1850) |
| high | GHSA-mh99-v99m-4gvg | brace-expansion |
[1856](https://github.com/twentyhq/twenty/security/dependabot/1856) |

Both are reached through caret ranges (`^7.5.4`, `^5.0.2`), so the
lockfile diff comes from a plain recursive `yarn up` - no resolution, no
`package.json` change.

## Checksum coupling

This fixture is read at runtime by `getDefaultApplicationPackageFields`
and pinned by stored constants (first 32 hex chars of SHA512).
**`DEFAULT_YARN_LOCK_CHECKSUM`** is regenerated to match the new
lockfile. `package.json` is byte-untouched so
`DEFAULT_PACKAGE_JSON_CHECKSUM` stays as-is.

Verified in order: the hash formula reproduces **both** current
constants before regenerating; the new constant matches the new content;
`yarn install --immutable` passes in the fixture.

## sharp deliberately excluded

The third open alert here (GHSA-f88m-g3jw-g9cj, high - inherited libvips
CVEs) is **not** a lockfile lift: sharp is a *direct* dependency of this
fixture at `^0.34.5`, so clearing it means moving the declared range to
`^0.35.0`. That changes the package set exposed to user logic functions,
shifts `DEFAULT_PACKAGE_JSON_CHECKSUM` too, and sharp 0.35 raises its
engines floor from Node 18 to `>=20.9.0` while Lambda layers are still
advertised as NODE18-compatible. The same `^0.34.5` ceiling gates
twenty-sdk and 15 app manifests, so it deserves one coordinated decision
rather than a drive-by change here.
2026-07-27 07:55:17 +00:00
martmull 4f9fd6f674 feat(applications): restore the application custom settings tab (#23256)
## Summary

Restores the application **custom settings tab** feature that was
removed in #22156. This reverts that removal so applications can again
expose a custom settings tab via a front component.

## Changes

- Restore the `SettingsApplicationCustomTab` component and its tab
entry/rendering in `SettingsApplicationDetails`.
- `ApplicationManifestMigrationService` syncs
`settingsCustomTabFrontComponent` from application manifests again
(`syncDefaultRoleAndSettingsCustomTab`), resolving the front component
from `settingsCustomTabFrontComponentUniversalIdentifier`.
- Remove the deprecation annotations added by #22156:
- `ApplicationDTO.settingsCustomTabFrontComponentId` (drop GraphQL
`@deprecated`)
-
`ApplicationManifest.settingsCustomTabFrontComponentUniversalIdentifier`
- the `settingsCustomTabFrontComponentId` column comment on
`ApplicationEntity`
- Regenerate the corresponding GraphQL schema/types to drop the
`@deprecated` reason.

The DB column was never dropped, so no schema migration is required.


---
_Generated by [Claude
Code](https://claude.ai/code/session_01A6aoLa5kZjba9C3uwo6nay)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23256?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-07-27 06:52:04 +00:00
neo773 3fb29db28a Feat/email settings v2 (#23180)
Settings pages changes

- Add `displayName`
- Unsubscribers Page

<img width="1496" height="844" alt="Screenshot 2026-07-22 at 8 52 15 PM"
src="https://github.com/user-attachments/assets/69bc1993-4547-4a64-83a6-b47fef1a4e40"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23180?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-07-26 22:48:30 +02:00
github-actions[bot] 763d31a859 chore: sync AI model catalog from models.dev (#23298)
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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23298?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-25 08:43:55 +02:00
Abdullah. c102a22375 fix: lift axios/tar/brace-expansion/body-parser in server fixture lockfiles (Dependabot) (#23291)
## Summary

Follow-up to #23267: lifts **axios, tar, brace-expansion, body-parser**
in the two **twenty-server fixture projects** that were deliberately
excluded from the apps sweep because of checksum coupling:

- `application-package/constants/seed-dependencies`: axios 1.16.1 ->
1.18.1, body-parser 1.20.5 -> 1.20.6, brace-expansion 2.1.1 -> 2.1.2 and
5.0.6 -> 5.0.7, tar 7.5.16 -> 7.5.20
- `logic-function/.../common-layer-dependencies`: brace-expansion 2.1.1
-> 2.1.2

All moves fit the declared ranges (recursive `yarn up`), so both diffs
are lockfile-only.

## Checksum coupling

`seed-dependencies` is read at runtime by
`getDefaultApplicationPackageFields` and its content is pinned by stored
constants (first 32 hex chars of SHA512; package.json hashes the
re-serialized JSON). The lockfile change therefore regenerates
**`DEFAULT_YARN_LOCK_CHECKSUM`** in
`get-default-application-package-fields.util.ts`. `package.json` is
byte-untouched, so `DEFAULT_PACKAGE_JSON_CHECKSUM` stays.

Verified in order: the hash formula reproduces both *current* constants
before regenerating; the new constant matches the new lockfile content;
`yarn install --immutable` passes in both fixture projects.
`common-layer-dependencies` has no checksum coupling (copied + installed
at Lambda layer build time).

## Deliberately not covered

**sharp** stays at 0.34.5 in seed-dependencies: every path is
minor-locked at `^0.34.5` (including twenty-sdk latest); pending the
twenty-sdk range decision.

## Alerts

Clears the axios (10), tar (4), brace-expansion (3) and body-parser (1)
Dependabot alerts on these two manifests.
2026-07-24 18:11:29 +00:00
Etienne 4851489ebc fix(ai-chat) fix AI chat tool-output spill leaks: spill learn_tools, cap navigation tools, truncate on spill failure (#23286)
## Context

AI chat spills tool outputs larger than `MAX_INLINE_TOOL_OUTPUT_BYTES`
(16 kB) to a file and lets
the model page them back with `search_output` / `extract_json_paths`.
Three paths bypass this and
let unbounded payloads into conversation history:

1. **`learn_tools` is never spilled.** Tool schemas go inline whatever
their size.
2. **Navigation tools have no inline cap.** They are exempt from
spilling by design (they page
   spilled files), but nothing bounds their own output.
3. **Spill failure falls back to full inline.** On any spill error the
service returns the complete
   payload with only a warning appended.

## What changed

- **`learn_tools` now spills.** `createLearnToolsTool` takes `{
excludeTools?, spillLargeOutput? }`
(same shape as `createExecuteToolTool`); chat execution enables it. Only
the bulky `tools`
schemas are spilled; `message` / `notFound` / `suggestions` stay inline
and the response carries
a `spilledTools` envelope (fileId, preview, hint) pageable via
`extract_json_paths`. MCP is
  unchanged.
- **Navigation tools get a hard inline cap.** Still never spilled, but
output above 16 kB is
head+tail truncated with a marker telling the model to narrow the query
or page with `offset`.
- **Spill failure truncates instead of inlining.** The fallback returns
head+tail within the 16 kB
  budget with the original byte size in the marker, keeping the warning.
- New `truncateHeadTail` util: byte-budgeted, marker-aware, UTF-8
codepoint-safe.

## Test plan

- `tool-output-spill.service.spec.ts`: spill envelope unchanged,
under-budget passthrough,
navigation cap for both tools (budget respected, marker mentions
`offset`, no file written),
  truncated fallback on spill failure with warnings preserved.
- `learn-tools.tool.spec.ts`: no spill without the option, inline under
budget,
`message`/`notFound`/`suggestions` intact when spilled, spill-failure
warnings surfaced.
- `truncate-head-tail.util.spec.ts`: budget, head+tail+marker,
multibyte-safe cuts.
- 63 tests across 6 suites; `lint:diff-with-main` and `typecheck` green.

## Post-deploy

Watch the `AiChatToolOutputTokens` histogram (p95 should collapse to ~4k
tokens) and the
`AiChatInputTokens` / `AiChatCacheReadTokens` ratio on GPT-5-class
models.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23286?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 16:57:35 +00:00
Etienne 3a1067ec6d fix(server): index page-layout FKs to fix workspace cleanup timeout (#23289)
The cleanSuspendedWorkspacesJob cron timed out every run (Sentry monitor
"a timeout check-in was detected"): hard-deleting soft-deleted
workspaces hung on `DELETE FROM core.pageLayout`, hit the 10s query
timeout, rolled back, so those workspaces were never destroyed and got
retried hourly.

Root cause: the FKs in the pageLayout -> pageLayoutTab ->
pageLayoutWidget tree had no usable index on the referencing column. The
existing indexes lead with workspaceId and are partial ("deletedAt" IS
NULL), so ON DELETE CASCADE / SET NULL fell back to full sequential
scans of the shared core tables per deleted row; on layout-heavy
workspaces this exceeded 10s.

- Add non-partial FK-column indexes on pageLayoutTab(pageLayoutId) and
pageLayoutWidget(pageLayoutTabId)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23289?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 16:50:57 +00:00
martmull 45f92b6763 feat(billing): make logic function executions free for exempt apps (#23255)
## Problem

Workspaces get 5 free credits/month to run logic functions, AI and
workflows. When a user imports their mailbox with the
onboarding-suggested **Call Recorder** and **Last contact** apps, each
imported message/calendar event fires those apps'
database-event-triggered logic functions, and each execution bills a
flat 100 micro-credits. A single import can fire tens of thousands of
executions and drain the entire monthly allowance before the user has
done anything else.

The trigger pipeline has no notion of "this came from sync", and logic
function executions are metered per record (one job per imported
record), so the burn is unavoidable today.

## Approach

Keep a static list of billing-exempt app identifiers
(`MARKETPLACE_BILLING_EXEMPT_UNIVERSAL_IDENTIFIERS` — Call Recorder and
Last contact) and check it in the logic-function executor's billing step
via a small `isBillingExemptApplication(universalIdentifier)` utility.
When the running app is exempt, the per-invocation meter records
`creditsUsedMicro: 0` and skips the credit decrement.

Scope is deliberately narrow: only the automatic per-invocation meter is
exempted. Anything the function itself charges via `chargeCredits` (the
separate `/app/billing/charge` endpoint) and any AI token usage keep
billing and keep their enforcement, so a free app can still charge for
real paid work (e.g. Call Recorder's per-recording charge, People Data
Labs enrichment) and AI usage still throws on credit exhaustion.

There is no DB column, migration, cache, admin UI, or per-registration
state — the exemption is derived entirely from the app's
`universalIdentifier` against the in-memory list, so it applies
uniformly to fresh and existing installations.

## Changes

- `isBillingExemptApplication` utility over the exempt-apps constant,
with a unit test.
- Logic-function executor consults the utility to decide
`creditsUsedMicro` (0 for exempt apps, 100 otherwise) and only
decrements credits for non-exempt invocations.

## Notes / follow-ups

- This fixes the billing drain but not the execution burst: an import
still fires the real isolate executions for zero user-visible benefit
over the apps' existing batch backfill. Suppressing database-event
triggers during historical import is a complementary follow-up worth
doing for infra cost and rate-limit reasons.

## Test plan

- [x] `nx typecheck twenty-server` / `nx typecheck twenty-front`
- [x] Server unit tests (`isBillingExemptApplication`) pass
- [ ] Manual: install Call Recorder / Last contact, import a mailbox,
confirm credits are not consumed by their logic function executions
while AI usage and in-app charges still bill
2026-07-24 18:23:17 +02:00
Thomas Trompette d6c186a71e Fix system objects bypassing role object permission overrides (#23280)
## Bug

Fixes #23062 (security). A workspace member whose role denies all object
access could still read/mutate system-object records (messages, calendar
events, and related system objects). System objects bypassed explicit
role-level object permissions.

## Root cause

In `workspace-roles-permissions-cache.service.ts`, the per-object
permission helper resolved values as:

```ts
(isSystem ? true : (overrideValue ?? defaultValue))
```

For every non-workflow, non-workspace-member system object this forced
`read`/`update`/`softDelete`/`destroy` to `true`, so an explicit deny
override on the role was never consulted.

## Fix

Flip the precedence so an explicit role override wins, and the
`isSystem` default only applies when the role provides no override:

```ts
overrideValue ?? (isSystem ? true : defaultValue)
```

Because the override fields are `boolean | undefined`, `??` correctly
honors an explicit `false` while still falling back to the system
default (`true`) when the role has no override row for that object.

Workflow objects (settings-gated via the `WORKFLOWS` flag) and
workspace-member objects (settings-gated, always readable) are handled
in separate branches and are unchanged, so their intended defaults do
not regress.

## Testing

- `nx lint:diff-with-main twenty-server` passes.
- Typecheck: no new errors from this change (pre-existing unrelated
failures in `twenty-shared` date-filter utils only).
- Manually verified on a local instance that a deny-all role no longer
has read access to system objects.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23280?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 15:49:52 +00:00
Paul Rastoin bc7922da7d fix: move add-agent-foreign-key-to-role-target instance command to 2.25 (#23285)
## What

Moves the `add-agent-foreign-key-to-role-target` fast instance command
from `2.24.0` to `2.25.0`.

Introduced in #23206, the command was registered under version `2.24.0`.
Since `TWENTY_CURRENT_VERSION` is now `2.25.0`, `2.24.0` is an
already-released version, so its instance commands do not re-run on
upgrade and the foreign-key migration would never execute.

This is the same issue #23271 fixed for the message-list-members
backfill workspace command.

## Changes

- Moved the command file from `upgrade-version-command/2-24/` to `2-25/`
(renamed the file prefix).
- Updated the decorator from `@RegisteredInstanceCommand('2.24.0', ...)`
to `('2.25.0', ...)`.
- Updated the import in `instance-commands.constant.ts` to the new
relative path and reordered both the import and the array entry to sit
after the 2-24 commands.

The timestamp (`1784820332810`) and command logic (`up`/`down`) are
unchanged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01KpCb9BK1eoM1WWXJU6g95e)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23285?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 15:32:33 +00:00
Abdul Rahman 7067f6ef88 fix: honor agent rolePermissionConfig in record CRUD (#23248)
## Summary
- Agent tools were built with the agent’s `rolePermissionConfig`, but
record CRUD ignored it and re-resolved permissions from `authContext`
(app `defaultRoleId`)
- CRUD services now pass `rolePermissionConfig` through
`CommonApiContextBuilder` and the common query runner, so repository
access matches the agent role
- Workflow/chat paths already use the same role for auth and
`rolePermissionConfig`, so their behavior should be unchanged

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23248?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 15:21:27 +00:00
Félix Malfait 6cc7ed7570 Make solo tabs first-class: derived presentation, native editing, unified widget header (#23109)
## Why

Full-page record tabs (Timeline, Tasks, Notes, Files, Emails, Calendar,
Flow) were encoded by storing a `CANVAS` layout mode. That made them a
separate species: editing one didn't feel native (no drag handles, no
way to add a second widget, the tab couldn't adapt), and the widget
pipeline was full of `layoutMode === CANVAS` branches.

This PR replaces the stored mode with two derived rules and one unified
header grammar:

> **Presentation is derived from content, never stored.**
> A list tab with exactly **one widget** renders it **solo**
(full-bleed, it owns the tab). Anything else is a **stack** of boxed
cards. **Edit mode always shows the stack structure.**

No widget taxonomy, no per-type branches: any lone widget owns its tab.

## What

**Presentation model**
- `getTabPresentation({ widgets, layoutMode, isInEditMode })`: solo iff
a list tab has exactly one widget in view mode; grid tabs (dashboards)
and edit mode are always stacks. The pinned left panel is always a
column (a surface rule, not a widget rule).
- Solo view rendering is identical to the old CANVAS rendering
(container height, internal scroll).
- Stacked widgets in the main tab area get one bounded slot rule
(`max-height` + own scroll) so no widget swallows the tab;
pinned/side-column stacks keep their flowing behavior. This only binds
on user-composed mixed tabs, which could not exist before.

**Native editing (the point of the PR)**
- Every record-page tab is edited through the same vertical-list editor:
drag handle, reorder, remove, add widget. Add a second widget to a
Timeline tab and it becomes a stack; remove back down to one and it's
solo again. Nothing is stored, nothing to migrate.
- Fixes the stuck-drag bug found while testing the preview: widgets
publishing header info republished a fresh object on every render
(activity cards build their action from non-memoized hook returns), and
since the widget chrome reads that state above the widget content, any
tab with an activity card sat in an infinite render loop. The loop
starved React's transition lane, which dnd-kit's drop teardown waits on,
so the drag clone and drop outlines froze on screen after a drop. The
header hook now republishes only on real value changes and routes
onClick through a stable wrapper, so callers need no memoization. The
page-layout drag provider also disables the Feedback drop animation so
clone cleanup is synchronous at drop time.

**Unified widget header API**
- A widget's content can publish header info to its chrome via
`usePublishWidgetHeaderInfo({ count, primaryAction })`: a count rendered
in grey next to the title, and a primary action (icon button with
accessible name) on the right in view mode. Instance-scoped state keyed
by widget id, so third-party widgets (front components) can use the same
seam later; the hook no-ops outside a page layout (stories, previews)
and is safe to call with inline, non-memoized values.
- A solo widget's header only appears when the widget published
something: the tab label already names it, so a bare title row adds
nothing. Timeline/Flow tabs stay exactly as today.
- Emails, Tasks, Notes, Files, Calendar publish their count (query
totals, not loaded-page lengths) and action (Compose, New task, New
note, Add file) and stop rendering internal title rows ("Inbox 12", "All
5"): exactly one header per widget everywhere, same grammar.
`ComposeEmailButton`, `AddTaskButton` and the title/button plumbing in
`NoteList`/`AttachmentList`/`TaskList` are deleted.

**Object-aware tabs**
- The hardcoded `SYSTEM_OBJECT_TABS` title allowlist is gone. A tab
renders based on whether the target object supports its widgets: widgets
that read through a relation (Tasks, Notes, Files, Timeline) require the
relation field to exist and be active, while Emails and Calendar
aggregate through the messaging timeline, so a missing participants
relation is fine (Company) and a deactivated one is an explicit opt-out.
System objects on the shared default layout keep exactly Home +
Timeline, now by derivation instead of hardcoded titles.

**Data cleanup**
- Seeds (frontend defaults, server standard template, `twenty app`
scaffolder, docs) write `VERTICAL_LIST`;
`PageLayoutTabLayoutMode.CANVAS` is `@deprecated`, kept read-only for
layouts persisted before this change (they render correctly through the
derivation; no data migration, by design: an in-place flip can't pass
the widget-position/tab-layoutMode validator atomically, and it isn't
needed).
- Locale catalogs are intentionally untouched: the i18n pipeline
extracts and translates the new header labels on main; they fall back to
their English source until then.

## Deliberate view-mode changes (approved)

- A lone widget of any type now owns its tab full-bleed: lone Fields tab
(mobile/side panel), lone rich-text Note tab, lone chart, and the
message-thread page lose their card box.
- Activity tabs show the unified header (title, grey count, + action)
instead of their internal "Inbox 12"-style rows.

Everything else is pixel-parity, including solo scroll behavior and
dashboards.

## Test plan
- `nx typecheck twenty-front` / `twenty-server`: clean; oxlint/oxfmt on
the changeset: clean
- 239 suites / 1474 tests across page-layout, activities, side-panel
pass, including new tests for `getTabPresentation` (count-based,
edit-mode override) and `usePublishWidgetHeaderInfo` (publish, cleanup
on unmount, no-op outside a widget, referential stability across
re-renders with inline actions, latest-onClick wrapper)
- `getTabsRenderableForTargetObject` tests covering missing vs
deactivated relations, Emails/Calendar without a participants relation,
and non-relation widgets
- Stuck-drag repro verified fixed end to end against a local stack with
an instrumented dnd-kit: before the fix the affected tab committed ~65
renders/second at idle and drops never tore down; after it, idle commits
are flat and every drop cleans up
2026-07-24 15:02:02 +00:00
Etienne 0bba75dd18 fix(ai-chat): stop duplicate tool_use ids from bricking threads (#23277)
## Issue

Some AI chat threads become permanently broken. Every turn fails with an
Anthropic 400:

messages.5.content.1: tool_use ids must be unique



The error is on the message *history*, so once a thread is in this state
every subsequent turn fails too, not just the one that triggered it. It
surfaces most visibly when aborting a thread and continuing it, but the
abort is incidental: it just replays the already-corrupted history.

## Root cause

A single tool call gets persisted as **two message parts sharing one
`toolCallId`**. Confirmed in the DB for the affected thread, one
assistant message held:

- `tool-extract_json_paths` and `dynamic-tool`, both
`toolu_01FXxxfBYP27NZEvA6AZ3AJc`
- `tool-search_output` and `dynamic-tool`, both
`toolu_01VqnFrYdGCERAc2dbG3zAyx`

On the next turn `convertToModelMessages` turns each pair into two
`tool_use` blocks with the same id, which Anthropic rejects.

## Why it happens

It is not two concurrent calls. It is one call the AI SDK classifies
inconsistently across its own stream chunks.

The chat only exposes a small set of directly-callable tools
(`execute_tool`, `learn_tools`, `load_skills`, `ask_questions`, plus
native/preloaded ones). Registry tools like `extract_json_paths` and
`search_output` are reachable only through `execute_tool`. When the
model shortcuts that and calls one directly, the name is not in the
active `ToolSet`, and the SDK does this:

1. On `tool-input-start`, `dynamic` is derived from the tool set:
`tools[name]?.type === "dynamic"`. The tool is absent, so `dynamic:
false`, and a **static** `tool-<name>` part is created.
2. On finalization the unknown tool throws `NoSuchToolError`.
`repairToolCall` intentionally skips name errors (`return null`), so the
SDK re-emits the call with a hardcoded `dynamic: true`. That error
routes to the **dynamic** path and creates a second `dynamic-tool` part
with the same id.

The UI-message builder keeps static and dynamic tool parts in separate
buckets, each searched by `toolCallId` independently, so the mid-call
static-to-dynamic flip produces two parts for one call. Both persist and
break the next turn.

## Fix

Two independent read/convert-path passes, both in
`sanitizeMessagePartsForModel` in `chat-execution.service.ts`, running
before `convertToModelMessages`:

1. **`finalizeDanglingToolParts`** now dedupes tool parts by
`toolCallId` (first-wins), keeping the `input-streaming` filter ahead of
the dedup so a leading streaming duplicate can't strand the call.
Because it runs before conversion and on the write paths too,
already-corrupted threads are un-bricked on their next turn with no
migration.
2. **`guideUncallableToolCallsToMetaTool`** addresses the behavior that
caused it: when the model calls a tool that is not directly callable, it
appends the `learn_tools` -> `execute_tool` flow to that failed tool
result, so the model reads how to reach the tool. Detection is
structural (a failed tool part whose name is not in the active tool
set), not string-matched against the SDK's error wording.

Unit tests added for both. Typecheck, lint, and the suite pass.

Note: the stale duplicate rows already in the DB are harmless (deduped
on every read); no migration is required.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23277?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 14:54:13 +00:00
github-actions[bot] 9aaa5b7778 i18n - translations (#23284)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-24 16:46:27 +02:00
Thomas Trompette cbcfba0de2 feat(workflow): dedicated updateWorkflowVersionTrigger mutation + close version CRUD holes (#23207)
Prerequisite for the workflow-core soft-ref migration: every
`workflowVersion` content write must go through a dedicated,
draft-guarded server mutation so it can later be wrapped in a
transactional core mirror. This closes the generic-CRUD holes that let
writes bypass that path.

## Part A - dedicated `updateWorkflowVersionTrigger` mutation
The builder saved a version's trigger through generic
`updateOneWorkflowVersion` - the only content write not going through a
dedicated mutation. Added:
- Server: `updateWorkflowVersionTrigger(input: { workflowVersionId,
trigger })` resolver +
`WorkflowVersionStepWorkspaceService.updateWorkflowVersionTrigger`,
draft-guarded via `getValidatedDraftWorkflowVersion` then
`updateWorkflowVersionStepsAndTrigger` (reuses existing write logic).
- Front: `useUpdateWorkflowVersionTrigger` now calls the dedicated
mutation instead of `useUpdateOneRecord`.

## Part B - restrict generic `updateOneWorkflowVersion`
`validateWorkflowVersionForUpdateOne` previously allowed writing
`trigger`, `position`, `workflowId` (re-parenting),
`coreWorkflowVersionId`, and let `steps: null` slip through on a draft.
It now rejects any update that sets `steps`, `trigger`, `status`,
`workflowId`, or `coreWorkflowVersionId`, or that clears the `name`,
while still allowing a plain rename. (A name-only allowlist was tried
first but blocked legitimate renames - at the pre-hook the generic
update payload is not single-key - so it was replaced by this denylist,
verified live.)

## Part C - close the destroy/restore hole
`workflowVersion` had no `destroyOne/destroyMany/restoreOne/restoreMany`
query hooks, so a caller with object permission could hard-destroy any
version (including active) or resurrect one with no validation. Added
pre-hooks that forbid all four via the API ("Method not allowed"),
matching the existing forbidden generic mutations (`createOne`,
`deleteMany`, ...). Rationale: there is no legitimate API use for
standalone version destroy/restore - retention purging happens through
the trash-cleanup cron (internal, not hook-gated) and restore happens
through the workflow-restore cascade or create-draft-from-version.

## Tests
Integration specs that set a trigger through the generic mutation were
migrated to the new `updateWorkflowVersionTrigger` mutation (new
`update-workflow-version-trigger.util.ts`). Unit test for the front hook
updated.

## Verification
- `twenty-server` + `twenty-front` typecheck: clean.
- oxlint + oxfmt on all changed files: clean.
- `graphql.ts` regenerated for the new mutation; its types match the
server DTOs exactly. Local `graphql:generate` introspects a running
server, so it only succeeds against a server built from this branch - CI
regenerates against the PR server and verifies.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23207?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-07-24 14:32:09 +00:00
martmull 25b0b2601f Replace admin app rollout buttons with upgrade-application CLI command (#23212)
## What

Removes the two rollout buttons from the admin application detail page
and replaces the upgrade flow with a CLI command that can be run
directly from a server or worker pod. Also restructures application stop
into its own module with a kill switch CLI command, and surfaces stopped
apps in workspace settings.

### Removed

- "Install on all workspaces" button (General tab,
`SettingsAdminApplicationRegistrationGeneralToggles`), its confirmation
modal and tooltip
- "Upgrade existing installations" button
(`SettingsApplicationRegistrationGeneralStats`), its confirmation modal
and batch size input
- `backfillApplicationInstallation` and
`upgradeRegistrationApplications` admin GraphQL mutations and their
frontend documents / generated types
- `BackfillApplicationInstallationJob` (its only trigger was the removed
mutation); `UpgradeApplicationsJob` is kept since the auto-upgrade flow
still enqueues it

Per review, the "install on all workspaces" flow is dropped without a
CLI replacement for now; a dedicated command will be added when needed.

### application:upgrade command

Located in `application-upgrade/commands`, registered in
`ApplicationUpgradeModule`:

```
yarn command:prod application:upgrade \
  --application-registration-universal-identifier <universalIdentifier> \
  [--batch-size 5] \
  [--workspace-id <id> --workspace-id <id2>] \
  [--workspace-count-limit 10] \
  [--dry-run] [--yes]
```

- `--workspace-id` (repeatable) restricts the upgrade to specific
workspaces; `--workspace-count-limit` caps how many installations are
upgraded (max 50, for canary rollouts)
- `--batch-size` and `--workspace-count-limit` are validated as positive
integers, max 50
- `--dry-run` reports how many (and which) workspaces would be upgraded,
without upgrading
- Without `--dry-run`, a confirmation prompt shows the app, target
version and impacted workspaces; the run then executes exactly the
confirmed set; `--yes` skips the prompt for non-interactive usage

The upgrade plan is computed by a new
`ApplicationUpgradeService.findApplicationsToUpgrade`, and batches run
through a new `upgradeApplications` method — both reused by
`upgradeAllApplications`, so the auto-upgrade job path is unchanged.

### Application kill switch (per review)

Global mechanism only — a per-workspace stop had no demonstrated
operational need and added a Redis key format, execution branching, CLI
options and tests; an isolated workspace issue can be handled directly
in the DB or Redis with the same effort.

- `ApplicationStopService` moved to a dedicated `application-stop/`
folder with its own `ApplicationStopModule` (imported and re-exported by
`ApplicationModule`)
- `stop` / `remove` methods that enable or clear the Redis-backed global
kill switch; the logic function executor checks it before executing
- `application:kill-switch` command with a positional action,
confirmation prompt (shows the installation count) and `--yes` bypass:

```
# Enable the kill switch (stop is the default action)
yarn command:prod application:kill-switch stop -u <universalIdentifier> [-y]
yarn command:prod application:kill-switch -u <universalIdentifier>

# Remove the kill switch
yarn command:prod application:kill-switch remove -u <universalIdentifier> [-y]
```

### Stopped apps surfaced in workspace settings (per review)

- Dedicated `isApplicationStopped(applicationUniversalIdentifier)` query
backed by the kill switch, fetched with `network-only` policy solely by
the application detail page — listing applications triggers no extra
Redis reads
- Application detail page shows a danger banner when the app is stopped:
"We are currently encountering issues with this app, its behavior may be
degraded while we work on a fix."

## Test

- `npx nx typecheck twenty-server` / `npx nx typecheck twenty-front`
pass
- `npx nx lint:diff-with-main` passes for both packages
- `application-stop.service.spec.ts` covers stop, remove, caching and
fail-open behavior
- Verified end to end locally: ran the kill switch command on a seeded
workspace and confirmed the banner renders on the app detail page
(screenshot shared separately)
2026-07-24 14:31:25 +00:00
github-actions[bot] 86d0e15a6a i18n - translations (#23281)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-24 16:20:55 +02:00
Raphaël Bosi d1c6b8ee72 Show relation record labels instead of UUIDs in dashboard charts (#23163)
https://github.com/user-attachments/assets/d012a013-2c90-49a1-a27e-b8e4b684a84f



Charts grouped by a relation without a sub-field rendered raw FK UUIDs
on axis ticks, legends and tooltips. The server now batch-resolves the
grouped record ids to their label identifier through a permission-scoped
query and formats every bucket with the record's display name.
Unresolvable records (deleted or not readable) render as Unknown and
their ids are stripped from the response payload. Same-named records get
an ordinal suffix so their buckets don't merge. Covers bar, line and
pie, plain and morph relations.

```mermaid
flowchart TD
    A["Dashboard widget load"] --> B["Chart data service<br/>(bar / line / pie)"]
    B --> C["executeGroupByQuery:<br/>group by relation FK id,<br/>ORDER BY target label identifier,<br/>scoped to source object permissions"]
    C --> D["filterOutEmptyChartBuckets"]
    D --> E{"Bare relation axis?<br/>(no sub-field)"}

    subgraph RL["ChartRelationLabelService.resolveRelationLabels"]
        direction TB
        G1["Collect distinct record ids<br/>per target object"] --> G2["Batch SELECT label identifier columns,<br/>scoped to TARGET object permissions"]
        G2 --> G3["buildRawLabelByRecordId:<br/>display name per record"]
        G3 --> G4["buildUniqueRelationLabels:<br/>suffix duplicates, Unknown for unresolved"]
    end

    E -- No --> H["formatDimensionValue per bucket"]
    E -- Yes --> G1
    G4 --> H
    H --> I["Strip unresolved ids from<br/>formattedToRawLookup"]
    I --> J["Chart DTO to frontend"]
```

The chart settings sub-field dropdown gains a Record option to group by
the related record itself, and now only offers sub-fields the backend
accepts (system fields like a workspace member's updatedBy were
selectable but rejected at query time). Chart-data errors are now logged
server-side.

Also fixes two latent bugs on this path: sorting a bare-relation chart
by field threw `Cannot orderBy unknown field: agentId`, and the pie
chart truncated slices before sorting. The AI dashboard tool guidance
and the seeded dashboards no longer force the sub-field workaround.

The group-by query orders buckets by the related record's label
identifier at the database level (the engine now accepts ordering by a
target field when grouping by its id), so with more than 100 distinct
related records the surviving buckets match the label order.
2026-07-24 14:12:22 +00:00
Weiko b036d67ec9 Configure async ClickHouse inserts for pageview events (#23274)
## Context

Pageview tracking goes through the `trackAnalytics` mutation on the
metadata API and is persisted through the unified event pipeline before
the mutation resolves.

ClickHouse inserts already use:

```text
async_insert = 1
wait_for_async_insert = 1
```

`async_insert` lets ClickHouse buffer and batch small inserts, but
`wait_for_async_insert = 1` still keeps the API request open until that
buffer is flushed successfully. For sparse pageview inserts, the buffer
timeout can therefore account for most of the request duration and
contribute to metadata API tail latency.

## What this changes

- Adds a named `ClickHouseService.insert` option for overriding
`async_insert_busy_timeout_max_ms`.
- Caps the pageview buffer wait at 100 ms.
- Keeps `wait_for_async_insert = 1`.
- Leaves workspace, object, usage, application-log, and other event
inserts on the existing default timeout.

## Why this approach

This removes the avoidable buffer wait from the pageview request path
without changing the delivery guarantees of the event pipeline.

In particular, this does **not** use `wait_for_async_insert = 0` or
fire-and-forget writes. The API still receives an acknowledgement only
after ClickHouse flushes the pageview successfully, and insert/schema
errors still propagate through the existing handling.

The 100 ms value caps only the batching wait. It does not impose a 100
ms deadline on the complete ClickHouse request.

## Expected impact

- Lower ClickHouse span duration for pageview tracking.
- Lower tail latency for metadata API requests that emit pageviews.
- No behavior or durability change for other event types.

The trade-off is that pageviews may be flushed in smaller batches. The
setting remains scoped to the pageview table so higher-value event
streams keep their current batching behavior.

## Testing

- Added coverage for the optional ClickHouse busy-timeout setting.
- Added coverage verifying that only pageview inserts receive the 100 ms
override.
- Existing insert failure/retry behavior remains covered.
- `twenty-server` typecheck passes.
- Focused test result: 23 tests passed.

## Post-deploy verification

- Compare pageview ClickHouse span p95/p99 before and after deployment.
- Compare metadata API p95/p99.
- Check ClickHouse asynchronous-insert failures.
- Watch ClickHouse part creation and merge pressure for unexpected
growth.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23274?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 14:01:23 +00:00
Paul Rastoin dee653dfa6 fix: move message list member backfill command to 2.25 (#23271)
## What

Moves the `backfill-message-list-members-junction-target` workspace
upgrade command from `2.24.0` to `2.25.0`.

Introduced in #23176 (commit 59eead23), the command was registered under
version `2.24.0`. Since `TWENTY_CURRENT_VERSION` is now `2.25.0`,
`2.24.0` is an already-released version, so its upgrade commands do not
re-run and the backfill would never execute on upgrade.

## Changes

- Moved the command and its module from `upgrade-version-command/2-24/`
to a new `2-25/` directory.
- Updated the decorator from `@RegisteredWorkspaceCommand('2.24.0',
...)` to `('2.25.0', ...)`.
- Renamed `V2_24_UpgradeVersionCommandModule` to
`V2_25_UpgradeVersionCommandModule` and updated its registration in
`workspace-command-provider.module.ts`. The `2-24` module only ever
provided this single command.

The timestamp (`1784567000000`) and command logic are unchanged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01549NK3QrcEgFsdi8UgXk1u)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23271?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 13:50:26 +00:00
Paul Rastoin ba5cb6ba15 fix(server): repair missing keyValuePair.applicationId on 2.23 upgrades (#23272)
Fixes #23254

## Problem

Upgrading a self-hosted instance from `2.23.x` to `2.24.0` leaves
`core.keyValuePair` without the `applicationId` column. Database-backed
config loading then fails on startup and on every refresh (~every 15s)
with:

```
column KeyValuePairEntity.applicationId does not exist
```

The frontend shows "Unable to reach the backend".

## Root cause

`AddApplicationIdToKeyValuePairFastInstanceCommand` was added in #23089
(after `2.23.x` shipped) but registered under the already-released
`2.23.0` segment:

```ts
@RegisteredInstanceCommand('2.23.0', 1784659343818)
```

The upgrade cursor is **positional and forward-only**:

- `resolveStartCursor` resumes at `lastAttemptedIndex + 1`. A
fully-upgraded `2.23.x` instance has its cursor at the last `2.23`
workspace command, which sits *after* this newly-inserted fast command
in the sequence. So the runner steps right over it and the DDL never
runs.
- The upgrade-aware metadata layer decides "applied" the same way
(`stepIndex < currentCursor` in
`upgrade-aware-entity-metadata.adapter.ts`). Since the step index is
below the cursor, the column is considered applied and is **not** hidden
from TypeORM SELECTs, so every query references a column that was never
created.

Fresh `2.24.0` installs replay the whole sequence, so only `2.23.x ->
2.24.0` upgrades are affected. The instance log `1 fast instance ... for
2.24.0` confirms the command landed in the `2.23.0` bundle rather than
`2.24.0`.

## Fix

- Add `RepairKeyValuePairApplicationIdFastInstanceCommand` under the
current version (`2.24.0`) with a fresh timestamp, so it sorts last in
the sequence and runs for every existing instance regardless of cursor
position. Its DDL mirrors the original command and is fully idempotent
(`ADD COLUMN IF NOT EXISTS`, `DROP INDEX IF EXISTS` + recreate, `ADD
VALUE IF NOT EXISTS`), so it is a no-op on healthy instances. `down()`
is intentionally empty: the column lifecycle is owned by the `2.23.0`
introduction command.
- Repoint the entity's `@WasIntroducedInUpgrade` to the new command so
the column stays hidden from queries until the repair has actually run,
eliminating the error window during the migration itself.

## Notes

- `2.24.0` (`TWENTY_CURRENT_VERSION`) is the correct target: the upgrade
sequence only covers previous + current versions, so a command under
`2.25.0` (a next version) would not run. If a version bump lands before
this merges, the command should be moved to the new current version.
- Follow-up worth considering: nothing currently prevents registering a
command under a version in `TWENTY_PREVIOUS_VERSIONS`. A startup
validation rejecting that would have caught this at PR time.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23272?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 13:46:59 +00:00
Weiko abe4d7491c Cap nested relation query concurrency (#23252)
## Context

Common API queries load selected relations after fetching the root
records.

Relation loading is batched: one query pipeline loads a relation for all
parent records, so this is not an N+1 problem. However, every sibling
relation currently starts concurrently through `Promise.all`.

Nested relations repeat the same behavior recursively. A wide selection
can therefore submit many independent relation query pipelines at once.

Existing query complexity and record limits restrict what can be
requested, but they do not limit how much database work starts
concurrently.

## What this changes

This PR adds a request-local FIFO concurrency limiter for nested
relation loading.

- At most four `findRelations` pipelines execute concurrently.
- One limiter is created for the outer relation-loading call.
- The same limiter is shared by every recursive level.
- Queued work starts as permits become available.
- Permits are released in `finally`, including when a query fails.

Conceptually:

```text
Before:
all sibling relations -> database concurrently
nested siblings       -> more database work concurrently

After:
all sibling relations -> FIFO queue -> at most 4 database pipelines
nested siblings       -> same FIFO queue and same limit
```

Note: Also addressing
https://github.com/twentyhq/twenty/pull/23251#discussion_r3644510597
2026-07-24 13:27:26 +00:00
Weiko e5c9fcf058 Add PostgreSQL connection pool pressure metrics (#23251)
## Summary
- Add pool gauges for total, idle, waiting, and maximum connections
- Record PostgreSQL connection acquisition duration and failures
- Instrument core, workspace primary, and optional replica data sources
- Add unit tests covering gauges, acquisition timing, failures, and
deduplication


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23251?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 13:03:12 +02:00
github-actions[bot] d0863dd1f7 i18n - translations (#23253)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-24 12:10:12 +02:00
Félix Malfait 1fdb5605f1 feat: kanban, calendar and grouped-table layouts for relation field widgets (#23112)
## Context

A relation field widget on a record page can already embed a
record-scoped view rendered as a **table** (`FieldDisplayMode.TABLE`) —
e.g. a Company's Opportunities. This brings **kanban, calendar and
grouped-table** to that same embedded view, so the board/calendar stays
scoped to *this* record's related records (not a standalone all-records
widget — that was the earlier #23003 approach, closed).

Builds directly on the merged dashboard widget layouts (#22963), reusing
its renderer, draft/save pipeline, and settings dropdowns.

## Approach — extend the existing "Table" display mode

The relation field widget already stores a `viewId` and renders it
through the layout-agnostic `RecordTableWidgetRendererContent` (which
branches on the embedded view's `type`), scoped to the current record
via `RecordFilterValueDependenciesContext`. So rendering + persistence
already work for any widget view type — only the authoring UI and one
server gate were missing. **No new `FieldDisplayMode`, no data
migration.**

## Server

- `view-widget-upsert.service.ts`: a field widget in table display mode
(`isFieldTableWidget`) could already persist viewFields/filters/sorts
through this path, but was **blocked from updating view settings**
(`type` / group-by / calendar), pinning its embedded view to a table.
The widget-type guard earlier in the method already rejects every widget
kind other than record-table and field-table, so the now-redundant
record-table-only guard on the view-settings branch is dropped. The
allowed-widget-view-types check and the downstream group-by /
calendar-field validations still apply equally.

## Frontend

- **One merged Layout picker.** The field widget's Layout dropdown lists
**Field / Card / Table / Kanban / Calendar** in a single flat list — you
pick Kanban directly, instead of "Display as: Table" first and a
separate embedded-view layout second. Picking a view layout selects the
`TABLE` display mode under the hood, seeds the record-scoped embedded
view on first use (with a default group-by / date field), and applies
the layout in the same click. Kanban/Calendar are disabled with a hint
("Needs a Select field" / "Needs a Date field") when the relation target
can't support them — same gating as the dashboard picker. The row's icon
and description reflect the effective selection (e.g. Kanban), and the
dropdown mounts the draft-init effect so switching straight from
Field/Card to Kanban works before the table renderer has ever mounted.
- **Contextual rows** (Group by / Date field / Calendar view / Hide
empty groups) extracted from the dashboard panel into a reusable
`WidgetViewLayoutSettingsRows` (source object passed in — fixed to the
relation target; no Source / Limit rows) and surfaced under the picker
while a view layout is active. Its standalone layout row is hidden here
(`isLayoutRowHidden`) since layout lives in the merged picker.
- Reuses the dashboard draft snapshot + `upsertViewWidget` save pipeline
and the group-by/calendar dropdown components unchanged.

## Scope

- **One-to-many relations only** (matches the existing
`getFieldWidgetAvailableDisplayModes` gate; junction / many-to-many stay
table-only — a pre-existing inconsistency left untouched here).
- Field-widget **calendars inherit the dashboard's behavior** (month
read-only by default; day/week + drag-to-reschedule only behind
`IS_CALENDAR_WEEK_VIEW_ENABLED`), since it's literally the same
renderer.

## Tests

- Server integration
(`upsert-view-widget-view-settings.integration-spec.ts`): a FIELD +
TABLE widget can switch its embedded view to `KANBAN_WIDGET` (with
group-by) and `CALENDAR_WIDGET` (with date field), and the kanban
group-by validation still applies through the newly-opened path.
- Front unit: `getWidgetViewLayoutSettingsItemIds` (keyboard-nav row ids
per layout/flag/group state).

## Follow-ups (intentionally not in this PR)

- Migrate the dashboard settings panel onto the shared
`WidgetViewLayoutSettingsRows` (kept out to avoid churning the
just-merged #22963 file; behavior-preserving refactor).

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-24 12:01:53 +02:00
martmull fdb8865933 test: cover uninstall logic function hook execution (#23249)
Follow-up to #23227 ([review
comment](https://github.com/twentyhq/twenty/pull/23227#pullrequestreview-4771629391)):
adds integration coverage for the `defineUninstallLogicFunction` hook
execution on uninstall.

## What

New integration suite
`successful-uninstall-application-logic-function-hook.integration-spec.ts`
that drives the real `syncApplication` / `uninstallApplication` GraphQL
flow and asserts on the executor wiring:

- When the synced manifest declares an `uninstallLogicFunction`,
uninstalling the application resolves the hook and calls
`LogicFunctionExecutorService.execute` exactly once, before deletion,
with the `{ version }` payload.
- When the manifest declares no uninstall hook, uninstalling the
application does not call the executor.

The executor is spied via the running app container
(`getAppProviderByClassName`) and stubbed to a success result, so the
test verifies the server-side resolution/trigger path deterministically
without depending on the local function runtime.

## Test plan

- `npx jest --config ./jest-integration.config.ts
successful-uninstall-application-logic-function-hook` passes (2/2).
- Typecheck green for twenty-server; oxlint and oxfmt clean on the new
file.


---
_Generated by [Claude
Code](https://claude.ai/code/session_016zJPggkVEw1V7SqnPSiUQx)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23249?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: martmull <martin@twenty.com>
2026-07-24 09:26:04 +00:00
martmull f793f3c5a9 Fix application logos resolving to null on install and sync (#23245)
## Problem

Application icons are no longer resolved: `logo` is `null` on
`FindManyApplications` / `FindOneApplication`, so app chips and the
settings applications table fall back to initials avatars.

## Root cause

Manifests produced by the current SDK carry the logo path in
`manifest.application.logo`; `logoUrl` is deprecated and stripped by
`normalizeApplicationAssets` (external URLs are dropped, relative ones
are moved to `logo`). Three server call sites still read only the
deprecated `logoUrl`:

- `application-sync.service.ts` (`syncApplication`): wrote `logo:
manifest.application.logoUrl ?? null` on every install/upgrade/dev sync,
overwriting `application.logo` with `null`
- `application-sync.service.ts` (`buildVirtualDryRunFlatApplication`):
same read on the dry-run path
- `application-install.service.ts` (`ensureApplicationExists`): same
read on the create path

Since both `Application.logo` and the `Application.logoUrl` resolve
field derive from that column, icons went null everywhere.

Separately, OAuth-only apps (e.g. "Twenty CLI" from dynamic client
registration) never get a logo at all: `OAuthRegisterInput` accepts
`logo_uri` but the registration controller dropped it, so
`applicationRegistration.logoUrl` also resolves to null for those.

## Fix

- Read `manifest.application.logo ?? manifest.application.logoUrl ??
null` at all three sites, matching the fallback already used by
`importLogoFile` and `fromManifestApplicationToDisplayFields`
- Persist `logo_uri` into `applicationRegistration.logo` on OAuth
dynamic client registration; `buildLogoUrl` passes absolute URLs
through, so the consent screen and app chips can resolve it

Existing rows that were already nulled will self-heal on the next app
upgrade/sync, since the sync path rewrites `logo` from the manifest.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Us7BE5yBguYTACg5H3Y85z)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23245?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 11:08:03 +02:00
martmull abc82d66b7 Consolidate per-queue worker tuning in one explicit config file (#23229)
## Context

Follow-up to the worker configuration analysis and to the worker pool
split rolled out in twentyhq/twenty-infra#805/#806. Worker tuning was
previously spread across two partial constants (`QUEUE_WORKER_OPTIONS`,
`MESSAGE_QUEUE_PRIORITY`), and most queues silently relied on implicit
BullMQ defaults.

## What this PR does

Introduces a single dedicated file to pilot worker behavior per queue:


`src/engine/core-modules/message-queue/message-queue-worker-config.constant.ts`

`MESSAGE_QUEUE_WORKER_CONFIG` declares, for **every** queue, an
explicit:
- `priority` (applied when enqueuing, lower runs first)
- `concurrency`
- `lockDuration`
- `maxStalledCount`
- `boundedShutdownDrain`

Explicitness is enforced at compile time: the record is typed
`Record<MessageQueue, { priority: number; workerOptions:
Required<MessageQueueWorkerOptions> }>`, so adding a queue without
declaring its full configuration is a type error, and no field can be
omitted.

Wiring changes:
- `message-queue.explorer.ts` passes
`MESSAGE_QUEUE_WORKER_CONFIG[queueName].workerOptions` when creating
workers
- `bullmq.driver.ts` reads the enqueue priority from the same record
- `message-queue-worker-options.constant.ts`,
`message-queue-priority.constant.ts` and
`ai-stream-lock-duration.constant.ts` are removed (the AI stream lock
duration is inlined into the one config entry that used it)

## Behavior

No behavior change — the previously implicit BullMQ defaults
(concurrency 1, lockDuration 30s, maxStalledCount 1) are now spelled out
per queue, and the existing overrides (`ai-stream-queue`: concurrency 20
/ 10 min lock / no stall retry / bounded shutdown drain;
`logic-function-queue`: concurrency 10) and all priorities are carried
over unchanged.

## Validation

- `npx nx typecheck twenty-server` 
- `oxlint --type-aware` + `oxfmt --check` on changed files 
- `npx jest "message-queue"` (7 tests) 

Companion infra PR: twentyhq/twenty-infra#808 moves the worker pool
topology (replicas, resources, queue filters) into a dedicated
`workers.yaml` per environment.

Session: https://claude.ai/code/session_01TL6Te48Lkys5NxyG9j2Nz6
2026-07-24 10:45:16 +02:00
martmull fb52635d2a Add defineUninstallLogicFunction hook for applications (#23227) 2026-07-24 10:41:22 +02:00