Commit Graph

14232 Commits

Author SHA1 Message Date
github-actions[bot] 435bb54b97 i18n - docs translations (#23878)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 17:20:43 +02:00
nitin 3cbcf999d6 Add call recording transcript and summary widget types (#23864)
Registers two record-page widget types for call recordings:
CALL_RECORDING_SUMMARY and CALL_RECORDING_TRANSCRIPT.

- Widget type and configuration type enums, configuration DTOs, and
creation/update validators
- Core pageLayoutWidget_type_enum migration (2.29 fast instance command)
- Shared configuration types and regenerated metadata client schemas

Widget components and their placement on the callRecording record page
layout come in a follow-up PR.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23864?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-08-06 15:10:53 +00:00
Charles Bochet a47f566eb1 fix(twenty-server): notify SSE subscribers when an update leaves their filtered view (#23858)
## Context

Record tables subscribe to SSE with their query signature (object +
filter) and the server pushes only matching DB events.
`isQueryMatchingObjectRecordEvent` evaluated `after ?? before` — for
UPDATED events that is always `after`, so an update moving a record
**out** of a filtered view never matched: no event, and the open table
keeps the stale row until a manual reload.

Symptom on twenty-internal: a Sales Action Item marked Done by an
AI-chat tool (or any API/workflow write) stays visible in the `status =
OPEN` view. Records *entering* a view appear live; records *leaving*
never disappear. UI edits mask the bug via the local Apollo cache.

## Fix

For UPDATED events, view membership now matches on either snapshot (a
before-only match = the record just left the view). Row-level
authorization is unchanged: still evaluated against the delivered
snapshot, so a before-state match cannot authorize a payload the
subscriber lost RLS access to.

A pre-existing, unrelated payload leak spotted during review (before
values delivered when a record enters RLS scope) is fixed separately in
the stacked #23870.

## Test plan

- Unit: leave-view update publishes (fails on the old matcher —
verified), neither-state-matches does not, RLS-failing delivered state
does not even when before matched. 36/36 on the spec, full server suite
green.
- End-to-end on a local stack: companies table filtered `Name contains
'Open'`, `updateCompany` renamed a row out of the filter via the API →
row disappeared from the open table within seconds, no reload.
2026-08-06 15:02:26 +00:00
Charles Bochet f5a42cdbae Give AWS SDK clients an explicit request timeout (#23857)
## Why

The AWS SDK defaults `requestTimeout` to `0`, which means *no timeout* —
see `DEFAULT_REQUEST_TIMEOUT` in `@smithy/node-http-handler`. None of
our clients overrode it, so a request that never completes holds its
socket forever. Once that happens to `maxSockets` requests (default 50),
the client's connection pool is permanently exhausted and every
subsequent call on that process queues indefinitely rather than failing.
To the caller it is indistinguishable from a hang.

This caused a production incident on 2026-08-06. A single
`twenty-server` pod reached:

```
@smithy/node-http-handler:WARN - socket usage at capacity=50 and 1004 additional requests are enqueued.
```

and never recovered — the queue grew monotonically and the pod completed
**zero** Lambda invocations over 12 hours while its six siblings
completed dozens each. Egress was fine (a direct HTTPS call to the
Lambda API returned in 53ms) and the pod was never OOM-killed or
restarted, so nothing surfaced as an error anywhere.

The user-visible effect was that workspace creation hung. Activation
reached `synchronizeTwentyStandardApplicationOrThrow`, blocked on a
Lambda call, and never returned or threw — so the `catch` in
`activateWorkspace` that resets a workspace to `PENDING_CREATION` never
ran, and the workspace was stranded in `ONGOING_CREATION`. Retrying
didn't help because the pod was still poisoned. With one bad pod out of
seven, roughly one signup in seven failed:

| pod | activations started | completed |
|---|---|---|
| healthy × 5 | 13 | 13 |
| poisoned | 3 | **0** |

Nothing appeared in Sentry, because nothing ever threw.

## What this changes

- **Every AWS SDK client now sets `connectionTimeout` and
`requestTimeout`** via a shared `buildAwsRequestHandlerOptions()`
helper. A saturated pool now surfaces as an ordinary error the caller
can catch and retry instead of hanging forever. This is the fix that
matters.
- **The Lambda client gets a higher ceiling and a larger socket pool.**
Synchronous invocations legitimately hold a socket for as long as the
function runs, so its `requestTimeout` clears
`EXECUTOR_LAMBDA_TIMEOUT_SECONDS` (900s) with a minute to spare, and
`maxSockets` goes to 200 so long invocations cannot starve the
control-plane calls (layer lookups, waiters) sharing that client.
- **`S3Client` and `STSClient` in `LambdaAwsClientService` are now
reused.** Both were constructed on every call and never destroyed, so
each leaked its own agent and socket pool. They are invalidated
alongside `lambdaClient` when assume-role credentials refresh.

No new dependency: `requestHandler` already accepts a plain
`NodeHttpHandlerOptions` object.

## Deliberately not in scope

- **A timeout around `activateWorkspace` itself.** A hung activation
still strands a workspace in `ONGOING_CREATION` until the 5-minute
stale-lock reclaim, and that only fires if the user happens to retry.
Worth fixing separately.
- **Alerting on `socket usage at capacity`.** That warning was the only
signal this was happening and nobody was watching it — an infra change
rather than a code one.

## Testing

- Unit tests for the helper, including that the timeout is always
non-zero.
- `tsc --noEmit` clean on the touched files; `oxlint` reports 0 warnings
and 0 errors.
- Not reproducible in a test environment — the leak needs a saturated
pool — so the mechanism above is evidenced from production logs and the
SDK's own defaults rather than from a regression test.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23857?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-08-06 15:02:05 +00:00
Abdul Rahman 577cd68cb5 feat(slack): release the Slack team claim when the connection goes away (#23753)
Closes the loop on
https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334,
where the Slack app claimed a `team_id` on connect and never released
it. The `onDisconnect` hook it waited on merged in #23538, and
`twenty-sdk` 2.27.0 ships the `onDisconnectLogicFunction` manifest
field, so this bumps the app to it.

## Disconnect

`slack-team-release` releases the server-scoped `slack-team:{teamId}`
claim, so another Twenty workspace can connect that Slack team
afterwards. `kv.delete` on a SERVER key only clears a claim the calling
workspace owns, and the result reports a team only when that delete
actually removed something.

The connection is already deleted when the hook runs, so `getConnection`
cannot resolve the `team_id` anymore. `claimSlackTeam` records `teamId`
under a workspace-scoped
`slack-connected-account-team:{connectedAccountId}` key, written before
the claim itself so a failure between the two cannot leave a claim
nothing can resolve, and the release reads it back from there. That also
retires the TODO the review comment pointed at.

The release is skipped when a live connection still maps to the same
team, excluding the disconnecting account explicitly. That covers a
reconnect that re-claimed the team while the job was queued, and a
second connection to the same Slack workspace, whose claim would
otherwise have been dropped along with the first. The recorded team is
cleared either way, so the disconnecting account leaves nothing behind.

## Uninstall

Uninstalling the app drops its connections through the
`connectionProvider` and `application` cascades, which never reach the
disconnect hook, and the same operation deletes the app&#39;s logic
functions so the hook could not run anyway. The app declares an
`uninstallLogicFunction`: it runs before the app&#39;s metadata is
deleted, lists the remaining Slack connections, and releases each claim
through the same `releaseSlackTeamClaim` util. It deliberately skips the
live-connection check, since at that point every connection is still
listed.

## Known limits

A disconnect immediately followed by a reconnect has a window where the
queued release can clear a claim the reconnect just took. It self-heals,
because the reconnect&#39;s own onConnect re-claims the same key moments
later. Closing it properly needs a compare-and-set on the claim, which
the key-value store does not expose.

No unit tests are added for the release path. Earlier revisions of this
branch had specs for the disconnect guard, the uninstall sweep and the
no-op-release result; they were dropped, so those branches carry no
regression coverage. The app&#39;s existing 34 tests still pass, along
with lint, typecheck and `twenty dev:build`, the last being what used to
fail with `Duplicate universal identifiers` on the older SDK.
2026-08-06 13:57:33 +00:00
github-actions[bot] 5f9419589e i18n - translations (#23873)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 15:55:49 +02:00
Charles Bochet a7324252fd fix(twenty-server): stop the yarn-install Lambda from running out of memory (#23805)
## Context

Sentry issue
[7438578272](https://twenty-v7.sentry.io/issues/7438578272/) (Logic
Function Layer Build Failed, 7.9K events over 3 months): the
yarn-install tool Lambda dies with `Runtime.OutOfMemory` / `signal:
killed` while building an application's dependency layer. Every layer
build for the affected application fails permanently, each
database-event trigger re-attempts it, and one workspace produced ~2.5K
events in the last week alone.

## Root cause

The offending application declares `twenty-ui@1.0.0-alpha.0` (181MB
unpacked, dragging in 141MB of `@tabler/icons*`) and dev tooling as
production `dependencies` of server-side logic functions. Installing
that tree needs just under 3GB during Yarn 4's fetch/link phase, so the
1024MB sandbox is OOM-killed. And even a successful install could never
ship: AWS caps a function plus all its layers at 250MB unzipped.

The user never sees any of this: the OOM is retried forever, and nothing
tells them their dependencies are the problem.

## Fix

1. **Raise the yarn-install Lambda to 4096MB** so legitimate dependency
trees install. Tool function names now include the
memory/timeout/ephemeral-storage constants in their content hash, so a
config change rotates the function name and the ensure path creates a
fresh function with the new configuration — without this, the constant
change would never reach already-deployed functions (their config is
only applied at creation, and the ensure path early-returns when the
function exists).
2. **Propagate Lambda's own errors to the user.** The install OOM
(`Runtime.OutOfMemory` on the invoke) and the layer size rejection
(`InvalidParameterValueException` at `PublishLayerVersion`) map to a new
`LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED` code telling the user to
move packages their logic functions don't import out of `dependencies`.
Surfacing per API boundary:
- **Sync / install (CLI)**: the workspace migration interceptor formats
it into the same metadata validation error shape the SDK already
renders, as one `logicFunction` entry carrying the remedy and the
underlying AWS detail — no SDK rendering changes needed.
- **`executeOneLogicFunction`**: mapped to `UserInputError` in the
GraphQL handler.
- **Route triggers**: HTTP 422 with the user-facing message, no Sentry
capture.
- **Background triggers**: skip instead of retrying, since no retry can
succeed until the user changes their application.

## Test

The error originates in AWS behavior, which CI (local driver, no AWS)
cannot reproduce — so the chain is verified link by link:

- **Real AWS, manual (not in CI)**: reproduced with the offending
application's actual package.json against real Lambdas in the dev
account — OOM-killed at 1024MB and 2048MB (exact prod error signature),
install succeeds at 4096MB (~4min), and the resulting 292MB layer is
rejected by `PublishLayerVersion` with the exact
`InvalidParameterValueException` this PR matches. Same matrix reproduced
in local cgroups beforehand.
- **Server unit specs**: AWS error payload → exception mapping
(`build-yarn-install-failure-exception`), exception → validation payload
formatting (interceptor handler), `executeOneLogicFunction` GraphQL
mapping, route filter 422 mapping, tool-function/layer name hashing.
- **SDK integration spec (mocked server)**: runs the real `app dev`
orchestrator on the minimal app with `syncApplication` mocked to return
the validation-shaped failure, and asserts the CLI report renders the
error code and remedy. It covers CLI rendering only — no test installs
actual oversized dependencies, by design.
- Docs updated (dependency size limits, sync failure taxonomy, route
platform error responses).
2026-08-06 13:45:37 +00:00
Charles Bochet c7cfd143c7 fix(twenty-front): keep auto-select model preselection instead of discarding it (#23854)
## Context

Opening the Ask AI panel with a FAST model preselection
(`useOpenAskAiPageWithPreprompt`) sets the chat's model to the
workspace's `fastModel`. For every workspace on default settings that
value is the auto-select sentinel `default-fast-model`.

## Bug

`useAgentChatModelId` validates the selected model against the
enabled-models list — and `useWorkspaceAiModelAvailability` deliberately
filters sentinel ids out of that list. The preselection is therefore
silently discarded (`selectedModelId = null`), the request is sent with
no model, and the server falls back to its default — the **smart**
model. Net effect: FAST preselection no-ops on default-configured
workspaces (observed on twenty-internal: a `model: 'FAST'` entry point
ran on gpt-5.6-sol instead of gpt-5.6-luna).

## Fix

Treat auto-select sentinel ids as always available in the check — the
server-side registry already resolves them (`getEffectiveModelConfig` →
`getDefaultSpeedModel`). One line + a regression test.

## Test

`useAgentChatModelId.test.tsx`: new case asserting a sentinel selection
survives to `modelIdForRequest`; all 4 pass.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23854?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-08-06 13:38:37 +00:00
Thomas Trompette b2684ce107 Clear to-one relation key instead of storing {id: null} in record workflow steps (#23869)
Deselecting a to-one relation in a Create Record / Upsert Record step
stored `{ "id": null }` instead of removing the field from the step
input. The step form then renders the field as empty, so nothing signals
that a value is still there, and the run fails later with:

```
Relation "idOpportunity" requires connect or disconnect operation
```

### Why

The relation picker fires `onChange(null)` when the current selection is
dropped (`FormSingleRecordPicker`, "No record" entry).
`handleFieldChange` wraps every to-one relation value as `{ id: value
}`, so `null` became `{ id: null }` and got persisted in `objectRecord`.

At runtime that shape is not a legacy `{ id: "<uuid>" }`, so it is left
untouched by `formatWorkflowRecordRelationFields` and reaches the common
API data arg processor, which rejects any relation value that is not a
`connect`/`disconnect` operation.

The field also reads as empty afterwards (`formData[field]?.id` is
`null`), so the poisoned state is indistinguishable from a clean one in
the UI, and the ✕ that would have cleared it properly is not rendered.

### Fix

Treat a cleared to-one relation as a field removal in both record forms,
matching what the chip's ✕ (`handleFieldClear`) already does. The logic
lives in `buildUpdatedRecordActionFormData`, shared by both components
along with the `RecordActionFormData` / `RelationManyToOneField` types
they each declared separately.

Update Record is unaffected: it stores relations under the join column
(`pointOfContactId`) with a raw value, where `null` is a valid
disconnect.

### Test

Manually, on a Companies Create Record and Create or Update Record step,
using the `Account Owner` relation: pick a record, then pick "No record"
in the same dropdown, and read the persisted step from
`workflowVersion.steps`.

| | `objectRecord` after "No record" |
|---|---|
| before | `{"accountOwner": {"id": null}}` |
| after | `{}` |

Selecting a record still stores `{"accountOwner": {"id": "<uuid>"}}`.
2026-08-06 13:36:26 +00:00
twenty-pr[bot] 8699766303 chore: bump version to 2.29.0 (#23820)
## 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/23820?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-08-06 12:49:37 +00:00
Félix Malfait c7f443662f Escape JSON-LD payloads before inlining them in a script tag (#23865)
Fixes the one code scanner alert of the three that turned out to be a
real vulnerability.

## The problem

`JsonLd` inlined `JSON.stringify` output straight into a `<script>`
body:

```tsx
<script dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} type="application/ld+json" />
```

The HTML parser scans a script body for `</script` and `<!--` before any
JSON parsing happens, so a string value containing `</script>` closes
the tag and everything after it is parsed as markup. `JSON.stringify`
does not escape it.

## Why it is reachable

The breadcrumb payloads are not all static. Two values come from outside
the repo:

- `app.name` on `/apps/[slug]` — served by the marketplace API, which
syncs it from the `displayName` field of an npm package manifest
(`marketplace-catalog-sync.service.ts`).
- `partner.name` on `/partners/profile/[slug]` — served by the partners
API from partner-submitted profiles.

Listing and vetting gate both, but that is a human review step, not an
escaping control. There is no CSP backstop either: `next.config.ts` only
sets `frame-ancestors 'none'`, no `script-src`.

Everywhere else these names render as React text and are escaped. This
was the only raw sink in `twenty-website`.

## Verification

Rendered the exact markup the component emits in headless Chromium with
a name of `Evil App</script>``&lt;img src=x onerror=...&gt;'``:

- before: the `ld+json` block is terminated early, an `<img>` element is
created, and the handler runs (page title changes).
- after: the script block stays intact, no element is created, and
`JSON.parse` of the payload deep-equals the input.

## The fix

Escape `<`, `>` and `&` as JSON unicode sequences (`<` and friends).
They parse back to the identical string, so consumers see unchanged
structured data, but nothing in the payload can start a tag or a
comment.

U+2028/U+2029 are deliberately not escaped: they matter when a payload
lands in a JavaScript context, and this one is parsed as JSON. Leaving
them out keeps the source pure ASCII rather than carrying invisible
separators.

Covered by unit tests for the breakout attempt, the comment-opening
case, round-trip equality, and the untouched-payload case.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23865?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-08-06 14:36:13 +02:00
Félix Malfait f4d5500fc4 fix(front): run a single Monaco instance across the app (#23855)
## Problem

Three Sentry issues, all `Missing requestHandler or method: <method>`,
first seen in v2.27.0:

| Method | Page | Events |
|---|---|---|
| `findDocumentColors` | `/settings/mcp-apis` | 170 |
| `resetSchema` | `/settings/mcp-apis` | 22 |
| `getCodeFixesAtPosition` | `/object/workflow/…` | 4 |

The first two are Monaco **JSON worker** methods, the third a
**TypeScript worker** method. All of them bottom out in
`monaco-editor/esm/vs/editor/common/services/editorSimpleWorker.js` at
`$fmr` — the foreign-module dispatcher — with `_foreignModule` still
`null`.

## Root cause

Two Monaco copies end up on the page at **different versions**:

1. **ESM `monaco-editor@0.52.2`**, bundled by Vite — what GraphiQL 5
uses.
2. **AMD `monaco-editor@0.55.1` from jsDelivr** — `@monaco-editor/react`
→ `@monaco-editor/loader@1.7.0`, whose default CDN path is hardcoded to
`monaco-editor@0.55.1/min/vs`. Nothing calls `loader.config({ monaco
})`, so it goes to the CDN.

`setupGraphiqlMonacoWorkers.ts` assigns
**`globalThis.MonacoEnvironment`**, a single global both instances read,
as a module side effect of the lazily-routed `GraphQLPlayground`. So
once the playground has been opened, the 0.55.1 CDN instance stops using
its own AMD workers and starts getting Vite-bundled 0.52.2 ones.

The two versions don't share a worker protocol: 0.52 routes
language-service calls through `$loadForeignModule` + `$fmr`, which
0.55's client never sends. `_foreignModule` stays `null`, and every call
rejects.

On `/settings/mcp-apis` the consumer is `SettingsMcpSetup.tsx` —
`<CodeEditor language="json">` for the MCP config. Monaco fires
`resetSchema` on `onWillDisposeModel` / `onDidChangeModelLanguage` and
`findDocumentColors` continuously, which is why one bug produces 170
events and 22.

There is a second, independent bug in the same file: the `switch` only
maps `json` and `graphql`, so `typescript` / `javascript` / `css` /
`html` fall through to the bare `EditorWorker`, which carries no
language service at all. That's the workflow-page
`getCodeFixesAtPosition`, and it would break even with matching
versions.

Impact is worse than the log noise suggests: after visiting the
playground, JSON validation/colors in the MCP config editor and TS
intellisense/quick-fixes in the workflow code editor silently stop
working for the rest of the session.

## Changes

- **`twenty-ui/src/input/CodeEditor/CodeEditor.tsx`** — configure
`@monaco-editor/react` with the bundled Monaco (`loader.config({ monaco
})`) instead of letting it fetch its own from jsDelivr. The import stays
dynamic so Monaco is still only downloaded when an editor actually
renders; the component shows its existing `Loader` until the loader is
configured.
- **`twenty-front/src/modules/app/utils/setupMonacoEnvironment.ts`**
(new, replaces
`settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers.ts`) — app-level
worker factory mapping every label Monaco can ask for: `json`,
`css`/`scss`/`less`, `html`/`handlebars`/`razor`,
`typescript`/`javascript`, `graphql`, and the generic editor worker as
the fallback.
- **`twenty-front/src/index.tsx`** and **`.storybook/preview.tsx`** —
set it up once for the app and for stories, rather than as a side effect
of one lazy route.

Dropping the CDN also means the code editors work in self-hosted and
air-gapped deployments, which today silently fall back to a broken
editor when jsDelivr is unreachable.

## Verification

- `nx build twenty-front` passes; `css.worker`, `html.worker` and
`ts.worker` chunks are now emitted alongside the existing
`editor`/`json`/`graphql` ones.
- Monaco stays lazy — `edcore.main` is not statically imported by the
entry chunk and is absent from `index.html`'s modulepreloads. Measured
against a baseline build of `main`, the entry chunk goes from 2,598,088
B to 2,599,040 B (**+952 B**).
- `oxlint` and `oxfmt --check` clean on all touched files; `tsc
--noEmit` clean for `twenty-ui` and reports nothing new for the touched
`twenty-front` files.

Not verified in a browser — worth a manual pass on the playground → MCP
tab → workflow code editor sequence that reproduced the original errors.

Fixes TWENTY-FRONT-8YV
Fixes TWENTY-FRONT-8YW
Fixes TWENTY-FRONT-ADE

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23855?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-08-06 14:26:54 +02:00
Raphaël Bosi 829ef9d8b9 Revert AI chat chips to the [[kind:...:label]] syntax (#23852)
Removes the `[[kind:...:label[[/kind]]` closing-tag syntax and goes back
to the simpler `[[kind:...:label]]` form for all four chip kinds
(record, object, field, view).

The parser is now a single regex pass instead of a two-pass scan with a
per-reference closing-tag search, a legacy fallback and surplus-bracket
handling. That removes 11 files. The label pattern excludes `[`, `]` and
newlines, which is what keeps an unclosed marker from swallowing the
text (and the marker) that follows it.

```mermaid
flowchart LR
    subgraph before ["Before — two passes"]
        O1["scan for marker openings"] --> O2["window each opening<br/>up to the next one"]
        O2 --> O3["find that kind's closing tag<br/>inside the window"]
        O3 --> O4["record only:<br/>bare-terminator fallback"]
        O4 --> O5["consume surplus<br/>closing brackets"]
    end
    subgraph after ["After — one pass"]
        N1["matchAll, one regex:<br/>object · field · view · record"] --> N2["map each match<br/>to a chip"]
    end
    before -.->|"11 files deleted"| after
```

Two things to know:
- Messages already stored with closing tags render as raw text instead
of chips.
- Malformed model output is no longer compensated for: a surplus `]`
after a chip stays in the text, and a display name containing brackets
does not chip. The system prompt tells the model to avoid both.

Rendering cost is unchanged for normal messages and noticeably lower on
long bracket runs, since the old opening pattern had to scan them.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23852?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-08-06 11:11:26 +00:00
github-actions[bot] 18a5121ab2 i18n - translations (#23860)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 13:12:09 +02:00
Marie d8b494d530 Make subdomain minimum length configurable via env var (#23209)
## What

Introduces a `SUBDOMAIN_MIN_LENGTH` environment variable (default `3`)
controlling the minimum number of characters allowed for a workspace
subdomain.

Until now the minimum was hardcoded (`3`), baked into the shared
`SUBDOMAIN_PATTERN` regex.

## How

- Added the `SUBDOMAIN_MIN_LENGTH` config variable (default `3`) in
`config-variables.ts`.
- Relaxed `SUBDOMAIN_PATTERN` in `twenty-shared` to validate format and
max length only, so the minimum length policy now lives with the caller
instead of being embedded in the regex.
- `isSubdomainValid` now takes a `minLength` argument (defaulting to
`3`) and enforces it explicitly.
- `SubdomainManagerService` reads `SUBDOMAIN_MIN_LENGTH` from config and
passes it to every validation call, making the server the authoritative
source.

## Scope

Server-side only. The frontend validation schema keeps its default
`.min(3)` UX check and is unchanged; the server remains the source of
truth for what subdomains are accepted.

## Tests

- Updated the shared `isValidTwentySubdomain` tests to reflect that the
pattern no longer enforces a minimum length.
- Added tests for the configurable minimum in
`is-subdomain-valid.util.spec.ts`.
- Updated the service spec config mock to return a numeric value for
`SUBDOMAIN_MIN_LENGTH`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23209?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-08-06 11:03:18 +00:00
Charles Bochet d4c3759c70 ci(pr-review): stop a skipped label dispatch from cancelling the open dispatch (#23856)
## Problem

The standard review silently does not run on PRs that get labelled by a
bot right after opening. https://github.com/twentyhq/twenty/pull/23854
is an example: no `PR Review #23854` run exists in `ci-privileged` at
all.

| time | what |
|---|---|
| 10:08:35 | PR opened |
| 10:08:39 | `twenty-eng-sync[bot]` adds the `-PR: draft` label |
| 10:08:40 | dispatch run for `opened` starts, cancelled during "Set up
job" |
| 10:08:43 | dispatch run for `labeled` is skipped by the job `if` |

Concurrency is evaluated before the job-level `if`, so the `labeled` run
preempts and cancels the in-flight `opened` run and is then skipped
itself (`-PR: draft` does not start with `pr-review-`). The sync bot
labels within ~4 seconds of open, which is faster than the app-token
mint step, so the `opened` dispatch loses this race essentially every
time that label is applied.

`opened` is the only event that resolves to the `standard` check, so
with no later push the PR gets no review at all. Same class of gap as
the one #23708 closed, moved down a layer: the trigger exists now but
gets cancelled.

## Fix

Scope the concurrency group by event action, and only cancel in-progress
runs for `synchronize`. Rapid consecutive pushes still de-duplicate;
`opened`, `ready_for_review` and `labeled` no longer cancel each other.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23856?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-08-06 12:54:20 +02:00
github-actions[bot] d649baa3f0 i18n - translations (#23853)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23853?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-08-06 11:57:10 +02:00
Abdul Rahman 0bf4b53af3 feat(slack): cache the Slack bot user id at connect time (#23726)
## What

Caches the Slack bot user id in workspace kv so the channel welcome
stops calling `auth.test` on every channel-join event.

## Why

Slack fires `member_joined_channel` for **every** person joining **any**
channel the bot sits in, not just for the bot itself. The welcome path
had to answer "was that our bot?", and did it by resolving the Slack
connection and calling `auth.test` — a connection lookup plus an
external API call, on every event, to conclude "no, that was a human, do
nothing".

The bot user id never changes for a given connection, so asking Slack
repeatedly is the wrong shape.

## How

`registerSlackConnection` already calls `auth.test` in the `onConnect`
hook and had `user_id` in hand, so it now writes it to workspace kv.
`resolveSlackBotUserId` reads it, falling back to `auth.test` (and
backfilling) for connections created before this change.

Reconnecting is the only thing that can change the bot user id, and
reconnecting re-runs that hook — so the cache is self-correcting and
needs no TTL.

Because resolving the id no longer needs a Slack client, the bot check
moved ahead of the connection lookup:

| per join event | before | after |
|---|---|---|
| Twenty round trips | 1 | 1 |
| Slack API calls | 1 | 0 |

A secondary win: previously `getSlackClient()` ran before the bot check
and threw on failure, so a revoked Slack connection made **every
unrelated human join** fail its job and retry. Now a human join answers
from kv and returns cleanly; the connection is only touched when there
is genuinely something to post.

## Claim ordering

Moving the client lookup after the claim opened a window where the claim
is held but nothing was posted, so that path now releases the claim
before throwing. The invariant the file follows is unchanged: release on
any failure that produced no message, keep it once a message is out (a
retry must not repost the channel message).

## Renames

`claimSlackTeam` → `registerSlackConnection`, and the logic function
`slack-team-claim` → `slack-register-connection`, since it now does more
than claim the team and connect-time work will keep landing there.

**The universal identifier value is unchanged** (`a29ae15d-…`) — it is
the app's stable identity and what the connection provider binds
`onConnectLogicFunction` to. Only the constant's name moved. Worth a
second pair of eyes in review, since that is exactly the kind of thing a
rename sweep regenerates by reflex.

Note this changes `name` and `sourceHandlerPath`/`builtHandlerPath` in
the manifest. Both are updates keyed on the unchanged identifier, not a
delete-and-recreate, so installed apps re-sync cleanly — but the app
needs rebuilding so the bundle path matches.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23722?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. -->



## Cache correctness

A wrong cached id fails silently — the bot's own join reads as someone
else's and the welcome never fires — so the entry is bounded and
self-healing in three ways:

- **Failed write drops the key.** Leaving the previous id in place would
keep a superseded value authoritative. An absent cache is rebuilt from
`auth.test`; a wrong one is believed.
- **Entries expire after 7 days.** `registerSlackConnection` rewrites on
every connect, so the expiry only matters when that write never lands.
- **A kv outage falls through to `auth.test`** rather than throwing,
which keeps the human joins that make up nearly all these events from
failing their job.
2026-08-06 09:50:53 +00:00
Thomas Trompette 5278a47b55 refactor(front): move workflow run step logs into workflow-actions (#23841)
Follow-up on unapplied review feedback from #21142.

### Folder structure

@thomtrp:
> We now have a folder workflow-run/observability, but we have
workflow-run related components in workflow-actions folder already. I
would avoid that workflow-run/observability folder.

The `workflow-run/` folder held nothing but `observability/`, so it is
removed entirely and each step log detail component now sits with its
action:

| File | New location |
| --- | --- |
| `WorkflowRunStepLogsAiAgentDetail.tsx`,
`WorkflowRunStepLogsToolCallRow.tsx` |
`workflow-actions/ai-agent-action/components/` |
| `WorkflowRunStepLogsCodeDetail.tsx` |
`workflow-actions/code-action/components/` |
| `WorkflowRunStepLogsHttpRequestDetail.tsx` |
`workflow-actions/http-request-action/components/` |
| `WorkflowRunStepLogsDetail.tsx`, `WorkflowRunStepLogsEntries.tsx`,
`WorkflowRunStepLogsEmailDetail.tsx`, `workflowRunStepLogsStyles.ts` |
`workflow-actions/components/` |
| `formatDuration.ts`, `formatBytes.ts` | `workflow-actions/utils/` |

The email detail stays in the shared `components/` folder since there is
no dedicated send-email action folder on the front end (email editing
lives at the root as `WorkflowEditActionEmailBase.tsx`).
`workflowRunStepLogsStyles.ts` goes next to the shared components rather
than `utils/`, since it is styled components and not utils.

Also folds in @FelixMalfait's `2 export in 1 file` comment:
`workflowRunStepLogsFormatters.ts` is split into `formatDuration.ts` and
`formatBytes.ts`.

### AI comments

@thomtrp:
> same, let's not keep AI comments

Removes every comment #21142 introduced, across the front end, server
and shared packages:

- `WorkflowRunStepLogsEntries.tsx` - the `onlyLatestIteration` prop
block
- `workflow-run-step-log-schema.ts` - the transport-failure note and the
permissive-schema rationale
- `strip-ansi-escapes.util.spec.ts`,
`build-http-request-step-log.util.spec.ts`,
`truncate-string-to-utf8-byte-budget.spec.ts`,
`agent-async-executor.service.spec.ts` - the byte-vs-char and
pre-fix-behaviour commentary

No behaviour change. Locale catalogs are deliberately left untouched;
the next i18n run picks up the new source paths.

### Still open from #21142, not covered here

- `persistStepLog` try/catch duplicated across the code, tool-backed and
ai-agent actions
- `draft-email-tool` returning both `sanitizedHtmlBody` and
`plainTextBody`
- storing both `totalCostInDollars` and `creditsUsedMicro`
- the byte-budget truncation utilities being over-engineered
- `strip-ansi-escapes` being local to application logs
2026-08-06 09:48:36 +00:00
github-actions[bot] f3bc2325cb i18n - docs translations (#23851)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 11:35:30 +02:00
Félix Malfait 1267697b2c Fix missing border below the last record table row (#23846)
The bottom border of the last row of a record table is missing, except
under the sticky first columns.

<img width="600" alt="before"
src="https://github.com/user-attachments/assets/placeholder" />

## Cause

Two things combine.

**A 1px off-by-one in the virtualization grid.** Virtualized rows are
absolutely positioned on a grid whose pitch is `RECORD_TABLE_ROW_HEIGHT
+ 1` (row plus its bottom border), and
`RecordTableRowVirtualizedContainer` reserves the first slot for the
header:

```ts
const pixelsFromTop =
  realIndexByVirtualIndex * (RECORD_TABLE_ROW_HEIGHT + 1) +
  (RECORD_TABLE_ROW_HEIGHT + 1);
```

`RecordTableVirtualizedBodyPlaceholder` reserves `n *
(RECORD_TABLE_ROW_HEIGHT + 1)` of in-flow height to match. But header
cells are sized `height: RECORD_TABLE_ROW_HEIGHT` with their
`border-bottom` inside that box, so the header only occupies 32px, not
the 33px the grid assumes. Everything after the placeholder therefore
sits one pixel above the grid.

**The add-new row started painting over that pixel.** It used to be an
unpositioned sibling, so the absolutely positioned rows painted above it
(positioned descendants paint after in-flow blocks) and the overlap was
invisible. #23211 wrapped it in `DragDropItemEndDropZone`, which is
`position: relative`; #23752 kept that as `StyledEndDropZone`. It is now
a positioned element later in DOM order, so it paints over the rows and
its opaque background covers the last row's border. The border survives
only where cells carry their own `z-index` — the sticky first columns.

Measured on `/objects/workflows` with 2 records, before the fix:

| element | top | bottom |
| --- | --- | --- |
| header row | 88 | 120 (height 32) |
| last row container | 154 | 187 |
| add-new wrapper | 186 | 218 |

## Fix

Give the header container the full row slot (`RECORD_TABLE_ROW_HEIGHT +
1`) so the body lines up with the grid the virtualization already
assumes. Header cells keep their own 32px sizing, so their internal
layout is unchanged.

This also closes the 1px gap that previously sat between the header and
the first row.

After the fix, on the same view:

| element | top | bottom |
| --- | --- | --- |
| header row | 88 | 121 (height 33) |
| first row | 121 | 154 |
| last row | 154 | 187 |
| add-new wrapper | 187 | 219 |

Overlap 0, header-to-first-row gap 0.

Only the ungrouped virtualized table was affected.
`RecordTableRecordGroupRows` has the same `position: relative` wrapper,
but its rows are in normal flow, so the header change just shifts the
whole body down a pixel with no overlap possible.

## Testing

Ran the app locally against seeded data:

- Workflows (2 rows): border restored across the full width, geometry
above verified in the DOM.
- Companies (599 rows): header 33px, first row flush at 0, uniform 33px
pitch across all 240 mounted row containers; scrolled and confirmed rows
slide under the sticky header cleanly.
- Verified the diagnosis independently by toggling the end drop zone to
`position: static` in the running page, which restores the border the
same way.

`npx nx typecheck twenty-front` and `npx nx lint:diff-with-main
twenty-front` are green.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23846?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-08-06 10:48:38 +02:00
Félix Malfait ae235d3b24 Scope sessions and application authorizations to the workspace (#23843)
Settings / Profile / Devices listed every live session for the account,
so a person who belongs to two workspaces saw all of them from either
one, and "Log out all other devices" signed them out everywhere.

Almost nothing in Twenty is account-wide, and a session belongs to the
workspace its exchange selected, so neither the list nor the revocations
are another workspace's business.

## What was wrong

`currentUserSessions` called `findActiveSessionsForUser(user.id)` with
no workspace filter. Both revoke paths were keyed on `userId` alone, so
`revokeUserSession` could target another workspace's session by id and
`revokeAllOtherUserSessions` cleared every workspace at once.

Sweeping the other resolvers that take `@AuthUser()` turned up the same
shape in the OAuth application authorizations added in #23678:
`findActiveAuthorizationsForUser(userId)` and `revokeAuthorizationById({
authorizationId, userId })`. A grant made in one workspace was listed,
and revocable, from another.

Everything else already pairs `@AuthUser()` with `@AuthWorkspace()`.
`client-config.resolver.ts` is the reference pattern.

## The fix

Both resolvers now take the workspace from the auth context and pass it
down, and the service methods are renamed to say so.

`revokeAllSessionsForUser` keeps `workspaceId` optional on purpose:
`auth.service.ts` uses it on password change, where clearing every
workspace is the intended behaviour.

Sessions with no workspace (the workspace-agnostic ones minted on the
default subdomain, which exists to list workspaces and carry the
auto-login window) now belong to no workspace's list and survive "log
out all other devices".

## Verification

- New integration spec built on Tim's membership of both apple and yc:
the list stays disjoint, a cross-workspace revoke by id is refused and
is a no-op, and revoking all other devices in one workspace leaves the
other signed in
- Mutation-checked by dropping the `workspaceId` from the query, which
fails the isolation test while the two revoke tests still pass,
confirming each assertion targets its own mechanism
- 160 unit tests, 70 integration tests across the session and OAuth
suites

## Also

The devices button drops its danger accent for the plain small variant,
matching Deactivate in `ObjectSettings.tsx`.

## Separate finding, not fixed here

`request.ip` resolves to an internal address behind the Cloudflare /
nginx chain, which is why every row in the screenshot that prompted this
showed the same RFC1918 address. That is an ingress configuration issue
rather than an application one. It does not affect ClickHouse audit
logs, which store no IP, but it does affect the two OAuth rate limiters
that key on `req.ip`.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23843?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-08-06 10:20:45 +02:00
Marie 26104d47a6 Report a sending domain as pending while SES waits for the DKIM records (#23818)
## Problem
<img width="648" height="310" alt="image-1785940453654"
src="https://github.com/user-attachments/assets/60362b8c-fed5-4ce3-af45-9aa064356f11"
/>

A sending domain that is simply waiting on its DKIM records is displayed
as **Failed**, with every DKIM row marked **Error**, even when the DNS
is correct and AWS has already published the key.

Hit while setting up `twenty.dev` for a demo. All five CNAMEs resolve
correctly from the authoritative nameserver and from a public resolver,
none are proxied, the unsubscribe row is green, and the first DKIM token
already resolves through to its published key at AWS:

```
$ dig +short TXT abbr…._domainkey.twenty.dev
abbr….dkim.amazonses.com.
"p=MIIBIjANBgkq…"
```

Yet all three DKIM rows read Error, which tells the user to go fix DNS
that isn't broken.

## Cause

`determineVerificationStatus` treats `VerifiedForSendingStatus ===
false` as terminal:

```ts
if (
  identityResponse.VerifiedForSendingStatus === false ||
  dkimStatus === 'FAILED'
) {
  return EmailingDomainStatus.FAILED;
}

return EmailingDomainStatus.PENDING;
```

SES reports `VerifiedForSendingStatus: false` for the entire period it
is waiting to detect the DKIM CNAMEs, which is the normal state of every
domain between setup and verification. So a pending domain returns
FAILED, and the PENDING branch is unreachable for any identity where the
field is present at all. `TEMPORARY_FAILURE`, which SES documents as
retryable, was also reported as Failed.

The status is then stamped onto each DKIM row by `withRecordStatus`,
which is why all three rows change together and none of them reflects
its own record.
2026-08-06 08:01:34 +00:00
github-actions[bot] 299ebb1890 i18n - translations (#23845)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 09:44:02 +02:00
Félix Malfait 7e00298044 Rename credits "Increase" button to "Manage" (#23840)
The green primary button in the Credits section was labelled "Increase"
with an up-arrow icon, but it opens the credit package picker, whose
slider spans every available package including smaller ones. So the
label promised upgrade-only while the modal supports both directions.

Renamed it to "Manage" and swapped the up arrow for `IconAdjustments`
(sliders), matching the slider-based picker it opens.

The secondary shortcut buttons ("Increase to $100", "Increase to $200")
are unchanged since those really do apply an upgrade directly.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23840?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-08-06 09:35:41 +02:00
github-actions[bot] 692c0c8402 i18n - translations (#23844)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 09:34:19 +02:00
Félix Malfait 647a6aec58 Add nested relation Field widgets on record page layouts (#23815)
## Context

Record pages can show a list of directly related records (Field widget
in Table display mode), but not records two relation hops away. The
canonical ask: on a Client page, list the Transactions of the
Client&#39;s Wallets.

Stacked on #23814 (merged) and #23832 (merged); their commits are
included in this branch. #23836 stacks on this PR to add many-to-one
first hops.

## How it works

The 2-hop case does not need any new query capability. It reuses the
relation traversal filter shipped for advanced filters: the widget
embeds a view on the terminal object (Transaction) with one seeded
filter `inverse relation IS current record`, traversed one hop
(`fieldMetadataId` = Transaction.wallet, `relationTargetFieldMetadataId`
= Wallet.client, value `isCurrentRecordSelected`). At query time this
compiles to `{ wallet: { clientId: { in: [currentRecordId] } } }`, which
is within the backend&#39;s `MAX_RELATION_FILTER_DEPTH = 1` since the
second hop lands on the join column. Records from all intermediate
records (all wallets of the client) are listed, so one-to-many fan-out
on the first hop works out of the box.

## Changes

Configuration
- `FieldConfiguration` gains an optional `nestedRelationFieldMetadataId`
(shared type, DTO, GraphQL fragment, regenerated metadata types).
Backward compatible: existing widgets are untouched.

UI
- The Field picker drills into one-to-many relation fields, mirroring
the advanced filter submenu pattern: back header, an entry to select the
relation itself (previous behavior), then the target object&#39;s
one-to-many relations. Selecting a nested field creates a widget titled
`First hop → Second hop` in Table display mode. First-level rows that
open a submenu never show the checkmark; the selected chain is only
visible inside the submenu, matching the chart group by field selection.
- The layout dropdown, settings panel and renderer resolve the terminal
object of the chain; a widget whose second hop was deleted or
deactivated renders nothing instead of silently showing first-hop
records.
- Nested widgets only offer embedded view layouts (Table / Kanban /
Calendar), since inline display modes would render the first hop&#39;s
relation field.
- The relation table view resolver regenerates the embedded view
whenever the selection results in a table widget and the chain changed
or the view id is missing, so a table widget can never carry a view
belonging to a different chain.

Server
- `FieldConfigurationDTO` accepts the new optional field.
- Both universal configuration mappers (to and from universal
identifiers) carry it for app manifest sync.
- New `validateFieldConfigurationNestedRelationOrThrow` enforces that
both hops are active one-to-many relation fields on the right objects,
wired next to the existing chart field reference validation.

Record creation
- `buildRecordInputFromFilter` skips relation-traversal filters: they
constrain a related record&#39;s column, so prefilling the created
record&#39;s own foreign key from them would link the wrong record (e.g.
`walletId = clientId`).
- Add New in a nested widget table instead prompts for the record to
create through: the row opens a picker listing the current record&#39;s
first-hop records (the client&#39;s wallets), scoped with a find filter
on the relation join column, and creates the record with the picked id
prefilled. Covers the plain table and per-group add rows. Board and
calendar layouts hide their create buttons in nested widgets since they
cannot know the record to create through.
- Matching the created record against the widget&#39;s traversal filter
client side is handled by #23832.

Out of scope, deliberately: depth stays at exactly two levels (matches
the backend filter depth cap), junction and morph relations are not
drillable, and chart widgets on record pages are untouched.

## Tests

- Unit: nested chain resolution util, draft view seeding with the
traversal filter, view id change resolver, picker parameter derivation,
server-side validation. Full `page-layout` and `record-filter` front
suites pass (187 suites / 1233 tests), server `page-layout-widget`
suites pass.
- Manual, on seeded data: created a `People → Opportunities` widget on a
Company page; it lists exactly the opportunities whose point of contact
belongs to that company, persists across save and reload, and scopes per
record. Add New opens a picker showing only that company&#39;s people;
picking one creates an opportunity with `pointOfContactId` set (verified
in DB) and the row appears in the widget immediately.

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

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23815?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
2026-08-06 09:26:53 +02:00
github-actions[bot] 9480513689 chore: sync AI model catalog from models.dev (#23839)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-08-06 08:51:42 +02:00
Félix Malfait c3d0f021b7 Fix client-side record matching for nested relation filters (#23832)
## Problem

`isRecordMatchingFilter` assumes every filter keyed by a relation field
name is a flat UUID filter on the related record id. Filters that
traverse a relation, like

```
{ pointOfContact: { companyId: { in: [companyId] } } }
```

(produced by view filters carrying `relationTargetFieldMetadataId`, such
as the seeded filter of a nested relation Field widget in #23815), make
it throw `Unexpected value for UUID filter`. The throw happens inside
the create and update optimistic effects, so creating a record from a
view seeded with such a filter aborts before anything is written.

## Fix

When the value under a relation field name holds related record field
names (or `and`/`or`/`not` composites) instead of UUID operators,
recurse into the related record with the relation target's object
metadata. A related record missing from the payload, or a list relation,
conservatively does not match. Flat UUID filters on the relation name,
join column filters and morph relations keep their existing behavior.

`isRecordMatchingFilter` now takes `objectMetadataItems` to resolve the
relation target metadata. The optimistic effect call sites already had
it in scope; it is threaded through the two group-by helpers.

## Tests

- New `Nested Relation Filters` cases: match, no match, related record
not loaded, composite conditions, list relation.
- Existing suites updated for the added parameter; record-filter and
optimistic-effect suites green, typecheck green.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23832?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-08-05 21:10:49 +02:00
github-actions[bot] 5ada3adfd5 i18n - translations (#23834)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-05 20:39:06 +02:00
Félix Malfait dac5d5a23a Extend live update coverage: workflow draft updates and side-panel records (#23830)
Stacked on #23811. Closes the two remaining live-update coverage gaps
found while auditing record-seeded editing surfaces.

## Workflow diagram misses updates to the current draft version

`WorkflowSSESubscribeEffect` triggered a content refetch only on
`create-one` of a workflow version (new draft created) and on SSE
reconnection. Step and trigger edits on the existing draft arrive as
`update-one` events and left the open diagram stale until a refresh,
which is the common case when the AI chat or a teammate edits a draft
workflow.

Refetch on `update-one`/`update-many` too. Local workflow mutations do
not dispatch these browser events (they only originate from SSE
deliveries), and an own-persist echo reseeds the diagram with the state
it already shows, so this does not fight local editing.

## Side-panel records receive no SSE events

`SidePanelRecordPage` registered no SSE query, so a record opened in the
side panel (notes and tasks most commonly) got no events for itself
unless another surface happened to subscribe to a matching query. With
#23811's rich text adoption this mattered doubly: events could not reach
the editor at all.

Register the record query like `RecordShowPage` does. The subscribe
effect takes a `queryScope` so the record page and side panel keep
independent registrations when they display the same record, and closing
one surface does not unsubscribe the other.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23830?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-08-05 20:33:33 +02:00
Félix Malfait 393e62ba9f Make AI chat streaming render cost independent of message length (#23831)
Follow-up to #23573: chip-heavy answers are long by design, and each
stream flush re-ran `protectChatReferencesForMarkdown` and
`marked.lexer` over the whole message, so render cost grew quadratically
with message length. This makes the per-flush cost proportional to the
appended text instead, and offsets the new code by removing dead AI chat
code.

## Streaming render

- **Incremental block splitting.** `getMarkdownBlocksIncrementally`
reuses blocks that can no longer change and re-tokenizes only the
trailing ones. Two trailing blocks stay unstable, not one: a loose list
followed by a blank line still merges with a later item (`- a\n\n` + `-
b` is one list token). Uses `Lexer.blockTokens` instead of
`marked.lexer` since only block raws are needed and the full lexer also
runs the inline tokenizer. Simulated stream over a 22 KB chip-heavy
message (120 chars/flush, matching the 100 ms flush throttle): 191 ms →
3.7 ms cumulative. The test suite pins char-by-char equivalence against
full `marked.lexer` output across loose lists, unclosed fences, setext
headings, tables, CRLF and chip markers.
- **Per-block reference protection.** `protectChatReferencesForMarkdown`
moved behind the existing block memo, so settled blocks never re-run
reference parsing during a stream.
- **Anchored open pattern.** `(?<!\[)\[\[+` anchors marker matching to
the start of a bracket run. The greedy `+` from #23798 backtracked at
every position inside a run, once per alternative: 429 ms → ~1 ms on a
10 KB bracket-run input. A run start always yields the same match, so no
valid marker is lost. Also an `includes('[[')` bail-out in
`findChatReferences`, which runs on every text node of the streaming
block.

## Chip lookups

`fieldMetadataItemByIdSelector` did `objectMetadataItems.find(obj =>
obj.fields.some(...))` per chip — O(workspace fields) each time the
agent's tool calls trigger a metadata refetch mid-chat. The by-id and
by-name map selectors mostly already existed with almost no consumers;
this wires `fieldMetadataItemByIdSelector`,
`objectMetadataItemFamilySelector` and `viewFromViewIdFamilySelector` to
them (adding the missing `objectMetadataItemsByIdMapSelector` and
`viewsByIdMapSelector`) and adds `areEqual` so unchanged lookups keep
referential stability.

## Offscreen messages

Settled messages (everything except the streaming last one) get
`content-visibility: auto`, so long threads skip layout and paint for
messages scrolled out of view. `contain-intrinsic-size: auto` keeps
remembered heights, so scroll positions stay accurate once a message has
been painted.

## Removed

`ReasoningSummaryDisplay`, `agentChatMessagesComponentState`,
`CHAT_THREADS_PAGE_SIZE`, `AgentResponseFormat` and `getFieldIcon` had
no consumers. `TextWithChatReferences` and
`protectChatReferencesForMarkdown` shared a duplicated segment-slicing
loop, now in `getChatReferenceSegments`, and the nine identical per-tag
markdown component entries collapse into `createChatReferenceElement`.
The branch lands at +354/−329 including the new test suite; production
code is net negative.

Incidental: `marked` added to jest's `transformIgnorePatterns` allowlist
(ESM-only, previously imported by no test).

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23831?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-08-05 20:31:57 +02:00
Félix Malfait c35e364562 Fix AI campaign editing: tool auth context in workers and live editor resync (#23811)
Fixes two issues reported when creating an email campaign through the AI
chat panel.

## 1. `save_campaign` failed with "Workspace auth context not set"

The AI chat streams inside a queue worker job, where no HTTP middleware
populates the async-local workspace auth context. The new
`MessageCampaignDraftService.saveDraft()` relies on
`executeInWorkspaceContext()`'s fallback to `getWorkspaceAuthContext()`,
which throws outside HTTP requests. Database CRUD tools worked because
`dispatchDatabaseCrud` builds an auth context explicitly; static tools
had no equivalent.

**Fix:** `ToolExecutorService.dispatch` (the single choke point for all
tool executions: preloaded chat tools, `execute_tool`, MCP, workflow
agents) now resolves the acting identity once, reusing a provided auth
context or building a user context from `userId`/`userWorkspaceId`, and
runs the dispatch inside `withWorkspaceAuthContext()`. This mirrors what
`WorkspaceAuthContextMiddleware` does for HTTP requests, so tool code
can rely on the async-local context on every transport.

Side benefit: metadata tools executed from chat previously emitted
metadata events with no user attribution (`MetadataEventEmitter`
swallows the missing context); they are now attributed correctly.

## 2. AI changes to the open campaign required a page refresh

The SSE pipeline delivers worker-originated record updates to the Apollo
cache correctly. The campaign editor ignored them:
`usePersistedCampaignDraft` seeds local draft state from the record
once, and the subject/body/list inputs are uncontrolled (TipTap reads
`defaultValue` on mount only).

**Fix:** the draft hook now adopts upstream record values while the
draft is pristine and exposes a `draftResyncKey` that remounts the
`defaultValue`-seeded inputs. Unsaved local edits win over concurrent
remote changes (last write wins on flush), and echoes of our own
debounced persists never remount inputs mid-typing.

## Tests

- `tool-executor.service.spec.ts`: auth context exposed to static tools,
provided-context reuse, no-identity passthrough, no context leakage
after dispatch, CRUD receives the resolved context, CRUD still rejects
without identity.
- `usePersistedCampaignDraft.test.tsx`: adopt-when-pristine, own-echo
stability, dirty-draft-wins, adopt-after-persist.
- `lint:diff-with-main` and `typecheck` clean on both packages.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23811?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-08-05 20:27:21 +02:00
Paul Rastoin dc8c62a7a9 Add OpenTelemetry metrics to workspace migration build and run (#23797)
## Context

## AI generated graph goal example
<img width="2078" height="914" alt="image"
src="https://github.com/user-attachments/assets/14c760df-5ac8-4526-a5e0-40b57344be90"
/>
<img width="2158" height="1850" alt="image"
src="https://github.com/user-attachments/assets/2324779e-9ab8-47fc-a014-78269a78823e"
/>


The workspace migration builder and runner already log phase timings via
`logger.perfTime`, but those logs are only useful for local debugging:
they're gated behind the `performance` log level and never leave the
process. This PR routes the same timings into the existing
`MetricsService` (OpenTelemetry) so migration performance can be tracked
over time and regressions detected.

## What's recorded

All metrics are histograms recorded via
`MetricsService.recordHistogram`, which is a synchronous in-memory
aggregation - export happens on the background OTel readers (Prometheus
scrape or periodic OTLP push), so nothing is added to the migration hot
path. When `METER_DRIVER` is unset, records are no-ops.

| Metric | Where | Attributes |
|---|---|---|
| `workspace-migration/build-duration-ms` |
`WorkspaceMigrationValidateBuildAndRunService` | `status`: `success`,
`fail` (validation errors), `error` (builder threw) |
| `workspace-migration/build-entity-duration-ms` |
`WorkspaceEntityMigrationBuilderService.validateAndBuild` |
`metadataName`, `status` |
| `workspace-migration/build-entity-phase-duration-ms` | entity builder
| `metadataName`, `phase`: `matrix-computation`, `deletion-validation`,
`creation-validation`, `update-validation` |
| `workspace-migration/run-duration-ms` |
`WorkspaceMigrationRunnerService.run` | `status`: `success`, `fail` -
recorded on every exit path, including pre-transaction failures (DDL
locked, cache retrieval, application not found) |
| `workspace-migration/run-phase-duration-ms` | runner | `phase`:
`initial-cache-retrieval`, `action-execution`, `commit`,
`cache-invalidation`; `status` - on failure the elapsed transaction time
is recorded as `action-execution` with `status: 'fail'` (the
action/commit split is unknowable mid-failure) |
| `workspace-migration/action-duration-ms` | base action handler wrapper
| `actionType`, `metadataName`, `step`, `status` - failed steps are
recorded via try/finally |
| `workspace-migration/action-count` | validate-build-and-run service |
- |

Phase notes: phases are disjoint (commit is subtracted from transaction
time) but not exhaustive - connection setup, the application-map fetch,
rollback, and after-commit side effects sit outside them, so the stack
approximates rather than equals the run total. The `cache-invalidation`
phase is recorded only at the run's post-commit call site, not inside
the public `invalidateCache`, so standalone callers (upgrade backfill
commands, `FlatCacheInvalidateCommand`) don't pollute the series.

## Implementation notes

- Durations are measured with local `performance.now()` instead of
reusing `perfTime`/`perfTimeEnd`, so metrics are recorded regardless of
the `performance` log level and are immune to key collisions in the
logger's shared timer map under concurrent migrations. Existing perf
logs are untouched.
- `WorkspaceMigrationRunnerService.run` is now a thin wrapper around the
previous body (`executeRun`) so the total run duration and its
success/fail status are recorded in one place for every throw path.
- No `workspaceId` in attributes to keep cardinality bounded.
- Two new bucket-boundary constants follow the existing
`AI_LATENCY_MS_BUCKET_BOUNDARIES` pattern: durations 5ms-120s, action
counts 1-5000.
- `MetricsModule` imported into `WorkspaceMigrationModule`,
`WorkspaceMigrationRunnerModule`, `WorkspaceMigrationBuilderModule`, and
the action-handlers module.

## Test

- `nx typecheck twenty-server` passes
- oxlint + oxfmt clean on changed files
- `workspace.service.spec.ts` passes
2026-08-05 16:40:32 +00:00
Thomas Trompette b7e556bcfc fix(workflow): make core-consistency drift check trustworthy for rollout (#23807)
## Context

Part of the workflow → core migration. Before enabling
`IS_WORKFLOW_DISPATCH_FROM_CORE_ENABLED` per workspace, the drift signal
that gates the rollout must be trustworthy. Two bugs in the (already
merged) consistency cron made it lie in both directions. This PR fixes
only those two; no new machinery.

The actual pre-flight gate is a read-only SQL query run per batch of
workspaces, so the heavier repair-command idea was dropped.

## What this does

**1. Exclude soft-deleted trigger rows from the automated-trigger drift
check.**
`checkAutomatedTriggerSync` read the workspace
`workflowAutomatedTrigger` table without a `deletedAt` filter (the
sibling workflow/version checks have one). Workflow soft-delete
soft-deletes the trigger row but removes the core-map entry, so every
soft-deleted automated workflow emitted a permanent false
`inTableNotCache` drift — inflating the exact metric meant to gate the
flag.

**2. Enumerate active workspaces in the consistency cron.**
The scan was `SELECT DISTINCT "workspaceId" FROM core."workflow"`: a
workspace whose mirror never succeeded has zero core rows and was
therefore never checked — the worst-drifted tenants were invisible. It
now enumerates ACTIVE workspaces and skips those with no (non-deleted)
workflow rows, so cost stays close to actual workflow usage.

No behavior change beyond the drift metrics themselves.
2026-08-05 15:57:12 +00:00
Charles Bochet 6abeb7b5e5 feat(server): instrument the local metadata cache and cap heavy providers by entry count (#23778)
## What

Two related changes to the per-pod local workspace-metadata cache
(`WorkspaceCacheService`):

1. **Occupancy metrics** — per-pod gauges so we can measure how the
cache is actually used from prod instead of guessing:
   - `twenty_workspace_cache_local_entries` — Map size
- `twenty_workspace_cache_local_workspaces` — distinct workspaces held
- `twenty_workspace_cache_local_versions_total` — total versions across
entries
- `twenty_workspace_cache_local_bytes_estimate` /
`..._bytes_by_provider{provider}` — sampled deep-size (circular-safe,
includes `localDataOnly` providers)
-
`twenty_workspace_cache_local_entries_by_version_count{versions=1|2|3|4|5+}`
— stale-version distribution

2. **Per-provider eviction budget (behavior change)** — a heavy provider
can override the global entry cap, and eviction now drops the
least-recently-read entry. `ORMEntityMetadatas` is capped at 128
entries.

## Why

Measured on a prod pod (with these gauges plus a live heap walk): the
local cache is ~1.5-2 GB of the pod's ~2.4 GB live heap, and the pod
sits at 85% of its 4 GB limit. Two providers own 89% of it:

- `orm:entity-metadatas` — **50%**, ~5 MB/entry (the full TypeORM
`EntityMetadata[]` graph), `localDataOnly` so it is pure per-pod RAM.
- `flat-maps:field-metadata` — 39%.

The pod held 434 ORM entries but served under one distinct workspace per
second, with 62% of entries idle for more than 5 minutes — it hoards.
Rebuilding an ORM entry is cheap (6-16 ms of synchronous CPU; the DB
read dominates the rest of a recompute), so bounding how many we retain
is nearly free.

## The eviction change

The only size control before this was a single global 6000-entry LRU,
which is byte-blind: a 5 MB ORM entry and a 760 B webhook entry each
count as "1", so "6000 entries" is anywhere from 300 MB to 3 GB.

This adds a **per-provider entry cap** and evicts the
**least-recently-read** entry, keyed on `version.lastReadAt` (replacing
the coarser `lastHashCheckedAt`, which was zeroed on invalidation and
only 100 ms-granular). `ORMEntityMetadatas` → 128 entries, down from 434
observed in prod: ≈640 MB at the measured ~1.0 versions/entry (worst
case bounded by `128 × MAX_LOCAL_STALE_VERSIONS`), against ~2.1 GB
today. The global 6000 cap remains as a coarse total backstop; providers
without an override are unchanged.

A cold miss (a workspace served again after its entry was evicted)
recomputes transparently. At the measured activation rate that is under
one rebuild per second per pod — well below 1% of a core. Cache
correctness is unchanged: entries are hash-versioned and disposable.

## Cost

- **Metrics**: stats are one pass over the local Map, memoized 5 s so
concurrent gauge callbacks reuse them. The byte estimate is a background
sampler (first run ~30 s after startup, then every 5 min) that
deep-sizes a few entries per provider, node-capped and yielding between
walks — off the request and scrape paths.
- **Eviction**: the per-provider cap is enforced on write; a cold-miss
rebuild is the 6-16 ms recompute above.
2026-08-05 15:42:29 +00:00
Charles Bochet 352d7dda55 fix(front): apply advanced filter value and operand edits after a fresh page load (#23819)
## Problem

On a freshly loaded page (e.g. opening a saved view with an advanced
filter), editing an advanced filter rule silently fails:

- Toggling a record in a relation value dropdown (e.g. Account Owner
\`Is Me\` → adding a workspace member) does nothing: the checkbox does
not stick and the filter is never updated.
- Changing the operand (e.g. \`Is\` → \`Is not\`) is also a no-op.

The edits only work in the session where the rule was just created,
which is why this slips through manual testing of new filters.

Found while QAing #23718: the new runtime-computed relation chip made
the stale value visible enough to notice the edit was never applied.

## Root cause

The object-filter-dropdown component states for an advanced filter row
live under the row's instance id (\`advanced-filter-<recordFilterId>\`,
provided by \`AdvancedFilterRecordFilterRow\`). They are hydrated by
\`useSetRecordFilterUsedInAdvancedFilterDropdownRow\` when a rule is
created — but never on a later page load.

\`AdvancedFilterValueInput\` did write
\`objectFilterDropdownCurrentRecordFilter\` & co on dropdown open, but
under a different instance id
(\`advanced-filter-view-filter-value-input-<recordFilterId>\`) that no
dropdown content ever reads — dead writes.

So after a reload, \`selectedOperandInDropdown\` is undefined in the
instance the dropdown reads, and
\`ObjectFilterDropdownRecordSelect.handleMultipleRecordSelectChange\`
(gated on it) silently drops the selection. Same story for
\`useApplyObjectFilterDropdownOperand\`, which sees no current record
filter and never upserts.

## Fix

- \`useSetRecordFilterUsedInAdvancedFilterDropdownRow\` now also
hydrates \`subFieldNameUsedInDropdown\` and
\`relationTargetFieldMetadataIdUsedInDropdown\`, mirroring
\`useSetEditableFilterChipDropdownStates\` (the regular filter chip
flow, which does not have this bug).
- \`AdvancedFilterValueInput\` calls it on value-dropdown open instead
of the phantom-instance writes, and its search-input/subFieldName states
now target the row instance actually read by the dropdown content.
- \`AdvancedFilterRecordFilterOperandSelectContent\` hydrates the same
states on operand-dropdown open.

## Test

Verified locally against a seeded workspace, on a saved view \`Account
Owner Is Me\` reloaded in a fresh session:

- Adding a member in the value dropdown now applies immediately: chip
updates to \`Me, Aaron Munoz\`, results re-query, Update view appears.
- Unchecking \`Me\` leaves \`Aaron Munoz\` with the record-name chip and
the filter applied.
- Changing the operand to \`Is not\` applies (count flipped from
owned-by-me to the complement).
- Regular (non-advanced) filter chips unchanged.

Ran \`lint:diff-with-main\`, \`typecheck\` and the advanced-filter jest
suites.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23819?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-08-05 15:41:57 +00:00
Thomas Trompette f7aab2e988 fix: allow app-manifest RECORD_TABLE widgets to reference a view by universal identifier (#23634)
## Context

Fixes #23065.

App-manifest dashboard `RECORD_TABLE` widgets could not reference a view
by universal identifier. `RecordTableConfiguration.viewId` was typed as
a plain `string`, so `FormatRecordSerializedRelationProperties` (which
only renames properties branded with `SerializedRelation`) left it as
`viewId` in the manifest type. As a result the manifest rejected
`viewUniversalIdentifier`, and the widget could not be made portable
across workspaces the way `FIELDS` widgets already are.

## Changes

- `RecordTableConfiguration.viewId` is now `SerializedRelation | null`
(was `string`), matching `FieldsConfiguration`. This makes the manifest
type surface `viewUniversalIdentifier` instead of `viewId`.
- `RecordTableConfigurationDTO.viewId` retyped to match.
- Forward converter
(`fromPageLayoutWidgetConfigurationToUniversalConfiguration`): the
`RECORD_TABLE` case now emits the `viewUniversalIdentifier` key instead
of `viewId`, since the branded property is renamed in the universal
type. Now consistent with the `FIELDS` case (uses `| null`).
- Reverse converter
(`fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration`): the
`RECORD_TABLE` case now reads `viewUniversalIdentifier` and resolves it
back to a concrete `viewId`.

Frontend readers need no change: `SerializedRelation` is a runtime
string, so the existing `typeof === 'string'` guards and `as string`
casts still hold.

## Migration

None needed. The persisted `pageLayoutWidget.configuration` still stores
a concrete `viewId`; `universalConfiguration` (which carries
`viewUniversalIdentifier`) is computed on the fly from it and never
persisted. Only the manifest/universal representation changes, so there
is no stored data in the old shape to backfill.

## Verification

- `nx typecheck twenty-server` and `nx typecheck twenty-front`: pass
- oxlint + oxfmt on the changed files: clean
- End-to-end against a server built from this branch: built a minimal
app declaring a view (by `universalIdentifier`) and a `DASHBOARD` page
layout with a `RECORD_TABLE` widget referencing that view via
`viewUniversalIdentifier`, then installed it. The manifest carried
`viewUniversalIdentifier`, and the installed
`pageLayoutWidget.configuration.viewId` resolved to the concrete
workspace view id.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23634?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-08-05 15:22:14 +00:00
Priyanshu Bartwal 17ff17bdec Dnd library migration fixes and changes (#23752)
Follow-up to #23211. Fixed issues, and simplified where possible.

The core idea: every sortable list now resolves its drop position the
same way —
"sortable over sortable", comparing the pointer against the hovered
item's
midpoint — instead of each surface owning bespoke droppable slots and
end-drop
zones.

## Refactors
- New `resolveDropFromPointer` handles both axes in one util; items can
tag their
  own `orientation`, so one provider can drive lists of mixed axes.
- Dropped `DragDropItemDroppableSlot` and `DragDropItemDropLine` path.
Record table/board headers, page-layout tabs & widgets, and fields
config all
derive the drop index from the hovered sortable, matching record-board
cards.
- `DragDropItemSortableCell` is now the single sortable primitive, with
drag
  optionally delegated to an explicit `DragDropItemSortableHandle`.
- Removed end-drop constants/types; lists now place a trailing append
target and
  resolve the append position in the consumer's own index space.

## Fixes
- Dragging a row within grouped records threw an error — the drag
overlay now
  resolves the source row's record-group context.
- Multi-select drag counter chip didn't show — drag state was read from
the
  wrong component scope instead of the active `recordIndexId`.

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

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-08-05 14:51:51 +00:00
Charles Bochet 6e30405489 Bump vulnerable dependencies flagged by ECR image scanning (#23813)
## Context

The Oneleet monitor **"AWS ECR repository image vulnerabilities are
remediated"** is alerting on `prod-twenty` images: 1 CRITICAL + 6 HIGH
advisories breach their SLA in 7 days, plus a set of MEDIUMs. All of
them are npm packages baked into the image.

## Changes

| Package | Before | After | How | Advisories |
|---|---|---|---|---|
| undici | 7.28.0 / 6.27.0 | 8.9.0 | jsdom `^30` bump + node-gyp
refresh; global `undici: ^8.9.0` resolution for
@module-federation/dts-plugin, e2b and miniflare, which still pin 7.28.0
at latest (replaces the old scoped dts-plugin resolution) |
CVE-2026-13697 (critical), CVE-2026-14643, CVE-2026-15157/16728/16729 |
| sharp | 0.34.5 | 0.35.3 | direct bump in twenty-sdk; @argos-ci
refresh; `next/sharp` resolution (next 16.3.0 with the fix is still
quarantined by yarn's minimal-age gate) | GHSA-f88m-g3jw-g9cj |
| axios | 1.17.0 | 1.19.0 | lockfile refresh | GHSA-gcfj-64vw-6mp9 + 10
medium |
| ip-address | 10.2.0 | 10.4.0 | lockfile refresh | CVE-2026-69192,
CVE-2026-54272, CVE-2026-69198 |
| brace-expansion | 2.1.2 | 2.1.4 | lockfile refresh (backport exists;
Inspector only lists 5.x) | CVE-2026-69152, CVE-2026-14257,
CVE-2026-13149 |
| typeorm | 0.3.29 | 0.3.31 | pin bump; the local yarn patch applies
unchanged | GHSA-2rp8-mm9q-fp49 |

## Validation

- `yarn.lock` contains no remaining vulnerable versions (undici resolves
only to 8.9.0)
- `yarn npm audit`: no remaining advisories among the bumped packages
- `nx build` green for twenty-server, twenty-front (exercises
module-federation dts-plugin on undici 8), twenty-sdk, twenty-website;
twenty-server typecheck green (typeorm patch is type-level)
- Runtime smoke: jsdom 30 DOM parse, sharp 0.35.3 png encode, undici
8.9.0 load

## Not covered

- **react-router / react-router-dom 6.30.4** (medium, 1–3 month SLA):
react-router-dom 6.x has **no fixed release**; the fix is the v7
migration (~225 files) — separate effort.
- `prod-business-dash` body-parser 2.2.2 → 2.3.0 lives in its own repo.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23813?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-08-05 14:42:43 +00:00
Félix Malfait 198ffbb6bd Cancel drag activation when the drag source unmounts mid-gesture (#23816)
## Summary

Follow-up to the Sentry error
[TWENTY-FRONT-HJ8](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-HJ8)
(`Cannot start a drag operation without a drag source`), seen on a
dashboard page and discussed in #23752.

A pointer drag only activates once the pointer travels past the
activation constraints (distance/delay). dnd-kit's `PointerSensor`
captures the pressed draggable on pointerdown, and when the constraint
is satisfied it starts the drag by resolving that draggable's id in the
registry. If a re-render unregistered it in between, the lookup fails
and `manager.actions.start()` throws.

That window is real in our UI: virtualized table rows remount under new
per-instance sortable ids, and widgets/tabs remount while a page loads.
The breadcrumbs of the Sentry event show the gesture straddling a
navigation onto a loading dashboard, with the error firing on the
activating `pointermove` 190ms later.

## Fix

`PointerSensorWithSourceGuard` extends `PointerSensor` and checks the
registry before starting: if the pressed draggable is gone, it cancels
the gesture through the sensor's own cancel path (same one dnd-kit wires
to activation aborts) instead of throwing. There is nothing left to drag
at that point, so cancel is the correct outcome. `DND_KIT_SENSORS` now
uses it, which covers every dnd surface.

Unit tests pin the behavior with real dnd-kit internals: the base sensor
throws in this scenario (documents why the guard exists, and breaks if
upstream fixes it so we can remove the subclass), the guard cancels and
leaves the operation idle, and a still-registered source starts
normally.

Fixes TWENTY-FRONT-HJ8


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23816?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-08-05 16:29:45 +02:00
Félix Malfait 3f9137bd4e Fix widget view save paths dropping relationTargetFieldMetadataId (#23814)
## Context

`viewFilter.relationTargetFieldMetadataId` (relation traversal, added in
2.6.0) is accepted and persisted by the `upsertViewWidget` mutation, and
`mapViewFiltersToFilters` restores it when loading a widget view. But
two frontend mappers silently dropped it, so any relation-traversal
filter on a record table widget view was lost the moment the layout was
saved (or the moment the user edited the widget's filters in the side
panel):

- `useSaveRecordTableWidgetViews` omitted the field when building the
`upsertViewWidget` input
- `useRecordTableWidgetFilterCallbacks` omitted it when syncing current
record filters back into the widget view draft

## Changes

- Carry `relationTargetFieldMetadataId` through both mappers
- Add regression tests for both hooks (they fail without the fix)

This is a prerequisite for nested relation field widgets (see follow-up
PR), which rely on a traversal filter surviving the widget view save
path.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23814?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-08-05 16:08:25 +02:00
Marie 5b6734691c Fix workflow email body overflowing into the attachments section (#23810)
before (introduced by https://github.com/twentyhq/twenty/pull/23657)
<img width="381" height="736" alt="Screenshot 2026-08-05 at 15 18 38"
src="https://github.com/user-attachments/assets/91d5d1de-bffb-4508-a488-2fe6a09eca61"
/>

after
<img width="1308" height="748" alt="image"
src="https://github.com/user-attachments/assets/06a9b875-6cde-47c6-a23f-f9a1f268db42"
/>


## Problem

In the workflow Send Email action panel, the Body editor painted over
the Attachments section when the panel was shorter than the editor's
minimum height.
2026-08-05 14:00:18 +00:00
nitin 4137a1f9fc Stop classifying Recall bot-detection timeouts as NOT_RECORDED in call-recorder (#23812)
Removes the two `timeout_exceeded_only_bots_detected_*` sub codes from
`NOT_RECORDED_RECALL_SUB_CODES`, so a bot-detection leave is handled
like any other call ending (`call_ended` -> PROCESSING -> artifact
import -> COMPLETED).

These sub codes are leave reasons, not capture verdicts. Bot detection
only fires when participants are present (otherwise `noone_joined` fires
first), and any participant starts the recording, so a bot-detection
ending virtually always has a real recording behind it. It is also the
app's own configured exit path whenever a third-party notetaker
(Fireflies, Otter) lingers after the humans leave, since a lingering bot
keeps `everyone_left_timeout` from ever firing. Classifying it as
NOT_RECORDED stamped successfully recorded calls as failures and skipped
artifact import; bots in the production Recall workspace end with this
sub code near-daily.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23812?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-08-05 13:54:42 +00:00
github-actions[bot] 45cbdef930 i18n - translations (#23806)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23806?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-08-05 14:39:27 +02:00
Félix Malfait 29e68a7f87 Refactor outbound email content compilation (#23782)
## Integration status

This is now the final landing PR for the reviewed editor/email
architecture stack.

| Order | Pull request | Scope | Status |
| --- | --- | --- | --- |
| 0 | #23657 | Advanced text editor capability presets | Merged into
`main` |
| 1 | **This PR** | Outbound email content compilation | Ready to land
into `main` |
| 2 | #23783 | Clean editor surface seam | Reviewed and merged into this
branch |
| 3 | #23790 | Shared editor block catalog | Reviewed and merged through
#23783 |
| 4 | #23791 | Canonical TipTap document persistence | Reviewed and
merged through #23790 |

The current branch tree contains the complete stack. Merging this PR
lands all four follow-up layers.

## Architecture

The stack establishes four reusable boundaries:

1. **Outbound compilation** — campaign, workflow, and one-to-one/tool
email share one compiler, sanitizer policy, renderer, and plain-text
derivation path.
2. **Editor surface profiles** — the generic editor owns rendering
mechanics while each consuming surface declares chrome, extensions, and
explicit compatibility readers.
3. **Shared block primitives** — sections, columns, HTML, images,
buttons, and related commands live in the neutral advanced-editor
catalog; email behavior is supplied by email schemas/rendering, not by
relocating reusable blocks into an email editor.
4. **Canonical persistence** — Twenty-owned authoring persists complete,
versioned TipTap JSON documents. HTML, Markdown, plain text, and
BlockNote are projections or explicitly owned legacy boundaries.

## Compatibility boundaries

Compatibility remains only where shipped data requires it:

- workflow Send Email: versionless TipTap JSON, HTML, and plain text
- inline email: HTML
- AI instructions: Markdown
- record rich text: BlockNote arrays and older Markdown/plain text

Campaign is unshipped, so its editor, stored rows, sendability
validation, and send-time compilation require the current canonical
schema version. AI chat drafts are canonical-only local state; old or
malformed drafts are rejected at hydration, and plain-text preprompts
are converted at their entry point.

## Outbound compiler details

The shared compiler owns:

- strict structured email-document parsing
- React-email rendering
- one cached DOMPurify/JSDOM policy for structured and legacy HTML
- plain-text derivation from sanitized HTML
- single-pass structured-document binding resolution across text,
variable tags, links, images, buttons, and raw HTML

Resolved workflow values remain inert, legacy workflow and one-to-one
HTML remain supported, and Campaign HTML/plain text come from the same
compiled result.

## Verification

- all automated standard/security reviews passed on the three merged
upper PRs with no unresolved threads
- shared TipTap/email codec tests: 20 passing
- editor, AI draft, and workflow compatibility tests: 14 passing
- campaign validation and compilation tests: 31 passing
- full shared suite during development: 223 suites / 1,738 tests passing
- twenty-front, twenty-shared, and twenty-server typechecks
- changed-file type-aware lint and formatting checks
2026-08-05 14:31:50 +02:00
Raphaël Bosi 76bf3651bb Fix stray bracket after AI chat chips (#23798)
Chips in the AI chat sometimes rendered with a leftover `]` after them.

The reference marker is bracket-asymmetric: it opens with `[[` and
closes with `[[/kind]]`, so a complete reference holds four `[` and only
two `]`. The model balances that by writing `…[[/object]]]`, and the
parser ended the match exactly at the close tag, leaving the extra
bracket as prose next to the chip.

The parser now absorbs up to as many surplus `]` as the reference opened
with, and accepts an opener with extra `[` so an over-wrapped marker
doesn't leak one either. The system prompt also tells the model the
marker is complete as written.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23798?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-08-05 12:04:49 +00:00
nitin f893b214e2 Make the call recorder transcript provider an application variable (#23789)
## What

Post-call transcription was locked to Gladia by a frozen constant
(#23532). It is now a `CALL_RECORDER_TRANSCRIPT_PROVIDER` application
variable that a workspace admin picks in app settings.

## Changes

- **Recall.ai transcription (`recallai_async`) is the default.** It is
the only provider that needs no third-party key in the Recall dashboard,
so a fresh install transcribes without extra setup. Gladia
(`gladia_v2_async`) stays available with code switching for
mixed-language calls, and still requires a Gladia API key in the Recall
dashboard per region.
- Providers name their language options differently (`recallai_async`
takes `language_code`, `gladia_v2_async` takes
`language_config.code_switching`), so the variable holds a Recall
provider id and the app owns the per-provider payload in
`RECALL_ASYNC_TRANSCRIPT_PROVIDERS`. Unset or unrecognized values fall
back to the default, matching how `getBotImageBackground` and
`isCallRecordingSummaryEnabled` read their variables.
- `SETUP.md` now frames the Gladia key as conditional on that selection
rather than a hard requirement, and lists the new variable alongside the
other application variables.
- App bumped to 1.7.0. No new server capability is needed, so
`engines.twenty` stays at `>=2.26.0`.

## Upgrade note

Existing installs run on Gladia today through the old constant, and move
to Recall.ai transcription on upgrade unless the variable is set.
Workspaces that rely on code switching for mixed-language calls should
select Gladia after deploying.

## Tests

533 unit tests pass, typecheck and lint clean.

- `recall-bot-api.test.ts` asserts the `create_transcript` request body
for both the default and a Gladia selection.
- `get-recall-async-transcript-provider.test.ts` covers the default, the
fallback for an unsupported provider, and a drift guard asserting the
manifest's SELECT options match the keys of the provider map, so adding
a provider to one without the other fails.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23789?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-08-05 11:51:44 +00:00
github-actions[bot] 5c6ad77563 i18n - translations (#23803)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-05 13:42:46 +02:00
Raphaël Bosi 59672b71b8 Add a book-a-call onboarding step for qualified leads (#23521)
https://github.com/user-attachments/assets/76d5a14e-53bd-4195-963b-bf9bb265c8c1



Large-company signups either self-serve a small plan or drop off at the
paywall without sales ever seeing them. This adds an embedded Cal.com
booking step to onboarding, shown only to leads worth a call.

The step sits between Invite Team and the plan step: the lead has built
out a workspace by then, and sales gets a chance before checkout. It is
always skippable, and a successful booking advances automatically.

Qualification reuses the employee count from the People Data Labs
enrichment added in #23199. `ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT`
sets the bar; leaving it unset means the step never appears.
`CALENDAR_BOOKING_PAGE_ID` must also be configured, so the step can
never strand someone on an empty embed.

Enrichment is no longer gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, since
the book-a-call step is now a second consumer of it.
`PEOPLE_DATA_LABS_API_KEY` remains the instance-level switch.

The existing `/book-call` page is reused: it moves into the onboarding
shell and its footer switches between Skip (as a step) and the back link
(when reached from the plan page).


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23521?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-08-05 11:34:06 +00:00