Commit Graph

5269 Commits

Author SHA1 Message Date
Thomas Trompette 79bf20515d feat(workflow): mirror version delete/restore/destroy to core transactionally (#23356)
Next step in the workflowVersion -> core soft-ref migration. The
transactional mirror (#23243) made **content** writes (create/update)
drift-free. This does the same for the **lifecycle** events (delete /
restore / destroy), which were still handled only by the async
best-effort listener. It's the prerequisite for dropping that listener.

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

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

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

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

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

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


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

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

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

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

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

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

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

## What changed

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

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

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

## Why this is safe

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

## Expected impact

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

## Tests

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

Validated with:

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

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

## Fix
reuse same OAuth client instance

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



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

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

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

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

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

---------

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

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

```mermaid
sequenceDiagram
  participant Browser
  participant Server
  participant DB

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

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

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

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

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

---------

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

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

## Fix

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

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

---------

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

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

## Root cause

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

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

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

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

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

## Fix

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

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

## Verification

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

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


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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## What changes

The core data source now applies:

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

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

## Expected impact

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

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

## Safety

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

## Scope

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

## Testing

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

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

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

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

## What changes

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

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

## Before and after

Before:

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

After:

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

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

## Why deduplicate in memory

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

This is a deliberate tradeoff:

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

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

## Safety

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

## Scope

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

## Testing

Added unit coverage for:

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

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

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

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

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

## Checksum coupling

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

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

## sharp deliberately excluded

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

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

## Changes

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

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


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

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

---------

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

- Add `displayName`
- Unsubscribers Page

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


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

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-07-26 22:48:30 +02:00
github-actions[bot] 763d31a859 chore: sync AI model catalog from models.dev (#23298)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

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

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

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

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

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

## Checksum coupling

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

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

## Deliberately not covered

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

## Alerts

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

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

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

## What changed

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

## Test plan

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

## Post-deploy

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

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

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

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

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

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

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

## Approach

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

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

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

## Changes

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

## Notes / follow-ups

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

## Test plan

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

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

## Root cause

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

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

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

## Fix

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

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

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

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

## Testing

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


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

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

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

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

## Changes

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

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

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

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

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

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

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

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

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

## What

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

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

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

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

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

## Deliberate view-mode changes (approved)

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

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

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

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

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



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

## Root cause

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

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

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

## Why it happens

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

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

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

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

## Fix

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

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

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

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

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

---------

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

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

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

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

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

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


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

---------

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

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

### Removed

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

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

### application:upgrade command

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

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

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

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

### Application kill switch (per review)

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

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

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

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

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

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

## Test

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

---------

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



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

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

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

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

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

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

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

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

ClickHouse inserts already use:

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

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

## What this changes

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

## Why this approach

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

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

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

## Expected impact

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

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

## Testing

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

## Post-deploy verification

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

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

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

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

## Changes

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

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

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

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

## Problem

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

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

The frontend shows "Unable to reach the backend".

## Root cause

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

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

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

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

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

## Fix

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

## Notes

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


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

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

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

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

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

## What this changes

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

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

Conceptually:

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

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

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


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

---------

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

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

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

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

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

## Server

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

## Frontend

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

## Scope

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

## Tests

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

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

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

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-24 12:01:53 +02:00
martmull f793f3c5a9 Fix application logos resolving to null on install and sync (#23245)
## Problem

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

## Root cause

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

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

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

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

## Fix

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

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

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

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

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

## What this PR does

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


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

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

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

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

## Behavior

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

## Validation

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

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

Session: https://claude.ai/code/session_01TL6Te48Lkys5NxyG9j2Nz6
2026-07-24 10:45:16 +02:00
martmull fb52635d2a Add defineUninstallLogicFunction hook for applications (#23227) 2026-07-24 10:41:22 +02:00
Rehuz 7f1e3d3541 fix(admin): prevent fallback to 'latest' string when dockerhub tags c… (#22885)
Fixes #22849 

### What changed?
When the `AdminPanelVersionService` hits a DockerHub API error (or
filters out all valid tags), it was previously hardcoded to return
`'latest'`. This caused the frontend to display `Latest version:
latest`.

I updated the GraphQL DTO to make `latestVersion` nullable, and modified
the service fallback and unit tests to return and expect `null` instead.

The frontend (`SettingsAdminVersionDisplay`) already has logic to handle
a falsy version and gracefully display `No latest version found`, so
this backend fix entirely resolves the UX issue without touching the
frontend.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22885?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 09:25:46 +02:00
Abdul Rahman 148dc6dfaa Let server route resolvers answer the caller synchronously (#23233)
## Problem

A server route resolver can only return a dispatch target (`{
workspaceId, targetLogicFunctionUniversalIdentifier, payload }`), and
`ServerRouteTriggerService` always acks `202 {queued:true}`. The target
function runs off the queue, after the response has been sent, so its
return value can never reach the caller.

That makes it impossible to integrate a provider whose webhook URL has
to be proven with a handshake on the same response. Slack's Events API
is the case that surfaced it: `url_verification` sends `{ type,
challenge }` and will not accept the Request URL unless the challenge
comes back on that POST.

## Change

A resolver may now return a `Response` (the existing
`LogicFunctionHttpResponse`) instead of a dispatch target. The route
sends it as-is via `buildRouteTriggerResponse` and enqueues nothing.

- Reuses the marker and builder that HTTP route triggers already use, so
there is no new response shape.
- Dispatch results behave exactly as before; the resolver error path is
unchanged, just hoisted out of `parseResolverResult` so it runs before
the branch.
- SDK: `ServerRouteResolverResult` becomes `ServerRouteDispatchResult |
LogicFunctionHttpResponse`.

Additive: a resolver that returns a dispatch target sees no behavior
change. Previously, returning this shape threw
`RESOLVER_INVALID_RESULT`.

## Testing

`server-route-trigger.service.spec.ts` gains a case asserting the
resolver's response is sent verbatim and nothing is enqueued. 15/15
pass.

## Context

Split out of #22984 (Slack conversational assistant), which needs this
to complete the Slack Events URL verification. That PR depends on this
one merging first.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23233?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 08:56:34 +02:00
github-actions[bot] 5bc97f8591 chore: sync AI model catalog from models.dev (#23242)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-24 08:47:03 +02:00
github-actions[bot] 34c2e11dcb i18n - translations (#23230)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23230?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-24 02:37:41 +02:00
Abdul Rahman 2c79093b74 feat: agent roleUniversalIdentifier for manifest-driven role assignment (#23206)
## Summary
- Adds optional `roleUniversalIdentifier` on `AgentManifest` /
`defineAgent` so apps can declaratively assign a role to an agent (same
config shape as `defaultRoleUniversalIdentifier`).
- Wires `agentUniversalIdentifier` as a sync many-to-one FK on
`roleTarget`, and emits a deterministic `roleTarget` from the agent
during app sync (create / update / delete).
- Enables app agents (e.g. Slack assistant) to get a role on install
without postInstall hooks or manual admin assignment.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23206?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 05:57:40 +05:30
neo773 59eead238d message list member backfill (#23176)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23176?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 00:55:46 +05:30
twenty-pr[bot] 6623901eb4 chore: bump version to 2.25.0 (#23221)
## Summary

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

## Checklist

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23221?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 Action Deploy <github-action-deploy@twenty.com>
2026-07-23 19:50:34 +02:00
Paul Rastoin b91c2a6457 fix(server): repair missing applicationRegistration.logoFileId on upgraded instances (#23215)
## Problem

closes https://github.com/twentyhq/twenty/issues/23210

Self-hosted instances on 2.23.x fail their workspace upgrade with:

```
column ApplicationEntity__ApplicationEntity_applicationRegistration.logoFileId does not exist
at UpgradePeopleDataLabsApplicationCommand.runOnWorkspace
```

The `2-21` instance command that adds
`core."applicationRegistration"."logoFileId"` was merged ~20 minutes
after the 2.22 version bump (PR #22827, `94192a2164`), so it first
shipped in 2.22 while registered under
`@RegisteredInstanceCommand('2.21.0', ...)`.

The upgrade runner resolves its start position from the last recorded
command and only moves forward. Any instance that had already run a
2.21.x binary has its cursor past that slot, so the command is skipped
permanently and the column is never created.
`UpgradeAwareEntityMetadataAdapter` decides column visibility
positionally (`index < currentCursor`), not by whether the command
actually ran, so it keeps `logoFileId` in the SELECT list and the
instance reports "Up to date" while the column is absent.

**Affected:** instances that ran 2.21.x, then upgraded to >= 2.22.
Instances that went from <= 2.20 straight to >= 2.22 replayed the full
sequence and are fine.

`logoFileId` is populated lazily by design (NULL is a supported state),
so no backfill is added.

## Changes

**1. Idempotent DDL guard in the failing workspace command**

`2-23-workspace-command-...-upgrade-people-data-labs-application.command.ts`
now ensures the column exists at the top of `runOnWorkspace`, before the
`findOne` that crashes on affected instances. It uses the core
`DataSource` (`@InjectDataSource()`) because
`core."applicationRegistration"` is instance-global, guards with a
per-process boolean in addition to the SQL-level `IF NOT EXISTS`, and
copies the full statement list (column + unique + FK constraints)
verbatim from the 2.21 command. In dry-run it probes
`information_schema.columns` and returns instead of running the crashing
query.

**2. Fast instance command in 2.23**
New
`2-23-instance-command-fast-1784823473532-add-logo-file-id-to-application-registration.ts`,
registered at the end of the 2.23 fast segment (highest timestamp),
running the same idempotent DDL. This covers the normal 2.22 -> 2.23
path and, critically, instances with zero provisioned workspaces where
the workspace command body never executes. The shared DDL lives in
`2-23/utils/ensure-application-registration-logo-file-id-column.util.ts`
so both paths stay byte-for-byte identical. Class name follows the
`Early2_4` / `Early2_5` precedent to avoid colliding with the 2.21
command.

The fix lives entirely in 2.23: instances stuck at the failing workspace
command retry it every run, and 2.22 -> 2.24 jumps still replay the 2.23
segment.

## Ops note

Instances failing right now can be unblocked immediately by running the
same `ALTER TABLE` block by hand against their core database
(byte-for-byte what the command does). Worth including in the 2.23 patch
release note.

## Verification

- New fast instance command re-slotted last in the 2.23 fast segment
(timestamp `1784823473532` > current max `1784659343818`).
- Manual repro path: boot `twentycrm/twenty:v2.21`, seed, stop, run
`upgrade` from this branch, assert the column exists and
`upgrade:status` reports 0 failed. The default v1.22 baseline does not
reproduce it (replays from cursor 0).

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23215?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: Paul Rastoin <paul.rastoin@gmail.com>
2026-07-23 17:12:32 +00:00
Thomas Trompette 5160415f40 fix(workflow): create core mirror rows during workflow prefill (#23204)
## Problem
Seeded workflows are inserted during workspace activation (and dev
seeding) via raw SQL in `prefill-workflows.util.ts`, which bypasses the
ORM entirely. The async dual-write listener that mirrors `workflow` /
`workflowVersion` into `core.workflow` / `core."workflowVersion"` never
fires for these rows, so every newly activated workspace is born with:
- no core mirror rows, and
- NULL `coreWorkflowId` / `coreWorkflowVersionId` soft-refs.

This is permanent, growing drift. The core-consistency check reports
every new workspace as 2 unlinked workflows + versions, and once trigger
dispatch reads from core these seeded workflows would silently stop
working.

## Change
Insert the `core.workflow` and `core."workflowVersion"` mirror rows and
stamp the soft-refs inside the same prefill transaction. Field mapping
mirrors the dual-write / backfill exactly:
- `core."workflowVersion".workflowId` = the workspace workflow id (what
the trigger-map cache groups by)
- `triggers` = jsonb `[trigger]`
- `applicationId` = `workspace.workspaceCustomApplicationId` (throws if
missing, same contract as the sync path)
- `core.workflow.lastPublishedVersionId` = the workspace version id
- one ACTIVE core version per workflow (satisfies the partial unique
index)

Core ids are deterministic v5 (same helper/namespace as the existing
prefill ids) so the existing `.orIgnore()` re-run guard stays idempotent
— a re-run hits the PK conflict and is skipped instead of inserting
duplicate orphan core rows.

The large diff is mostly re-indentation: the step/trigger arrays were
extracted to consts so the same objects feed both the workspace and core
inserts. The step/field UUID literals are unchanged from main.

## Verification
- `nx lint:diff-with-main twenty-server`: clean.
- Typecheck (isolated `tsc`): no errors in the changed file (`nx
typecheck twenty-server` is blocked by a pre-existing `twenty-shared`
build error on main, unrelated).
- Runtime: pending a `database:reset` + cross-schema parity query
confirming zero drift on a freshly seeded workspace.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23204?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-23 16:15:05 +00:00
martmull 4d09c400a4 Remove DATABASE_EVENT_JOBS_CHUNK_SIZE and Promise.all from logic function trigger jobs (#23205)
## What

- `LogicFunctionTriggerJob` now processes a single
`LogicFunctionTriggerJobData` payload instead of an array processed with
`Promise.all`.
- Removed `DATABASE_EVENT_JOBS_CHUNK_SIZE` and the `lodash.chunk` usage
in `CallDatabaseEventTriggerJobsJob`.
- Added `bulkAdd` to `MessageQueueService` and both drivers (BullMQ
driver uses native `queue.addBulk`, sync driver processes payloads
sequentially). `CallDatabaseEventTriggerJobsJob` uses it to enqueue all
payloads in one call.
- Updated the other producers (`ServerRouteTriggerService`,
`ApplicationInstallService`, `ConnectionProviderOauthFlowService`,
`CronTriggerCronJob`) to enqueue a single payload instead of a
one-element array, and updated the corresponding specs.

## Why

Each logic function execution now gets its own queue job, so a failing
execution only retries itself instead of re-running the whole chunk, and
job-level retry/metrics apply per execution.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23205?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-23 17:50:43 +02:00