Commit Graph

14063 Commits

Author SHA1 Message Date
martmull a99e62fbca Add application job enqueue limits guard on all message queue drivers (#23570)
## Context

Applications trigger logic functions from several paths (cron, database
events, HTTP routes, install/connect hooks). Without a cap, a single app
can flood the `logic-function-queue` and starve it. This adds enqueue
limits scoped to that queue, mirroring the existing per-application API
rate limiting.

## What changed

`JobEnqueueThrottlerGuard` reuses the `ThrottlerService` token bucket
(same primitive as the API rate limiter) with two tiers:

- **Per application installation**
(`enqueue:throttler:application:{applicationId}`) - lower ceiling,
`APPLICATION_JOB_ENQUEUE_RATE_LIMITING_LIMIT` (default 500).
- **Per application registration**
(`enqueue:throttler:application-registration:{applicationRegistrationId}`)
- higher ceiling shared across all workspaces that installed the same
app, `APPLICATION_REGISTRATION_JOB_ENQUEUE_RATE_LIMITING_LIMIT` (default
2000).

Both share `APPLICATION_JOB_ENQUEUE_RATE_LIMITING_TTL_IN_MS` (default
60s). Both buckets are checked before either is debited, so a rejection
on one tier never burns quota on the other.

**Per-queue guarding.** A `ThrottledMessageQueueDriver` decorator wraps
the concrete driver (BullMQ or Sync) in the `QUEUE_DRIVER` provider and
passes the `queueName` to the guard. The guard only acts on queues in
`GUARDED_ENQUEUE_QUEUES` (currently just `logic-function-queue`); every
other queue is untouched.

**Required application context.** The guard reads a dedicated
`applicationJobEnqueueContextStorage` (AsyncLocalStorage) carrying `{
applicationId, applicationRegistrationId }`, and throws if a
guarded-queue enqueue runs without both. Every logic-function-queue
enqueue site wraps its `add`/`bulkAdd` in
`withApplicationJobEnqueueContext`:

- cron trigger
- database-event trigger (groups logic functions by application, one
batch per application)
- server route trigger
- application post-install hook
- connection-provider on-connect hook

When the limit is reached the guard records a
`JobEnqueueApplicationRateLimited` metric and throws
`ThrottlerException` (mapped to 429 by the existing handlers).

## Files

- `message-queue/guards/job-enqueue-throttler.guard.ts` - the guard
(new)
- `message-queue/storage/application-job-enqueue-context.storage.ts` -
dedicated enqueue context (new)
- `message-queue/constants/guarded-enqueue-queues.constant.ts` -
guarded-queue set (new)
- `message-queue/drivers/throttled-message-queue.driver.ts` - decorator
driver wrapping any driver (new)
- `message-queue/message-queue-core.module.ts` - wires the guard into
the driver provider
- `twenty-config/config-variables.ts` - three tunable `RATE_LIMITING`
config variables
- `metrics/types/metrics-keys.type.ts` -
`JobEnqueueApplicationRateLimited` key
- the 5 enqueue sites above - inject the enqueue context

## Notes / trade-offs

- A logic function whose application has no `applicationRegistrationId`
is skipped at the trigger paths (the install hook throws), matching how
the server route trigger already treats "not linked to a registration".
- `addCron` is left ungated (idempotent upsert).
- Default limits are placeholders and tunable per instance.

## Testing

- `JobEnqueueThrottlerGuard` unit tests (7 cases): non-guarded queue
skip, throw on missing/partial context, two-tier throttling with
distinct limits, per-item token consumption on bulk, no partial debit
when either tier is exhausted.
- Updated `connection-provider-oauth-flow.service.spec.ts` for the new
cache key.
- `npx nx typecheck twenty-server` passes; oxlint + oxfmt clean on
changed files.
2026-07-30 16:40:22 +00:00
BOHEUS cc8fac46c3 Add licensing section to legal FAQ (#23560)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23560?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@twenty.com>
2026-07-30 18:36:22 +02:00
Weiko bd65dbd47a Cache metadata lookups during ORM result formatting (#23593)
## Context

Production profiling identified `formatResult` as a recurring CPU
hotspot on read paths, especially for list queries and nested relations.

The formatter receives one metadata snapshot for the complete result,
but previously rebuilt metadata-derived lookup structures for every
record. For each record, including recursively formatted relation
records, it rebuilt or rescanned:

- field name and join-column maps
- composite field property maps
- required composite properties
- date and date-time field collections

This metadata does not change while one result is being formatted, so
the repeated work scaled with the number of records without changing the
output. It also created short-lived allocations that added GC pressure
on busy server pods.

## What changed

- Create a private cache for each top-level `formatResult` invocation.
- Lazily derive formatter metadata once per object metadata ID.
- Reuse it across records in an array and recursively formatted
relations.
- Precompute required composite property names and date-time field
metadata.
- Remove the DATE post-processing pass, which assigned each value back
to itself.
- Keep the exported `formatResult` signature unchanged.

The cache is discarded when the formatting call returns.

## Why use a call-scoped cache

The derived structures are valid for the metadata maps passed to one
`formatResult` call. Keeping the cache local provides reuse for the
complete result batch without adding cross-request state, invalidation
rules, or another long-lived memory cache.

This also preserves existing callers and keeps recursive implementation
details private.

## Safety

- Formatting behavior and returned shapes are unchanged.
- Nested relation formatting still resolves metadata for each target
object type.
- Composite null and default handling, and DATE_TIME validation, are
unchanged.
- Metadata is recomputed for every top-level invocation, so a later
request cannot reuse data derived from an older metadata snapshot.
- No Redis, workspace-cache, database, or public API behavior changes.

## Expected impact

Metadata preparation now scales with the number of object types in a
result instead of the number of records. The largest benefit is expected
for list queries and nested relations, with lower CPU usage and fewer
short-lived allocations.

This is a targeted result-formatting optimization. It does not address
every source of API tail latency or retained cache memory.

## Validation

- Added a nested-relation regression test that verifies unchanged
output.
- The test verifies metadata resolution is bounded per object type
within one invocation and recomputed for a separate invocation.
- Focused formatter Jest suite.
- Existing chart relation-label Jest suite, 10 tests.
- Type-aware Oxlint.
- Oxfmt.
- `yarn nx typecheck twenty-server`.
2026-07-30 16:31:44 +00:00
Paul Rastoin bc0ec6b104 Maintain INDEX view system side effects on deactivated views (#23590)
# Introduction

Follow-up on
https://github.com/twentyhq/twenty/pull/23585#discussion_r3683701472.

Two INDEX view side-effect handlers bailed out when the view had
`isActive: false`. This drops those gates.

# Why

`isActive: false` on a view has exactly one writer: the delete path,
when `isCallerOverridingEntity` is true
(`from-delete-view-input-to-flat-view-or-throw.util.ts`,
`view.service.ts`). It is not in `FLAT_VIEW_EDITABLE_PROPERTIES`, so
nothing else sets it.

So the flag means "the workspace deleted an engine-owned view, and since
the engine owns the row we deactivate instead of hard-deleting". It is a
workspace override of a row we still own, not a signal the row is gone
(that is `deletedAt`). Which makes it precisely the state where the
engine must keep maintaining its own rows: the row still exists, still
belongs to the engine, and is expected to be consistent whenever the
override is lifted.

Skipping the side effect instead left the view permanently incomplete,
with no repair path.

The gates were inherited from the candidate-view scan removed in the
same commit as these handlers were introduced
(`compute-flat-view-fields-from-fields-widgets.util.ts`, #23081). There,
`!view.isActive` filtered which of many views were candidates.
Transplanted into handlers that resolve *the* one deterministic
engine-owned INDEX view identifier, the same predicate stops meaning "is
this a candidate" and starts meaning "silently skip the system side
effect".

# Changes

- `fieldIndexViewFieldOnCreate`: create the INDEX view field even when
the view is deactivated.
- `objectIndexViewLabelIdentifierOnUpdate`: reconcile the label
identifier view field even when the view is deactivated.

`deletedAt` gates are unchanged in both.

The `should noop when the object has no active INDEX view` spec case is
inverted accordingly.

# Follow-up

Both handlers also drop inactive view fields when computing positions,
while `FlatViewFieldValidatorService` builds its `otherFlatViewFields`
with no `isActive` filter. An inactive view field below all active ones
would make a handler emit a label identifier position the validator then
rejects. Unreachable today (view fields are only ever soft-deleted,
never deactivated), so left out of this PR.
2026-07-30 15:42:30 +00:00
github-actions[bot] 550aeafd90 i18n - docs translations (#23589)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-30 17:20:41 +02:00
Paul Rastoin 08891db8be Create INDEX view fields visible on field creation (#23585)
# Introduction

Follow-up on https://github.com/twentyhq/twenty/pull/23081.

Creating a field on an existing object added its column to the object's
index view hidden, while creating the same field alongside its object
added it visible. Same field, different outcome depending on when it was
created.

Both now create it visible. Hiding the column stays one click away, and
that choice is kept as a user override on top of the engine default.

Applies to fields created from now on. Nothing is backfilled: an already
hidden column cannot be told apart from one a user hid on purpose.

`objectSystemFieldsAndIndexViewOnCreate` and the 2-26 reconcile command
are untouched.
2026-07-30 15:16:36 +00:00
github-actions[bot] f3de8ce631 i18n - translations (#23587)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-30 17:17:05 +02:00
Raphaël Bosi 5848c9bd30 Display object, field and view links as chips in the AI chat (#23573)
<img width="3840" height="1876" alt="CleanShot 2026-07-30 at 15 37
46@2x"
src="https://github.com/user-attachments/assets/9fe178b9-c2fa-4b05-9c9d-0cdc80270b67"
/>



https://github.com/user-attachments/assets/34c4e486-c462-4300-ae98-da99f614f069



The AI chat already renders record chips from a `[[record:...]]` marker
the model writes in its prose, but naming an object, field or view
produced plain text. This adds three sibling markers so those render as
chips too, as in the [Figma
design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=104416-116261).

- `[[object:<nameSingular>:<label>[[/object]]` links to the record index
page. It is name-keyed rather than id-keyed so an object the assistant
only *proposes* to create still renders as a chip, just without a link.
- `[[field:<id>:<label>[[/field]]` links to the field's settings page,
gated on the `DATA_MODEL` permission.
- `[[view:<id>:<label>[[/view]]` links to the object index page for that
view.

Field and view ids must come from a tool, so an unresolvable one falls
back to plain text rather than a chip that goes nowhere.

The record-only parser becomes one scan over all four kinds. Alternative
order is load-bearing: `[[view:<uuid>:` is shaped exactly like the
legacy prefix-less record marker, so metadata kinds are tried first and
only records keep the legacy `]]` terminator.

Server side is prompt-only. The metadata and view tools return bare
objects rather than `ToolOutput`, so there is nowhere to hang a
structured reference array without wrapping every factory, and the names
and ids the markers need are already in those results verbatim.

Also fixes a pre-existing issue in `LazyMarkdownRenderer`: its
`components` map was rebuilt on every render, and react-markdown uses
each entry as the JSX element type, so every node remounted on every
streamed chunk. Harmless before, expensive once the model is told to
chip every metadata name it writes.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23573?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-30 15:08:21 +00:00
Félix Malfait a9d996ff7e Clarify application licensing and add trademark policy (#23564)
## What

- `twenty-sdk`, `twenty-client-sdk`, `create-twenty-app`,
`twenty-shared` and `twenty-ui` are now MIT (package.json + LICENSE
files). The SDKs are bundled into third-party applications and app front
components import twenty-ui, so these need a permissive license for apps
to be licensable by their authors. `twenty-shared` is included because
both SDKs inline it at build time; an MIT SDK bundling AGPL code would
defeat the purpose. Apps under `packages/twenty-apps` were already MIT.
- Added a "Twenty Application Exception" to LICENSE (additional
permission under AGPLv3 section 7): applications that interact with
Twenty through the app platform interfaces (APIs, manifests, logic
functions, front components, SDKs) are not subject to copyleft and can
be licensed freely by their authors. Modifying Twenty itself remains
fully AGPL, including the network clause.
- Rewrote the LICENSE intro to describe the three licensing zones (AGPL,
Enterprise-marked files, MIT packages) and fixed the intro incorrectly
saying "GPL".
- Added TRADEMARK.md: what anyone can do without asking (self-host,
"built on Twenty", forks under their own name) and what requires
permission (using the name or logo for a product, domain, or hosted
offering).

## Why

Gives app developers and partners legal certainty that building on the
platform does not pull their apps under AGPL, while the core stays AGPL.

The exception and trademark wording should get a legal review before
being announced.
2026-07-30 16:55:19 +02:00
Weiko 3689e89440 Optimize upgrade status gauges with count-only queries (#23574)
## Context

Upgrade health metrics and the admin upgrade-status query currently
share `getInstanceAndAllWorkspacesStatus`.

On a cache hit, that method reads the cached behind/failed workspace
IDs, then hydrates every workspace name with an individual
`CoreEntityCacheService.get` call. This is useful for the admin
response, but the gauges only need the number of workspaces in each
state.

As the number of behind or failed workspaces grows, every gauge refresh
therefore creates a fan-out of entity-cache lookups. Those lookups can
include Redis validation and response deserialization. Production
profiling of slow upgrade-status requests showed
`loadWorkspaceNamesById` and `CoreEntityCacheService.get` on the hot
path, so this PR removes that unnecessary repeated work.

## What changed

- Added a count-only upgrade-status method for metric collection.
- Updated upgrade gauges to use cached ID counts without loading
workspace names.
- Replaced the admin path's per-workspace cache lookups with one
repository query selecting only `id` and `displayName`.
- Removed the upgrade module's now-unused core-entity-cache dependency.

## Why this improves performance

### Metrics path

Before:

- Read the cached upgrade-status IDs.
- Run one entity-cache lookup per behind/failed workspace.
- Discard the hydrated names and only use the array lengths.

After:

- Read the same cached upgrade-status IDs.
- Derive counts directly from those IDs.
- Perform no workspace-name lookup.

**This changes metric collection from a fixed set of status-cache calls
plus `N` entity-cache calls to only the fixed status-cache calls. The
amount of ID data still scales with the number of affected workspaces,
but the Redis/client round-trip fan-out does not.**

### Admin path

The admin response still needs workspace names. It now loads them with
one primary-key `IN` query instead of `N` independent entity-cache
calls. This reduces round trips and repeated cache validation while
preserving the response shape.

## Safety and behavior preservation

- Upgrade-status cache keys, TTLs and invalidation behavior are
unchanged.
- A missing cache marker still triggers the existing full status
refresh.
- Metrics names and values are unchanged.
- The admin GraphQL response is unchanged.
- Cached workspace IDs missing from the database still produce a `null`
name, matching the previous behavior.
- The batched query runs only for callers that request the detailed
admin payload, not for metric collection.

## Expected impact

- Remove recurring per-workspace cache fan-out from every API process
collecting upgrade gauges.
- Reduce Redis client work, response deserialization and event-loop
pressure during metric collection.
- Reduce latency for detailed admin upgrade-status requests.

This targets one profiled source of tail latency. It is not expected to
eliminate all API p99 outliers, which also have independent causes.

## Validation

- 36 focused upgrade-status and gauge tests pass.
- `yarn nx typecheck twenty-server` passes.
- Oxlint passes with zero warnings and errors.
- Oxfmt and `git diff --check` pass.
2026-07-30 14:38:48 +00:00
Weiko ad271ee639 Add connected account handle/provider index (#23580)
## Context

Google messaging webhook notifications resolve connected accounts with
an equality lookup on both `handle` and `provider`:

```ts
connectedAccountRepository.find({
  where: {
    handle: decodedData.emailAddress,
    provider: ConnectedAccountProvider.GOOGLE,
  },
});
```

This lookup runs for incoming Gmail notifications, but
`connectedAccount` currently has no index matching either predicate. As
the table grows, PostgreSQL has to inspect unrelated connected-account
rows for each notification. Under sustained webhook traffic, that adds
avoidable database work and keeps database connections occupied longer.

## What changed

- Add a composite B-tree index on `connectedAccount(handle, provider)`.
- Register the index in the TypeORM entity metadata.
- Add an idempotent 2.26 fast instance command to create the index for
existing installations and remove it on rollback.

The webhook handler and query behavior remain unchanged.

## Why this index

- Both query predicates are equality conditions, so the composite index
supports a targeted lookup.
- `handle` is first because it is the more selective value and also
makes the index useful for handle-prefixed lookups.
- The index is intentionally non-unique. The same provider handle may
legitimately belong to connected accounts in different workspaces, and
this change must not introduce a new data constraint.
- Connected accounts are read by webhooks much more frequently than
their handle or provider changes, so index maintenance overhead should
remain small.

## Expected impact

Webhook account resolution should use an index lookup instead of
scanning the connected-account table. This reduces cumulative PostgreSQL
work and connection occupancy on the Gmail notification path.

This is a targeted database optimization. It should reduce pressure
generated by this high-frequency query, but it is not expected to
resolve every source of API tail latency by itself.

## Safety and rollout

- The instance command uses `CREATE INDEX IF NOT EXISTS` and `DROP INDEX
IF EXISTS`.
- No uniqueness or application behavior changes are introduced.
- Existing rows require no data backfill.
- The index adds bounded storage and write-maintenance overhead.

## Validation

- `yarn nx typecheck twenty-server`
- Type-aware Oxlint on the changed files
- Oxfmt on the changed files
- `git diff --check`


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23580?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-30 14:35:24 +00:00
Weiko fc6a95a37f Throttle local cache expiration sweeps (#23579)
## Context

The workspace cache and core entity cache keep bounded in-process maps.
Entries that have not been read for 30 minutes are removed by an
expiration sweep.

Before this PR, every cache read synchronously walked the entire local
cache, including every stored version, before performing the actual
lookup. The cost therefore grew with the number of cached entries even
when there was nothing to expire. Both caches are used on common server
request paths, so these repeated full-map scans add unnecessary CPU work
and short-lived allocations, which can contribute to event-loop and
garbage-collection pressure under load.

## What changed

- Run each cache's expiration sweep at most once per minute.
- Keep the existing expiration logic unchanged and use one captured
timestamp for the complete sweep.
- Cover both cache services with tests proving that repeated reads
within the interval trigger one sweep and that sweeping resumes after
the interval.

Normal cache reads now pay only for a timestamp check and branch. The
full `O(cache size)` scan runs at most once per minute per process.

## Safety

This does not change cache freshness:

- The 100 ms local freshness window and Redis hash validation still run
as before.
- Explicit cache invalidation is unchanged.
- The 30-minute inactivity threshold is unchanged.
- LRU eviction still runs when entries are inserted.
- Existing local-cache size limits remain unchanged.

An unused entry can remain in memory for at most one additional minute
before the next sweep. This may marginally increase average retained
memory, but it cannot cause unbounded growth or allow stale data to
bypass the existing hash validation.

## Expected impact

This removes a cache-size-dependent operation from a high-frequency
path. The expected benefit is lower CPU and allocation overhead, less
garbage-collection pressure, and improved tail latency when local caches
are populated.

This is intentionally a narrow optimization. It does not claim to
address every source of API tail latency.

## Validation

- `yarn nx typecheck twenty-server`
- Type-aware Oxlint on the changed files
- Oxfmt on the changed files
- Targeted workspace-cache and core-entity-cache Jest suites, 23 tests
passing
2026-07-30 14:24:16 +00:00
martmull e1b5edc07e Compute last contact on relationship changes in last-contact app (#23569)
## What

The `last-contact` app only refreshed the last contact on Companies and
Opportunities when a new email or meeting arrived. When relationships
changed but no interaction happened, those fields went stale:

- Creating an opportunity with an existing point of contact left its
last contact empty.
- Changing an opportunity's point of contact kept the previous contact's
value.
- Assigning a person (who already had contact history) to a company
never surfaced on the company.

This adds logic functions that recompute the derived last-contact fields
when the record or its relationships change.

## Changes

New logic functions (auto-discovered):

- `on-opportunity-created` (`opportunity.created`) and
`on-opportunity-updated` (`opportunity.updated`, `pointOfContactId`)
recompute an opportunity's last contact from its point of contact.
- `on-company-created` (`company.created`) recomputes a company's last
contact from its people.
- `on-person-created` (`person.created`) and `on-person-updated`
(`person.updated`, `companyId`) recompute the former and current
company's last contact when a person joins or leaves.

Shared helpers `recomputeOpportunityLastContact` and
`recomputeCompanyLastContact` mirror the point-of-contact /
most-recent-person value onto the record (clearing it when there is no
contact). Reads use the morph relation subfield (`lastContactItemMessage
{ id }`), matching the existing integration-test read pattern.

The `updatedFields` filters keep these off the interaction write path,
so they never self-trigger.

## Tests

- Unit tests for both recompute helpers and the new logic functions.
- Integration tests covering opportunity-on-create, point-of-contact
change, person joining/leaving a company, and the empty-company case.
- `typecheck`, `lint`, and unit tests pass.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23569?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-30 14:24:05 +00:00
Paul Rastoin 2be01df271 docs: state v1.23 as prerequisite for cross-version upgrades (#23575)
Fixes #23568

The upgrade guide stated v1.22 was enough before jumping to a 2.x
release. In practice that path fails during the workspace migration with
`column ViewSortEntity.subFieldName does not exist`. Going through v1.23
first works.

Changes in
`packages/twenty-docs/developers/self-host/capabilities/upgrade-guide.mdx`:
- Cross-version upgrade section now says v1.23+ instead of v1.22+,
example updated to v1.23 -> v2.0
- "Before v1.22" section renamed to "Before v1.23" and its instructions
updated

Only the English source is edited, the `l/<locale>/` copies are
Crowdin-managed and will resync.

Co-authored-by: prastoin <paul.rastoin@gmail.com>
2026-07-30 14:22:55 +00:00
martmull 65155fe50c feat(apps): add enqueueJob to run a logic function on the workers (#23527)
Closes twentyhq/core-team-issues#2742

A logic function run is capped by its own `timeoutSeconds` (900s max),
so anything that can't finish in one run — a full re-sync, a per-record
fan-out, a rate-limited third-party API — had no way to continue. This
adds a way to hand that work to the workers.

## What it looks like for an app author

```ts
import { enqueueJob } from 'twenty-sdk/logic-function';

await enqueueJob({
  logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
  payload: { cursor: nextCursor },
  retryLimit: 3,
  priority: 2,
  delayMs: 60_000,
});
```

The target runs in its own process with its own timeout budget. The
classic shape is a function that enqueues *itself* with the next cursor
until there is nothing left.

## Changes

**twenty-shared** — `EnqueueJobInput` / `EnqueueJobOptions` /
`EnqueueJobResult` in `application`.

**twenty-server** — new `application-job` module under
`core-modules/application`, following the `application-key-value`
pattern:
- `enqueueJob` mutation on the metadata API, `@AuthApplication`-scoped
- the lookup is scoped to `applicationId` + `workspaceId` — that's the
authorization boundary, an app can only enqueue its own logic functions,
anything else is `LOGIC_FUNCTION_NOT_FOUND`
- pushes a `LogicFunctionTriggerJob` onto the existing
`logicFunctionQueue`, so the enqueued run goes through the same executor
(and the same execution throttling) as every other trigger
- the queued run inherits the caller's `userId`/`userWorkspaceId`, so
its app access token carries the same permissions as the function that
queued it

**Job options** are range-checked via `ResolverValidationPipe`, since
the values come from application code and an unbounded delay or retry
count would let an app pin work in the shared queue:

| Option | Default | Range |
|--------|---------|-------|
| `retryLimit` | `0` | `0`–`10` |
| `priority` | queue default | `1`–`10` (lower first) |
| `delayMs` | `0` | `0`–7 days |

`retryLimit` defaults to `0` rather than inheriting the server-route
path's `3`: retries re-run the whole handler, so opting in should be the
author's explicit choice.

**twenty-sdk** — `enqueueJob` in `twenty-sdk/logic-function`, same shape
as `runAgent`/`kv`.

**Docs** — new "Background Jobs" page under Extend → Apps → Logic, plus
nav and overview entries.

**Generated** — regenerated `twenty-front/src/generated-metadata` and
`twenty-client-sdk/src/metadata/generated` for the new mutation.

## Tests

- `application-job.service.spec.ts` — 5 unit tests: job options mapping,
defaults, acting-user propagation, application-scoped lookup, not-found
- `enqueue-job.integration-spec.ts` — 5 integration tests: rejects a
non-`APPLICATION_ACCESS` token, enqueues a function the app owns,
rejects a function owned by another application, rejects an unknown
identifier, rejects out-of-range options

All green locally, along with `typecheck` for
`twenty-server`/`twenty-sdk` and oxlint/oxfmt on the touched files.

## Notes for review

- The target is addressed by `universalIdentifier`, matching `runAgent({
agentUniversalIdentifier })` and
`ServerRouteDispatchResult.targetLogicFunctionUniversalIdentifier`.
Addressing by `name` would be friendlier, but logic function names
aren't validated for uniqueness within an app — happy to add it as a
convenience if you'd rather.
- `enqueueJob` returns as soon as the job is accepted; it can't return
the target's result, since the queue driver's `add` returns void.
Documented, with a pointer to the KV store for handing results back.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23527?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-30 14:20:34 +00:00
Thomas Trompette 6447b7f935 feat(workflow): make the flow atom authoritative for version content, read core behind a flag (#23499)
## What

The frontend half of the workflow-version read switch, plus the small
server query it consumes. Two ideas:

1. **One hook owns where content comes from.**
`useWorkflowVersionContent(workflowVersionId)` returns `{ trigger, steps
}` from the workspace record when `IS_WORKFLOW_VERSION_IN_CORE_ENABLED`
is off (default), and from the new `workflowVersionContent` core query
when on. Switching the source later (core-only, after the column drop)
is a change inside this one hook.
2. **`flowComponentState` becomes authoritative for the builder.** The
canvas, diagram and step output schemas derive from the jotai atom; the
atom is seeded once per version through the hook above; mutations keep
it up to date.

## Why the seeding change is required

Today `WorkflowDiagramEffect` re-seeds the atom from the Apollo record
on **every** `currentVersion` identity change. That has two
consequences:

- Three of the five step/edge hooks (`delete step`, `create edge`,
`delete edge`) never write the atom themselves; they only write the
record and the re-seed papers over it.
- The model breaks the moment content comes from a source mutations do
not write (i.e. core): the stale fetch would be re-applied over every
optimistic edit, and your just-added step would vanish from the canvas.

So the atom is now seeded **once per version**, and
`useUpdateWorkflowVersionCache` applies the mutation's
`stepsDiff`/`triggerDiff` to the atom directly. All five step/edge hooks
get that through their existing call, which closes the three-hook gap in
one move. The step-update, trigger and tidy-up hooks write the atom too.
The record-cache writes are all kept while `trigger`/`steps` still live
on the record (dropped later with the columns).

## The dead wire, now the refresh path

`shouldWorkflowRefetchRequestFamilyState` was set by
`WorkflowSSESubscribeEffect` (reconnect, other-tab create) and
**consumed by nothing**. It is now the external-refresh path: when set,
the builder refetches content and reseeds. Known trade-off: while
connected, another tab's edits no longer live-patch the canvas through
record cache updates (they arrive on reconnect, version switch or
reload). Given concurrent editing of one draft has no conflict handling
anyway, that seemed acceptable; easy to extend the SSE effect to set the
flag on update events if we want live propagation back.

## Untouched by design

- **Run visualizer**: feeds the same atom from the immutable
`workflowRun.state.flow` snapshot; that duality (version content or run
snapshot) is exactly why the atom stays separate from the record store.
- **Version visualizer** (read-only): reseeds on content change, safe
because nothing writes its instance optimistically.
- Peripheral readers of `currentVersion.trigger/steps` (test-workflow
command, headless command enrichment, if-else body, etc.) still read the
record. Correct while dual-writing continues; they move to the content
hook before workspace content writes stop (tracked in the migration
plan).

## Verification

- `nx typecheck` green on both packages; `oxfmt` + `oxlint --type-aware`
green on all 16 changed files
- Front unit tests: 134 suites / 993 tests green (the two hook tests
gained the visualizer instance context their hooks now require)
- New server integration test for `workflowVersionContent`
- **Live click-through pending**: step create/delete/duplicate, edge
create/delete, trigger edit, tidy-up, draft create/discard, activation,
version viewer, run viewer, with the flag off and on. The failure mode
this PR guards against (an edit vanishing from the canvas) does not show
up in typecheck or unit tests.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23499?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-30 14:00:10 +00:00
Félix Malfait c25ae72914 Delete PRODUCT.md (#23577) 2026-07-30 15:30:20 +02:00
Félix Malfait 2d74eca41c Delete DESIGN.md (#23576) 2026-07-30 15:29:50 +02:00
github-actions[bot] 5977185c34 i18n - translations (#23572)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23572?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-30 15:04:14 +02:00
Paul Rastoin 5adc3ab4a2 Pin last-contact to twenty &gt;=2.26.0 (#23520)
Follow-up to #23081.

`twenty-last-contact@1.1.3` stopped declaring its INDEX view fields
explicitly and now relies on the engine's `fieldIndexViewFieldOnCreate`
to provision the INDEX view column of each app field. That handler only
exists from `2.26`, but the app still advertised `engines.twenty:
">=2.23.0"`.

This bumps the range to `>=2.26.0` and documents it in the changelog.

## Why the range matters

`engines.twenty` is checked in two different places against two
different versions:

- `ApplicationTarballService.extractAndValidateTarball` →
`validateServerCompatibility`, against the **store server's** inferred
version. So `1.1.3` can only be deployed once the store instance is on
`2.26`.
- `ApplicationInstallService.runInstall` →
`validateWorkspaceCompatibility`, against the **workspace's completed
upgrade version**.

The second one is the reason for this PR. Publishing a new version calls
`enqueueAutoUpgradeApplications`, and the auto-upgrade path
(`ApplicationUpgradeService.upgradeApplicationToVersion`) does not pass
`skipWorkspaceCompatibilityCheck`. Without the bump, a workspace that
has not yet completed the `2.26` workspace commands would be
auto-upgraded to `1.1.3` and end up with no last-contact columns at all:
the manifest no longer declares them, and pre-`2.26` there is no handler
to provision them. With `>=2.26.0` those workspaces are skipped and stay
on `1.1.2`, which still works on a `2.25` server.

## No SDK bump needed

The only SDK surface the deleted `src/view-fields/*` files used was
`STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.<object>.views.*.universalIdentifier`,
which is exactly what #23081 mutated. Everything left in the app reads
only `.<object>.universalIdentifier`, unchanged, so
`twenty-sdk@2.23.0-alpha.2` still transpiles `1.1.3` to the manifest
`2.26` expects.

## Note on the version number

This pins the existing `1.1.3`, which assumes it has not been deployed
to the store yet. If it has, `validateVersionProgression` rejects a
same-version deploy and this needs to go out as `1.1.4` instead — a
one-line change on this branch.

## Not covered here

Stale `1.1.2` installs on a `2.26` server keep working at runtime (view
fields resolve by database id) but fail any manifest re-sync with `View
not found`, since the standard INDEX view identifiers they target were
renamed by `upgrade:2-26:reconcile-index-view-universal-identifier`.
They will be picked up by auto-upgrade once `1.1.3` is published.
Force-upgrading them from within the `2.26` workspace upgrade, as done
for people-data-labs in `2.23`, would need
`skipWorkspaceCompatibilityCheck: true` (the command runs before the
workspace is marked as having completed `2.26`) and is left out of this
PR.


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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23520?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-30 15:04:08 +02:00
Raphaël Bosi a3beea893d Revert the external link confirmation popup for front components (#23567)
Reverts #23270 and #23404.

Links in front components navigate natively again, with no confirmation
popup and no per-app trusted-origins state in localStorage.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23567?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-30 12:55:55 +00:00
nitin 66e7093524 Use client SDK /s dispatch for call-recorder own-route posts (#23558)
Migrates call-recorder's own-route self-invoke helper off hand-resolved
`TWENTY_FUNCTIONS_URL` and onto the client SDK's built-in `/s` dispatch
(#22863): `postToOwnRoute` now constructs `RestApiClient` with no base
URL and posts to `/s${path}`, letting the SDK resolve
`TWENTY_FUNCTIONS_URL` itself and fall back to `${TWENTY_API_URL}/s`
when it is empty (works on bare multiworkspace hosts since #23490).
Removes the now-unused `resolveOwnRouteBaseUrl` util, its test, and the
env-var-name constant.

Where `TWENTY_FUNCTIONS_URL` is injected non-empty the SDK builds the
identical URL; where it is empty the old code threw and returned false,
while the SDK fallback works on servers with #23490 and fails-caught
identically on servers without it.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23558?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-30 12:40:43 +00:00
Weiko e4e1d24731 Prevent overlapping workspace cleanup executions (#23522)
## Context

The suspended-workspace cleanup is a long-running scheduled job. Under
database or cache pressure, BullMQ can consider an execution stalled and
start a replacement on another worker while the original execution is
still running.

Both executions can then enumerate the same suspended workspaces and run
destructive cleanup concurrently. This amplifies the initial slowdown:

1. Multiple cleanup transactions target the same workspace data.
2. Transactions wait on each other's locks.
3. Database connections remain occupied while waiting.
4. Other workers and API requests have fewer connections available.

There is a second source of unnecessary lock duration in workspace
deletion. The deletion transaction currently starts before field
metadata is read from the workspace cache. If that lookup is slow, the
transaction stays open during an unrelated cache wait.

## What changed

### Prevent overlapping scheduled cleanups

- Acquire a non-blocking PostgreSQL advisory lock before listing
suspended workspaces.
- Skip the execution when another worker already holds the lock.
- Keep the lock on one dedicated PostgreSQL session for the full
callback.
- Release the lock in all normal and error paths.
- Discard the database connection if lock acquisition or release has an
ambiguous failure, preventing a session that may still own the lock from
returning to the pool.
- Encapsulate this lifecycle in `PostgresAdvisoryLockService`, exported
by `TypeORMModule`, so other coarse-grained jobs can reuse it without
handling acquisition and release themselves.

### Shorten the workspace deletion transaction

- Read field metadata and build deletion chunks before starting the
transaction.
- Pass the precomputed chunks into the transactional deletion loop.
- Keep the existing deletion order and SQL behavior unchanged.

## Why a PostgreSQL advisory lock

The lock needs to coordinate workers running in different pods. A
PostgreSQL session advisory lock provides the required behavior:

- It is shared across all workers using the same database.
- Acquisition is non-blocking, a duplicate execution can exit
immediately.
- It has no TTL or renewal heartbeat that could expire during the same
event-loop stall that caused BullMQ to recover the job.
- PostgreSQL automatically releases it when the owning session or
process disappears.

This is deliberately scoped to `CleanSuspendedWorkspacesJob`. It
prevents overlapping scheduled executions, but it is not an exactly-once
mechanism or a global mutex around every workspace-deletion entry point.

## Expected impact

- Prevent one slow cleanup execution from becoming several concurrent
cleanup executions.
- Reduce database lock contention and connection-pool pressure during
cleanup.
- Avoid holding deletion transaction locks while waiting for
workspace-cache data.
- Reduce cleanup-related API latency bursts without changing normal
cleanup semantics.

The advisory lock holds one core database connection for the duration of
the scheduled cleanup. This is intentional and bounded to the single
lock owner.

## Validation

- Focused advisory-lock tests cover successful execution, contention,
callback failure, and unsafe connection disposal when unlock fails.
- Cleanup-job tests cover both the lock-owner and skipped-execution
paths.
- Workspace-service coverage verifies that field metadata is loaded
before the deletion transaction starts.
- `yarn nx typecheck twenty-server`
- Oxlint, Prettier, and Oxfmt checks on the changed files
2026-07-30 12:34:27 +00:00
Paul Rastoin dbd2eac69c Let the instance upgrade version reach releases without instance commands (#23552)
Fixes the CI failure on #23520:

An upgrade version sequence has to at least contain one instance or one
workspace command
Workspaces commands do not run for the instance level and aren't
triggered automatically
Explaining this PR need

<img width="1396" height="954" alt="image"
src="https://github.com/user-attachments/assets/5455d05b-b286-482a-8914-808f55f3b0bf"
/>


```
Upload failed: App requires Twenty server >=2.26.0 but this server is 2.25.0.
```

The server really is 2.26 (`TWENTY_CURRENT_VERSION = '2.26.0'`), but
`validateServerCompatibility` resolves the instance version through
`UpgradeMigrationService.getInferredVersion()`, which reads the last row
in `core.upgradeMigration` with `workspaceId IS NULL AND isInitial =
false` and takes the version prefix off its name. Instance commands are
the only ones that write a `workspaceId`-null row, and `2-26/` ships
none (only three workspace commands), so the highest instance command in
the tree is still
`2-25-instance-command-fast-1785230296000-add-is-hidden-to-agent-message`.
A fully migrated 2.26 server infers 2.25.0, and any app declaring
`engines.twenty: ">=2.26.0"` is unpublishable.

Two defects, both of which `getWorkspaceCompletedVersion` already
avoids:

- **Not sequence-aware.** The workspace path walks the registered
sequence and only credits a version once the cursor sits on that
version's last step. The instance path just reads the cursor's prefix,
so a version contributing zero instance commands is unreachable.
- **Not status-aware.** `getLastAttemptedInstanceCommand` filters on
`attempt = MAX(attempt)` but not on status, so a *failed* 2.25 command
still made the server report 2.25.0.

## What changed

`UpgradeStatusService` gains `getInstanceCompletedVersion()`, the
instance-scope mirror of `getWorkspaceCompletedVersion`. It walks the
sequence filtered to instance steps, requires the cursor to sit on the
last instance step of its version *and* be `completed`, then advances
through any later supported version that declares no instance command at
all.

The version-skipping rule is the part that unblocks 2.26: a release with
no instance-level work has nothing for the cursor to land on, so it is
reached as soon as the last version that does have instance commands is
done. A version whose instance command exists but has not run still
holds the cursor back.

- `validateServerCompatibility` calls the new method;
`UpgradeMigrationService` is no longer a dependency of
`ApplicationVersionValidationService`.
- `getInstanceStatus` reports it as `inferredVersion`, so the upgrade
gauge metric and `upgrade:status` CLI stop showing 2.25.0 on a 2.26
server.
- `getInferredVersion` is deleted. Its one remaining caller passed a
command name, which is just `extractVersionFromCommandName`.
- Cursor resolution is extracted to
`resolve-completed-version-from-cursor.util`, now shared by both scopes;
the skip rule lives in
`advance-through-versions-without-instance-commands.util`.

The asymmetry between the two scopes is intentional and stays: instance
commands record a row per workspace as well, so workspace cursors land
on both command kinds and never had this gap.

## Testing

- `npx nx typecheck twenty-server` clean, `npx nx lint:diff-with-main
twenty-server` clean.
- 294 unit tests pass across the upgrade and application modules,
including 7 new ones for `getInstanceCompletedVersion`. Two pin the
boundary: a trailing workspace-only version is reached, a trailing
version whose instance command has not run is not.
- The fixture in `upgrade-status.service.spec.ts` used
`1.21.0`/`1.22.0`/`1.23.0`, which are real entries in
`TWENTY_PREVIOUS_VERSIONS`. With the skip rule in place that sequence
read as "every version from 2.0 onward has no instance commands" and
walked to the end, so the fixture is renumbered to `0.2x.0` to keep
those tests on cursor resolution alone.
- `failing-app-installation-workspace-version.integration-spec.ts`
already carried a comment describing this bug as a hazard it worked
around. The workaround still holds, but integration tests were not run
here (no DB in this session) — the stale comment is updated.

#23520 stays at `>=2.26.0` and unblocks once this lands.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23552?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-30 12:32:38 +00:00
Raphaël Bosi 0ff9e77fd3 Hide app record-selection commands when all records are selected (#23561)
PDL enrichment and call-recorder's call summary are `RECORD_SELECTION`
commands backed by headless front components, which only receive record
ids in selection mode. Under select all the context store switches to
exclusion mode, so they received an empty `recordIds` array and still
reported success while enriching nothing.

They now declare `conditionalAvailabilityExpression: !isSelectAll`, so
they disappear from the command menu and quick actions while select all
is active. Both app versions are bumped since the server rejects
redeploying an equal version.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23561?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-30 12:30:10 +00:00
Paul Rastoin eb69e56441 [Upgrade] Add a detached-run wrapper for the upgrade command (#23497)
Long upgrades are started over `kubectl exec` into the command-runner
pod, where the process is a child of the exec'd shell: a dropped SSM
tunnel, a closed laptop or a dead VPN kills the run mid-way. There is no
tmux in the image (Alpine, `sh: tmux: not found`).

`scripts/upgrade-background.sh` puts the run in its own session with no
controlling terminal and streams its output from a log file, so losing
the connection detaches the stream instead of killing the upgrade.

Builds on #23481. The cooperative shutdown path is untouched, this is
tooling around it. No TypeScript changed.

## Commands

```bash
yarn upgrade:background [args]   # start detached, then stream the log
yarn upgrade:background:logs     # re-attach from another shell
yarn upgrade:background:stop     # graceful stop; --now immediate, --force SIGKILL
```

`[args]` is forwarded verbatim to `upgrade`, so it takes that command's
options and no others:

| Option | Effect |
| --- | --- |
| `-d`, `--dry-run` | simulate without making changes |
| `-v`, `--verbose` | verbose output |
| `-w`, `--workspace-id <id>` | restrict to a workspace, repeatable; all
provisioned workspaces if omitted |
| `--start-from-workspace-id <id>` | resume from a workspace, ascending
id order |
| `--workspace-count-limit <n>` | process at most n workspaces,
ascending id order |

`-w` and `--start-from-workspace-id` are mutually exclusive, `upgrade`
rejects the combination.

## Example

```ts
➜  twenty-server git:(claude/upgrade-detached-run-wrapper-5nkb0v) ✗ yarn upgrade:background
Running (pid 15779), logging to /tmp/twenty-upgrade.log
Ctrl+C detaches the stream only. Use 'yarn upgrade:background:stop' to stop the run.
^C%
➜  twenty-server git:(claude/upgrade-detached-run-wrapper-5nkb0v) ✗ yarn upgrade:background:stop
SIGTERM sent to 15779, it finishes the step in progress then stops (exit 143)
Tail of /tmp/twenty-upgrade.log:
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] hidden columns on FieldMetadataEntity: standardOverrides,isCustom
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] applied cursor=217 renamed=0 unavailable=0 hiddenColumns=5
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] hidden columns on RolePermissionFlagEntity: flag
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] hidden columns on ObjectMetadataEntity: standardOverrides,isCustom
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] hidden columns on FieldMetadataEntity: standardOverrides,isCustom
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] applied cursor=207 renamed=0 unavailable=0 hiddenColumns=5
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [DatabaseConfigDriver] [INIT] Loading initial config variables from database
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [DatabaseConfigDriver] [INIT] Config variables loaded: 1 values found in DB, 104 falling to env vars/defaults
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeCommand] Initialized upgrade sequence: 217 step(s)
[upgrade] event=sequence.initialized stepCount=217 dryRun=false
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceIteratorService] Running on workspace 20202020-1c25-4d02-bf25-6aeccf7ea419 1/2
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceCommandRunnerService] Upgrading workspace 20202020-1c25-4d02-bf25-6aeccf7ea419 1/2
[upgrade] event=workspace.start workspaceId=20202020-1c25-4d02-bf25-6aeccf7ea419 index=1 total=2 dryRun=false
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceCommandRunnerService] Upgrade for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419 completed.
[upgrade] event=workspace.success workspaceId=20202020-1c25-4d02-bf25-6aeccf7ea419 executedByVersion=unknown dryRun=false
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceIteratorService] Running on workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db 2/2
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceCommandRunnerService] Upgrading workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db 2/2
[upgrade] event=workspace.start workspaceId=3b8e6458-5fc1-4e63-8563-008ccddaa6db index=2 total=2 dryRun=false
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [DummySleepCommand] Sleeping for 30000ms on workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db
[Nest] 15779  - 07/30/2026, 10:47:53 AM    WARN [CommandShutdownService] Received SIGTERM, finishing the step in progress then stopping. Send SIGTERM again to exit immediately.
Follow with 'yarn upgrade:background:logs'. Still stuck: 'stop --now'. Last resort: 'stop --force'.
➜  twenty-server git:(claude/upgrade-detached-run-wrapper-5nkb0v) ✗ yarn upgrade:background:stop --now
Second SIGTERM sent to 15779, immediate exit with the step in progress left unfinished
Tail of /tmp/twenty-upgrade.log:
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] hidden columns on RolePermissionFlagEntity: flag
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] hidden columns on ObjectMetadataEntity: standardOverrides,isCustom
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] hidden columns on FieldMetadataEntity: standardOverrides,isCustom
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeAwareEntityMetadataAdapter] [upgrade-metadata] applied cursor=207 renamed=0 unavailable=0 hiddenColumns=5
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [DatabaseConfigDriver] [INIT] Loading initial config variables from database
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [DatabaseConfigDriver] [INIT] Config variables loaded: 1 values found in DB, 104 falling to env vars/defaults
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [UpgradeCommand] Initialized upgrade sequence: 217 step(s)
[upgrade] event=sequence.initialized stepCount=217 dryRun=false
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceIteratorService] Running on workspace 20202020-1c25-4d02-bf25-6aeccf7ea419 1/2
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceCommandRunnerService] Upgrading workspace 20202020-1c25-4d02-bf25-6aeccf7ea419 1/2
[upgrade] event=workspace.start workspaceId=20202020-1c25-4d02-bf25-6aeccf7ea419 index=1 total=2 dryRun=false
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceCommandRunnerService] Upgrade for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419 completed.
[upgrade] event=workspace.success workspaceId=20202020-1c25-4d02-bf25-6aeccf7ea419 executedByVersion=unknown dryRun=false
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceIteratorService] Running on workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db 2/2
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [WorkspaceCommandRunnerService] Upgrading workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db 2/2
[upgrade] event=workspace.start workspaceId=3b8e6458-5fc1-4e63-8563-008ccddaa6db index=2 total=2 dryRun=false
[Nest] 15779  - 07/30/2026, 10:47:50 AM     LOG [DummySleepCommand] Sleeping for 30000ms on workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db
[Nest] 15779  - 07/30/2026, 10:47:53 AM    WARN [CommandShutdownService] Received SIGTERM, finishing the step in progress then stopping. Send SIGTERM again to exit immediately.
[Nest] 15779  - 07/30/2026, 10:48:00 AM    WARN [CommandShutdownService] Received SIGTERM again, exiting immediately. The step in progress is left unfinished, rerun the command to resume from the last recorded step.
EXIT=143
```

## Not a concurrency guard

`start` refuses when it can see a live run, but that only keeps this
wrapper's own bookkeeping straight, one PID file and one log per run.
The PID file is in the container's `/tmp`, so a second pod or a laptop
pointed at the same database sees none of it.

Nothing in `upgrade` prevents two sequences either: `upgradeMigration`
records only `completed` and `failed`, so it has no in-progress state to
lock against, and the sequence runner takes no advisory lock. Out of
scope here, and worth a follow-up if we want it enforced rather than
operational.

## Dockerfile

`scripts/` was not copied into the server image, so all three commands
would have failed with ENOENT in the pod. Added the COPY, plus a `chmod
+x` matching the one already on `entrypoint.sh`.

Rest is documented in `docs/UPGRADE_COMMANDS.md`, including the
exit-code table and why a graceful stop is always safe to rerun.
2026-07-30 12:24:07 +00:00
github-actions[bot] d5d0726216 i18n - translations (#23563)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23563?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-30 14:14:49 +02:00
Raphaël Bosi 38ad13655c Auto-start the workspace setup chat with a data model proposal (#23437)
https://github.com/user-attachments/assets/d447372b-c1b4-4c95-bac9-a8f8efa7d4a1



When the workspace creator lands on `/workspace-setup` after onboarding,
the AI chat now starts on its own: an invisible first message, built
server-side from the company enrichment collected in #23199, asks the
assistant to propose a data model tailored to the business. The proposal
streams in; the user never sees the prompt.

- New `startWorkspaceSetupChat` mutation: creator only, gated on
`IS_ONBOARDING_AI_CHAT_ENABLED`, available models and credits.
Idempotent per user and workspace via a `keyValuePair` pointing at the
thread, so a reload or a second tab joins the same conversation instead
of starting a new one.
- The thread holds exactly one hidden `USER` message combining the
company context and the setup instructions, which keeps the
one-hidden-message-per-thread index from #23199 satisfied. It goes
through a dedicated streaming path that never queues, so the prompt
cannot resurface as a visible message.
- The assistant only proposes. It creates nothing until the user
approves, then builds the model with the `metadata-building` skill.
Objects and fields get English names with labels in the user's language,
and the conversation continues in that language.
- With no enrichment (consumer email domain, or the integration
disabled) the kickoff still runs, and the assistant asks one short
question about the business before proposing.
- `findLatestSentUserMessage` no longer filters out hidden messages, so
a failed kickoff turn stays retryable, and the no-message chat error
surface now offers retry for stream errors.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23437?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-30 12:06:13 +00:00
Raphaël Bosi 014b3cdc67 Mirror host element geometry into front component workers (#23264)
Front components run in a Web Worker whose fake DOM has no layout APIs,
so any library that measures itself crashes. This is the reported
recharts bug: `ref.getBoundingClientRect is not a function`.

### Why this is needed

Layout only exists on the host: the worker builds a virtual tree, and
the host renders the real DOM nodes. Nothing in the worker knows how big
anything is.

```mermaid
flowchart LR
    COMP["Front component<br/>recharts, twenty-ui"] -->|"el.getBoundingClientRect()"| DOM["remote-dom fake DOM<br/>in the Web Worker"]
    DOM --> MISS["No layout APIs:<br/>method does not exist"]
    MISS --> BOOM["TypeError, component crashes"]
    HOST["Host: real DOM nodes<br/>with real sizes"] -.->|"never reaches the worker"| DOM
```

The worker cannot simply ask the host and wait: measurement APIs are
synchronous, and the worker must never block on a round trip.

### How the mirror works

The host measures and pushes; the worker only ever reads from a local
copy. Reads stay synchronous and are at most one frame behind.

```mermaid
flowchart TB
    subgraph HOST["Host - main thread, real DOM"]
        WAKE["Wake sources<br/>resize, scroll, mutations, animation events"]
        TRACK["createGeometryTracker<br/>rAF loop, idles after 20 unchanged frames"]
        NODES["Real DOM nodes<br/>registered per remote element id"]
    end

    subgraph WORKER["Web Worker - fake DOM"]
        STORE["workerGeometryStore<br/>snapshot mirror"]
        POLY["Element.prototype polyfill<br/>getBoundingClientRect, offset, client, scroll"]
        COMP2["Front component"]
    end

    WAKE -->|"wake"| TRACK
    NODES -->|"measure changed nodes only"| TRACK
    TRACK ==>|"pushGeometryUpdates over MessagePort"| STORE
    STORE -->|"synchronous read, one frame stale"| POLY
    POLY --> COMP2
    COMP2 -.->|"first read enrolls the element:<br/>observeElementGeometry"| TRACK
```

Enrollment is demand-driven: an element is only measured once the
component actually reads its geometry, so idle components cost nothing.

```mermaid
sequenceDiagram
    participant C as Front component
    participant P as Element polyfill
    participant S as Worker geometry store
    participant T as Host geometry tracker
    participant D as Real DOM

    C->>P: el.getBoundingClientRect
    P->>S: resolve snapshot
    S-->>P: none yet, returns zeros
    S->>T: observeElementGeometry, batched in a microtask
    T->>D: measure on the next animation frame
    D-->>T: rect, offset, client, scroll
    T->>S: pushGeometryUpdates with viewport and changed elements
    Note over T: the loop stops after 20 unchanged frames, any wake source restarts it
    C->>P: el.getBoundingClientRect on a later frame
    P->>S: resolve snapshot
    S-->>P: mirrored values
    P-->>C: real numbers
```

### What changed

- The host measures the real DOM nodes on animation frames while wake
sources report activity, and pushes snapshots over the existing
MessagePort. The loop goes idle when nothing changes, and both sides cap
observation at 500 elements.
- In the worker, `getBoundingClientRect`, the
`offset*`/`client*`/`scroll*` getters and
`window.innerWidth`/`innerHeight` read those snapshots from the
worker-local mirror.
- The worker also gains the small DOM APIs libraries expect:
`getComputedStyle` (returns the element's declared style),
`getElementsByClassName`, `document.getElementById`, and a working
per-element `style` on base elements (remote-dom ships a no-op stub
whose `getPropertyValue` returns undefined, which crashed twenty-ui's
ThemeProvider).

Result: a fixed-size recharts `AreaChart` story renders, and the four
twenty-ui gallery stories that used to fail on the missing
`getComputedStyle` now run in strict zero-failure mode.

Moved, not new: `FrontComponentRenderer` now renders its thread effects
directly instead of through a pass-through component, and its output is
wrapped in a `<div style="width:100%;height:100%">` instead of a
fragment so geometry has a measurable root (a real layout change for
embedders).

Deferred to the ResizeObserver follow-up: text measurement (axis-label
overlap thinning), `offsetParent` mirroring, animation in-flight
tracking, `ResponsiveContainer`, the tooltip, and the
`measureElementGeometry` RPC.

Last of the three PRs splitting the geometry mirror work, after #23262
(host wrapper hooks) and #23263 (style proxy).
2026-07-30 12:02:18 +00:00
github-actions[bot] cdeebb1a18 i18n - docs translations (#23559)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-30 13:09:47 +02:00
nitin c862a2a43d Switch call recorder post-call transcription to Gladia with code switching (#23532)
## What

Switches the Call Recorder app's post-meeting transcription provider
from Recall.ai's built-in transcription (`recallai_async`) to Gladia
(`gladia_v2_async`), with code switching enabled so mixed-language calls
transcribe correctly.

## Changes

- `create_transcript` requests now send `provider: { gladia_v2_async: {
language_config: { code_switching: true } } }` instead of
`recallai_async` with `language_code: 'auto'`. Gladia auto-detects the
spoken language by default, and code switching re-detects it per
utterance for calls that mix languages.
- The provider payload is extracted into a
`RECALL_ASYNC_TRANSCRIPT_PROVIDER` constant so a future
provider-selection variable can slot in without touching the request
code.
- SETUP.md documents the new operational requirement: a Gladia API key
must be added in the Recall.ai dashboard (Transcription > Gladia) for
each region in use, otherwise transcripts fail.

<img width="2810" height="1656" alt="CleanShot 2026-07-30 at 15 00
27@2x"
src="https://github.com/user-attachments/assets/c702ab09-eea8-4c54-8e5a-4941951391c9"
/>

tested on twenty dev recall workspace 

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23532?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-30 10:42:14 +00:00
nitin 079e9b8e56 feat(dashboard): extend number format option to bar, line and pie charts (#23505)
https://discord.com/channels/1130383047699738754/1509604545381142649

Extends the Format option (Short/Full) added for the Number widget in
#21521 to bar, line and pie charts.

Format controls the numbers printed on the chart face: data labels and
the pie center metric. Axis ticks stay abbreviated and tooltips always
show the full value. Defaults to Short, so existing charts render
unchanged.

Server: nullable `numberFormat` on the bar/line/pie configuration DTOs,
exposed in the dashboard AI tool schema. No migration, configuration is
jsonb.

Deferred:
- The Format row has no visible effect while data labels are off, since
tooltips are always full.
- Number widget format defaults differ by field type (CURRENCY defaults
to Short, NUMBER to Full). Pre-existing, untouched here.


https://github.com/user-attachments/assets/0778f08a-6681-4e7a-8716-fb3026d1e01f



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23505?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-30 09:42:21 +00:00
Abdul Rahman 72322a4d72 feat: Slack conversational assistant (#22984)
## Summary

Lets workspace members talk to the Twenty CRM agent from Slack —
`@mention` the bot in a channel or DM it, and it answers in-thread using
the `slack-assistant` agent and its assigned role.

## How it works

Slack Events webhook → app route verifies signature → **ack in <3s** and
enqueue a `slackAssistantRequest` → worker posts a placeholder
immediately, then fetches recent thread/DM history (excluding the
current message and placeholder), runs `runAgent`, and updates the
placeholder with the answer. After a successful reply, the thread stays
subscribed (24h TTL, renewed on each reply) so follow-ups work without
re-mentioning.

## App-owned orchestration

Protocol + orchestration live in `twenty-apps/public/twenty-slack`
(events resolver, enqueue, worker, team claim KV, thread subscription).
The server provides shared primitives (app routes, `runAgent`, app KV,
connection OAuth).

## Notes

- Agent role is bound via `roleUniversalIdentifier` on install. Default
**Slack Assistant** role: read/create/update/soft-delete on people,
companies, opportunities, notes, and tasks; **workspace members stay
read-only**; hard destroy stays off. Admins can tighten the role in
Settings.
- Setup (signing secret, event subscriptions, scopes) is in the app
README.
- Long-lived Slack bot tokens (no refresh token) are treated as
non-expiring.
- Multi-turn: recent Slack thread/DM messages are prepended into the
agent prompt.
- Replies are non-streaming for now (placeholder + final `chat.update`);
progressive streaming is a follow-up.

## Follow-ups

- **Streaming replies** — progressive edits while the agent runs.
- **Per-user / per-channel permissions** — Slack→Twenty user mapping and
optional channel rules (open by default; admins can narrow).
- **Other platforms** — Discord/Teams can reuse the same patterns; only
Slack protocol is in this PR.

## Screenshots


https://github.com/user-attachments/assets/3a72770a-93fa-411d-b4aa-2f741afbcee1


<img width="426" height="686" alt="Screenshot 2026-07-27 at 3 58 38 PM"
src="https://github.com/user-attachments/assets/b0a62e7c-c5e4-4c96-9389-5e47d7ef8c77"
/>
<img width="1053" height="726" alt="Screenshot 2026-07-29 at 12 54
45 AM"
src="https://github.com/user-attachments/assets/4e14b3fb-fbe5-4f4d-a380-cc45cc60a01a"
/>
2026-07-30 09:36:38 +00:00
github-actions[bot] 9a1a057d8f i18n - docs translations (#23555)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23555?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-30 11:35:44 +02:00
Raphaël Bosi 40dd01c47d Document current front component limitations (#23549)
Front components are still under active development, but the docs did
not say so, and two of the three field reports we got on Discord were
misdiagnosed because the sandbox fails silently.

Adds a "Current limitations" section to the front components page
covering layout measurement, DOM access, events, CSS scoping, storage
and network, with the workaround for each. Also corrects the testing
page, which claimed front components get "browser APIs" when the sandbox
only implements a partial DOM.

Every limitation was checked against the code rather than copied from
the roadmap, which turned up a few stale entries: CSS imports work,
`aria-*`/`data-*` now cross, and `MutationObserver` throws on
`.observe()` rather than silently never firing.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23549?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-30 09:01:35 +00:00
Félix Malfait 0b335d15b3 Refresh billing state after ending trial period (#23534)
Fixes #23530

After adding a credit card in the billing prompt, the credits section
and subscription details stayed stale until a full page refresh.

The `endSubscriptionTrialPeriod` mutation only returned `status` and
`hasPaymentMethod`, and the frontend hook only patched the subscription
status into the workspace state. The credits query was never refetched,
so granted credits kept showing trial values, and `currentPeriodEnd`
(renewal date) and `billingCustomer.hasPaymentMethod` stayed outdated.
The backend already syncs everything to the database synchronously
before the mutation returns, so fresh data was available, just never
fetched.

Changes:
- `BillingEndTrialPeriodDTO` now includes nullable
`currentBillingSubscription` and `billingSubscriptions`, returned by the
resolver on success, mirroring the other billing update mutations
(`switchSubscriptionInterval`, etc.)
- `useEndSubscriptionTrialPeriod` applies the full billing update via
`useApplyCurrentWorkspaceBillingUpdate` (falling back to the previous
status-only patch), marks the billing customer as having a payment
method, and refetches `GetResourceCreditUsage` so the credits section
updates for any active observer

This covers all entry points that end the trial: the billing page card
modal, the trial banner, the AI chat banner, and the return from the
Stripe portal.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23534?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-30 07:53:26 +00:00
github-actions[bot] 8b707c5131 i18n - translations (#23547)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23547?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-30 09:21:19 +02:00
Thomas Trompette 4ff9cba76d fix(server): stop the global catch-all filter from shadowing typed GraphQL exception filters (#23508)
## Context

Sentry
[TWENTY-SERVER-60Y](https://twenty-v7.sentry.io/issues/6633503406)
("Permission Denied: Entity performing the request does not have
permission") is still firing at full rate on `v2.25.0`: ~10.8k events in
the last 7 days, 24k total.

#23104 tried to fix it by registering
`PermissionsGraphqlApiExceptionFilter` globally via `APP_FILTER`. That
registration is correct but **inert in production**, and the integration
test added alongside it passes for a reason unrelated to prod behaviour.

## Root cause

`main.ts` registered a catch-all filter after bootstrap:

```ts
app.useGlobalFilters(new UnhandledExceptionFilter());
```

Nest builds each resolver's filter list as `[...global, ...class,
...method]`, reverses it, and selects **exactly one** matching filter —
there is no chaining. `APP_FILTER` providers are collected during module
scan; `useGlobalFilters` appends after that, so the catch-all ended up
at the head of the list:

```
1. UnhandledExceptionFilter   @Catch()     <- matches everything, wins
2. PermissionsGraphqlApiExceptionFilter    <- never reached
3. BillingGraphqlApiExceptionFilter        <- never reached
```

On a GraphQL host `UnhandledExceptionFilter` then no-ops:
`host.switchToHttp().getResponse()` returns the GraphQL args object,
`response.header` is undefined, so it hits `return;`. Nest treats a
falsy return as unhandled and rethrows the original
`PermissionsException`, which reaches the Yoga error hook as a
non-`BaseGraphQLError`, is serialized `INTERNAL_SERVER_ERROR`, and is
reported by `shouldCaptureException`.

The 28 resolvers carrying
`@UseFilters(PermissionsGraphqlApiExceptionFilter)` were unaffected —
method-level filters are evaluated before globals. Only the resolvers
relying on the global registration leaked, which is exactly the set
showing up in Sentry (`findOneApplication`,
`uploadFilesFieldFileByUniversalIdentifier`,
`UpdatePageLayoutWithTabsAndWidgets`, ...).

Two other global filters were shadowed the same way and have never run:
`BillingGraphqlApiExceptionFilter` and
`FlatEntityMapsGraphqlApiExceptionFilter`.

## Why the existing test did not catch it

`test/integration/utils/create-app.ts` builds the app from `AppModule`
directly and never executes `main.ts`, so `useGlobalFilters` does not
exist in the test process. It registered
`MockedUnhandledExceptionFilter` as an `APP_FILTER` on the root testing
module, which is collected *first* and therefore evaluated *last* — the
exact inverse of production precedence. The `findOneApplication` denial
test passed while the same query kept reporting to Sentry.

## Fix

Register `UnhandledExceptionFilter` through `APP_FILTER` on `AppModule`.
Root-module providers are scanned first, so it is collected first and
evaluated last. The filter stays global, stays catch-all, and keeps its
CORS-header role for HTTP; it simply no longer cuts in front of the
typed filters.

Un-shadowing the other two global filters means they now actually run,
so `FileStorageExceptionFilter` and
`FlatEntityMapsGraphqlApiExceptionFilter` get the `host.getType() !==
'graphql'` rethrow that `Billing` and `Permissions` already had. Without
it they would start throwing GraphQL error objects into the REST
pipeline.

`MockedUnhandledExceptionFilter` is removed: `AppModule` now supplies
the real filter in the same position, so the mock was dead weight.

## Test

Verified against a real server (not the integration harness), calling
the exact document from Sentry event `8d19eb7c` as a member with no
permission flags:

```
query ($v1:UUID){findOneApplication(id:$v1){applicationVariables{key,value}}}
```

| | response code | exceptions captured |
|---|---|---|
| before | `INTERNAL_SERVER_ERROR` | 1 |
| after | `FORBIDDEN` | 0 |

Capture count measured through the console exception-handler driver,
i.e. the same `captureExceptions` call site that is the Sentry driver in
production.

New unit spec `src/filters/__tests__/unhandled-exception.filter.spec.ts`
boots a Nest + Yoga app both ways: it asserts `FORBIDDEN` with the
`APP_FILTER` registration, and pins the shadowing behaviour of
`app.useGlobalFilters` so the pattern cannot come back unnoticed.

`granular-settings-permissions.integration-spec.ts` passes (10/10). Note
it also passes *without* this fix — the harness cannot observe
bootstrap-only configuration, which is the underlying reason #23104
shipped green. Closing that gap properly means sharing the post-`create`
bootstrap between `main.ts` and `create-app.ts`; left as a follow-up.

`file-storage-exception-filter.spec.ts` extended with a non-GraphQL host
case.

## CI follow-up

`failing-file-by-id-download.integration-spec.ts` snapshots were
updated. That REST endpoint's 403 body changed in tests from `{}` to
`{"statusCode":403,"error":"Forbidden","message":"Forbidden resource"}`.

The old `{}` was an artifact of the mock:
`MockedUnhandledExceptionFilter` rethrew, the exception escaped Nest's
handler into Express's default error handler, and supertest saw an empty
body. Production has always run the real `UnhandledExceptionFilter`,
which writes `response.status(status).json(exception.response)` — the
new snapshot. Production HTTP behaviour is unchanged by this PR: no
other global filter matches an `HttpException` (the typed ones rethrow
outside GraphQL), so the same filter handles it whether it is evaluated
first or last.
2026-07-30 07:13:00 +00:00
Félix Malfait b643ddf119 Keep fullWidth buttons at full width while loading (#23536)
When a `Button` enters its loading state, the wrapper gets
`.wrapperLoading { max-width: calc(100% - 32px) }` to make room for the
spinner on auto-width buttons. `.fullWidth` only set `width: 100%`
without a max-width, so any full-width button shrank by 32px for as long
as its loader was visible, leaving a visible gap on the right. Most
noticeable on the "Add credit card" button in the billing modal while
the card is being validated.

Fix: `.fullWidth` now also pins `max-width: 100%`. It is declared after
`.wrapperLoading`, so it wins the cascade at equal specificity and the
loading shrink keeps applying only to auto-width buttons.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23536?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-30 07:31:24 +02:00
github-actions[bot] 33fb57d128 i18n - translations (#23525)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-29 19:46:09 +02:00
Thomas des Francs 030ee2c7cc Move settings tabs below page headers (#23519)
## Summary

- Position the Admin Panel tabs directly below the settings header.
- Position the Communication tabs directly below the settings header.
- Reuse the shared settings tab bar while preserving permissions,
disabled states, and hash navigation.
- Keep the responsive tab overflow menu available on narrow settings
cards.

## After

<img width="3456" height="2008" alt="Admin Panel tabs positioned below
the settings header"
src="https://github.com/user-attachments/assets/c8ff965c-d4b2-4700-85e1-0763f9f0852d"
/>
2026-07-29 17:37:01 +00:00
github-actions[bot] 58ebbe0394 i18n - docs translations (#23523)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23523?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-29 18:54:49 +02:00
Etienne a276f3277f feat(workflow): pin concrete model on AI agent node creation and exclude interactive tools from workflow runs (#23447)
## Context

The AI Agent workflow node's model dropdown could show a model that was
not the one used at run time (e.g. the node displayed "Claude Haiku 4.5"
while the run log showed `openai/gpt-5.6-sol`).

Root cause: workflow agents were created with `modelId:
AUTO_SELECT_SMART_MODEL_ID`. The builder's model `Select` cannot
represent that value — auto-select ids are filtered out of the options
(`useWorkspaceAiModelAvailability`) and the pinned "default" option
remaps its value to the resolved concrete model id (`useAiModelOptions`)
— so `Select` silently fell back to `options[0]`, the alphabetically
first enabled model. Meanwhile the runtime correctly resolved
auto-select to the instance's default smart model.

## What this PR does

### 1. New workflow agents store a concrete model id

`WorkflowVersionStepOperationsWorkspaceService` now reads the
workspace's `fastModel` setting, expands it through
`AiModelRegistryService.getEffectiveModelConfig`, validates it with
`validateModelAvailability`, and stores the concrete model id — so the
dropdown displays the model that will actually run, and workflow agents
default to the cheaper fast tier instead of the smart one.

Falls back to `AUTO_SELECT_FAST_MODEL_ID` if the lookup or validation
fails (workspace missing, no AI provider configured, model disabled), so
node creation never breaks.

### 2. Exclude `search_help_center` and `navigate_app` from workflow
agent runs

`ActionToolProvider` adds both tools unconditionally, but they only make
sense in an interactive chat session (navigation targets the user's
browser; help-center search is a support tool). They are now excluded
via `WORKFLOW_AGENT_EXCLUDED_TOOL_NAMES` in `AgentAsyncExecutorService`,
alongside the existing output-navigation exclusions. Chat agents are
unaffected.

## Test coverage

- Existing specs for `WorkflowVersionStepOperationsWorkspaceService` and
`AgentAsyncExecutorService` updated/passing (new constructor deps
mocked).
- `nx typecheck twenty-server` and `lint:diff-with-main` pass.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23447?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-29 16:48:24 +00:00
martmull c4a79c50c3 Install pre-installed apps in a dedicated job after the workspace upgrade cursor is written (#23517)
## Problem

Application registrations flagged `isPreInstalled: true` were not
installed on newly created workspaces.

The call was wired in, but it ran too early. `activateWorkspace` invoked
`preInstalledAppsService.installOnWorkspace` from inside
`prefillCreatedWorkspaceRecords`, which runs **before**
`activateAndInitializeUpgradeState`.

The install path validates app/workspace version compatibility:

- `ApplicationInstallService.runInstall` reads `engines.twenty` from the
app's `package.json` and calls `validateWorkspaceCompatibility`
- `ApplicationVersionValidationService.validateWorkspaceCompatibility`
resolves the workspace version through
`UpgradeStatusService.getWorkspaceCompletedVersion`
- that reads the workspace's upgrade-migration cursor, which is only
written by `markAsWorkspaceInitial` inside
`activateAndInitializeUpgradeState`

During creation the workspace has no cursor row yet, so
`getWorkspaceCompletedVersion` returns `null`, the install throws
`INVALID_WORKSPACE_VERSION`, and the failure is swallowed twice over:
`PreInstalledAppsService` logs per-app failures without rethrowing, and
`activateWorkspace` wraps the whole call in non-critical error handling.
The workspace comes up silently missing its apps.

This affects most real apps, since they pin `engines.twenty`:
`fireflies`, `last-contact`, `people-data-labs`, `call-recorder`,
`postcard`, `self-hosting`, `twenty-partners` (`>=2.23.0`) and `exa`,
`real-estate` (`>=2.19.0`). Only apps with no `engines.twenty` installed
successfully. The same interaction is already documented in
`2-23-workspace-command-1784565137000-upgrade-people-data-labs-application.command.ts`,
which works around it with `skipWorkspaceCompatibilityCheck: true`.

## Changes

- Added `InstallPreInstalledAppsJob` on the workspace queue, mirroring
the existing `InstallOnboardingAppsJob`.
- `activateWorkspace` now enqueues that job instead of installing
synchronously, so workspace creation no longer blocks on package
fetching and manifest application.
- The enqueue happens after `activateAndInitializeUpgradeState` writes
the upgrade cursor, so the compatibility check has a workspace version
to resolve by the time the worker picks the job up.

## Notes

Workspaces created before this fix can be repaired with the existing
`install-pre-installed-apps` backfill command, which is idempotent.
2026-07-29 16:30:00 +00:00
Raphaël Bosi ada7eb1d88 Restore the Figma halftone shapes and calm the welcome animation (#23495)
https://github.com/user-attachments/assets/0c06d190-977e-4ad6-8a02-251f341ee310



The welcome overlay settled into circles instead of the halftone from
Figma: the densify step that took the dot set from 681 to 2897 emitted
points, so every dash had zero length. All 681 dashes in the Figma
export (`public/images/onboarding/welcome-halftone.svg`) share a
constant length / stroke width ratio of 1.452, so the shape is restored
by deriving the length from the stroke width, with no data regeneration.
Dashes now stretch into shape while they are still flying in, rather
than popping once they have landed.

The rest of the pass makes the animation quieter. The shine sweep is
gone, along with the highlight colour that only fed it. Particles
approach from much closer on a gentler ease, the idle drift is roughly
halved, and the exit is a soft outward drift instead of a burst that
threw everything off screen. The white pill behind the title and the
person chip's surface are removed too, so the title reads directly
against the halftone.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23495?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-29 16:14:46 +00:00
Raphaël Bosi 684259fd5e Make onboarding cards contrast with the page background (#23516)
Onboarding card surfaces used `background.secondary`, the same token as
the onboarding page background, so they blended in (visible on the
workspace selection step).

Switched them to `background.primary`, matching the other onboarding
cards (plan card, install apps, trust badges).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23516?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-29 16:04:45 +00:00
Thomas Trompette 58b8e8afee Fix clipped New chat button label in localized navigation drawer (#23511)
Fixes #23503

<img width="161" height="76" alt="Capture d’écran 2026-07-29 à 17 20
25"
src="https://github.com/user-attachments/assets/a6ea0a12-b91d-419f-8023-8e65d5dce65b"
/>

The expanded "New chat" button had a hardcoded `width: 103px`, sized for
the English label. Localized labels ("Neuer Chat", "Nouveau chat") were
clipped, since `OverflowingTextWithTooltip` can only truncate inside a
parent it cannot resize.

The expanded wrapper is now `width: max-content` with `max-width: 100%`,
so it grows with the label and only truncates (with tooltip) when the
sidebar has no space left. `min-width` keeps the pill from collapsing
below icon size. Collapsed state is unchanged.

### Verified locally (German locale)

| | wrapper width | label |
|---|---|---|
| before | 103px (fixed) | `Neuer C...` clipped |
| after | 109px (max-content) | `Neuer Chat` in full |

- English: 103px -> 98px, visually identical.
- Collapsed drawer: still exactly 24x24, unchanged.
- Very long label (Vietnamese-length): wrapper stops at the sidebar
edge, no overflow, label truncates with tooltip.
2026-07-29 15:24:45 +00:00
github-actions[bot] 8707ebb7ac i18n - docs translations (#23515)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-29 17:23:57 +02:00
nitin e5c6cbcf80 Resolve route-trigger workspace from bearer token on bare hosts (#23490)
When a request reaches the `/s` route on a host that names no workspace
(bare `SERVER_URL` on a multiworkspace instance), resolve the workspace
from the bearer token — the same source `/graphql` uses — instead of
failing with `WORKSPACE_NOT_FOUND`. Hosts that do name a workspace keep
host resolution unchanged, and requests without a token are unaffected.

This makes the client SDK's same-site `${apiBase}/s` fallback work on
multiworkspace instances without a configured public domain: app logic
functions calling their own HTTP routes (e.g. call-recorder artifact
import) currently 404 there, because `TWENTY_FUNCTIONS_URL` is injected
empty and the bare server host carries no workspace identity. Cloud
(workspace public origin injected) and single-workspace self-host (host
resolves the default workspace) never hit this path.

Note: this also allows public routes to be reached through a bare host
when a valid token identifies the workspace. It does not change route
authorization; the token is used only for workspace resolution.
2026-07-29 14:56:28 +00:00
github-actions[bot] cedb3768ec i18n - translations (#23513)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23513?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-29 16:55:13 +02:00