Commit Graph

1449 Commits

Author SHA1 Message Date
Abdul Rahman ded3f1efb3 Add messages support to runAgent for multi-turn bot conversations (#23395)
- Extend `runAgent` so callers can pass either a one-shot prompt or a
multi-turn messages array (user / assistant text), matching AI SDK’s XOR
shape — for Slack/Discord/Teams bots that need thread history.
- Enforce exactly one of prompt | messages in AgentRunService; map
messages 1:1 to AI SDK ModelMessages in AgentAsyncExecutorService
- Update shared types, GraphQL/SDK inputs, docs (skills-and-agents), and
regenerate metadata clients; existing prompt-only callers stay unchanged

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23395?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 08:20:12 +00:00
Paul Rastoin 8bfa9c4adb Proxy API routes through the vite dev server to keep local dev same-origin (#23779)
Replaces #23774 (closed), rebased on latest main.

## Problem

Since the cookie-session migration (#23642), the front sends every
request with `credentials: 'include'` and the server only reflects
`Access-Control-Allow-Origin` for the exact origins in the credentialed
allowlist (`SERVER_URL`, `FRONTEND_URL`, `AUTH_COOKIE_ALLOWED_ORIGINS`).
Any other origin gets the `*` wildcard, which browsers reject for
credentialed requests.

Local dev is split-origin by default (front on `localhost:3001`, API on
`localhost:3000`), and with `IS_MULTIWORKSPACE_ENABLED` every workspace
subdomain (`apple.localhost:3001`, ...) is yet another origin. Each
locally created workspace would need a manual
`AUTH_COOKIE_ALLOWED_ORIGINS` entry.

## Solution

Make local dev same-origin instead of widening the CORS policy: the vite
dev server now proxies all top-level API route prefixes to the backend,
and the front calls its own origin.

- `vite.config.ts` adds a `server.proxy` covering the backend's
top-level prefixes (`/graphql`, `/metadata`, `/admin-panel`, `/auth`,
`/rest`, `/file`, `/client-config`, ...), defined in
`src/config/apiProxyPrefixes.ts`. Keys are anchored regexes
(`^/auth($|[/?])`) so SPA routes sharing a prefix (`/authorize`,
`/settings`) are not swallowed. The target defaults to
`http://localhost:3000` and follows `REACT_APP_SERVER_BASE_URL`.
`changeOrigin` stays off so the backend sees the browser's Host:
same-origin checks (CSRF, cookie issuance) and workspace resolution by
subdomain work unchanged through the proxy.
- `config/index.ts` collapses to
`window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin`.
Every supported production path injects `window._env_` (docker
entrypoint fails hard without `REACT_APP_SERVER_BASE_URL`; a
server-served front gets it from `generateFrontConfig()`), and in dev
the current origin is correct on `localhost:3001` and every
`*.localhost:3001` workspace subdomain thanks to the proxy. The removed
`http://<hostname>:3000` fallback only served an un-injected production
bundle browsed on localhost, a setup whose credentialed auth the
cookie-session migration had already broken.

The credentialed allowlist itself is unchanged and stays strict; since
dev traffic is same-origin, the per-subdomain cookie-allowlist problem
disappears without loosening any production CORS/CSRF policy.

## Tests

- `src/config/__tests__/apiProxyPrefixes.test.ts` guards the proxy
boundary in both directions: representative backend path shapes
(including `/metadata?query=...` and `/auth/...`) must match, every SPA
route from the `AppPath` enum and vite's own dev paths must not — so a
future route collision fails unit tests instead of breaking dev.
- Verified against running dev servers: API paths proxy to the backend
from both `localhost:3001` and `apple.localhost:3001`, while SPA routes
`/settings` and `/authorize` still serve the vite app; a same-origin
POST from `apple.localhost:3001` goes through with no CORS involvement.
- `lint:diff-with-main` and `typecheck` pass for twenty-front.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-08-05 08:08:51 +00:00
Abdul Rahman 3a646ffcb0 feat(connections): add an onDisconnect lifecycle hook to connection providers (#23538)
Platform half of the follow-up to
https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334.
The Slack app claims a `team_id` on connect and had no way to release
it, because connection providers only had an on-connect hook. Nothing
here is Slack-specific, so it targets `main`. The app side is #23540, on
top of `feat/slack-bot`, and waits on this plus an SDK release.

## What changes

`defineConnectionProvider` accepts `onDisconnectLogicFunction` alongside
`onConnectLogicFunction`. It is stored on
`connectionProvider.onDisconnectLogicFunctionUniversalIdentifier` (fast
instance command `2.26.0_...1785350000000`) and enqueued right after the
`ConnectedAccount` row is deleted, in the disconnecting workspace, with
the same payload as on-connect:

```ts
type OnDisconnectPayload = {
  connectionProviderId: string;
  connectionProviderName: string;
  connectedAccountId: string;
};
```

The `ConnectedAccount` is gone by the time the hook runs, so
`getConnection` no longer resolves. Anything the cleanup needs has to be
in the key-value store, written at connect time and keyed by
`connectedAccountId`. The docs section spells that out, along with the
fact that uninstalling an app drops its connections through a cascade
that never reaches this hook, where `uninstallLogicFunction` is the
right tool instead.

Both dispatches moved into a new
`ConnectionProviderLifecycleHookService`, so
`ConnectionProviderOAuthFlowService` no longer owns hook plumbing and
`ConnectedAccountMetadataService.delete` can reuse it. On-connect
behaviour is unchanged: best effort, never blocks the caller, failures
go to Sentry.

## Tests

- `connection-provider-lifecycle-hook.service.spec.ts`: the on-connect
cases moved over, plus on-disconnect dispatch, no-hook, and
missing-provider cases
- `connection-provider-oauth-flow.service.spec.ts`: now asserts
delegation to the lifecycle hook service
- SDK validation, manifest duplicate-identifier, and manifest to flat
converter specs extended

Server unit tests and typecheck for shared, sdk and server pass locally.
2026-08-04 02:47:23 +00:00
Félix Malfait f8e3fd110d Bound an application by its own role as well as the user's (#23680)
## Why

When an application acts on someone's behalf its token carries `userId`
and `userWorkspaceId` alongside `applicationId`, and the application
then received **that person's permissions in full**. The role it
installs with was never consulted, so it was not a bound on what the
application could do for them. It was meant to be an intersection.

`permissions.service.ts` made this visible: the branches are `apiKeyId →
userWorkspaceId → applicationId` and each returns early, so with both
present the user branch won and `application.defaultRoleId` was never
read. The same was true on the object and row-level paths, for different
reasons.

## What had to change

Three independent causes, all of which blocked the intersection from
existing or from being enforced.

**The application was thrown away before anything could use it.**
`workspace-auth-context.middleware.ts` built a `type: 'user'` context
when both principals were present, and `UserWorkspaceAuthContext` had no
slot for an application. It now carries an optional one.

Additive rather than a new union member on purpose. Nothing in the
server exhaustively checks this union (no `assertUnreachable`, one
`switch`, in Sentry tagging), so a sixth member would have compiled fine
and then fallen through actor attribution, that switch, and
`metadata-event-emitter.ts` silently. The additive change leaves all
four type guards returning identical booleans.

**Role resolution returned a single id.** The rule itself now lives in
one place, `resolveRoleIdsForUser`: a user's role, narrowed by the
application's if it declared one, never the same id twice.
`resolveRoleIdsFromAuthContext` and `resolveRolePermissionConfig` build
`{ intersectionOf: [...] }` from it, which `getRepository` already
applied over N roles. A user with no role still resolves to nothing, so
an application can never stand in for a missing user role.

**Row-level security ignored all of it.** RLS was re-derived from a
single role at query time, so it would have been unaffected by any
intersection. Each role is now compiled on its own and the resulting
filters are ANDed.

That last choice matters. Merging the raw predicates and groups first
would have been wrong: `computeRecordGqlOperationFilter` honours only
the first parentless group, so concatenating two roles' groups makes one
role's predicates vanish, **widening** access. Compiling per role and
ANDing needs no synthetic groups, no re-parenting and no `twenty-shared`
type change, and reuses the single-role logic untouched.

Subscriptions go through the same rule. An event stream resolved only
the subscriber's role, so a stream opened by an application acting for
someone was filtered by that person's role alone. The stream now records
the application it was opened by and the publisher intersects both roles
for object permissions, restricted fields and RLS, exactly as a query
does.

Two smaller fixes fall out:

- `getObjectsPermissionsFromRolePermissionConfig` had a `// Multi-role
union/intersection is not ready — use the first assigned role only`
shortcut and now intersects.
- `computePermissionIntersection` hardcoded empty row-level predicate
arrays, which is why RLS-constrained fields were not exempted from the
field-permission check on insert and could fail spuriously. It now
reports the fields **every** role constrains. Reporting fields
constrained by only one role would be worse than the original bug: the
insert guard waives a field-update deny on them, so one role's row-level
rule would cancel another role's deny.

## Behaviour on the edges

**An application that declares no role adds no bound.** `defaultRoleId`
stays null whenever a manifest omits `defaultRoleUniversalIdentifier`,
which is the common case, so denying would have broken a lot of
installed applications. Behaviour changes only for applications that
actually declared a role.

To stop that being permanent, `defaultRoleUniversalIdentifier` should
become required for new applications. Hard-requiring it needs a backfill
for existing installs, so it is not in this PR.

**An application that cannot be found denies.** That is not the same as
one that declared no role, and treating it as such would have let a
token naming a deleted application fall back to the full permissions of
the user it acts for.

**A role that cannot be resolved denies.** `application.defaultRoleId`
is a plain uuid column with no foreign key, and role deletion does not
clear it, so it can dangle. A bound we cannot apply must not let the
remaining roles decide on their own, so the ORM path,
`getObjectsPermissionsFromRolePermissionConfig` and the subscription
publisher all return no permissions in that case rather than falling
back.

## Testing

- Full `twenty-server` unit suite green (896 suites, 7353 tests)
- New spec for `resolveRoleIdsFromAuthContext`: both roles, application
with no declared role, application holding the user's own role, user
with no role, api key, application-only, system
- New spec for multi-role RLS, including the case this fixes (a
restricted role intersected with an unrestricted one keeps the
restriction) and two restricted roles ANDing
- `permissions.service.spec.ts` had **no coverage of the application
branch at all** (`ApplicationEntity` was mocked as `{}`); it now has a
real mock plus user-grants/application-denies, the reverse, both-grant,
the null-role fallback, a shared role, and a missing application
- First coverage of non-empty row-level predicates through
`computePermissionIntersection`, including a field constrained by one
role only
- Subscription publisher: application role denies, both allow,
application role dangling, and both roles reaching the RLS filter
- Updated the two specs that asserted the old behaviour: the middleware
dropping the application, and "use the first role when multiple are
provided"

No schema, cache or GraphQL change: `rolesPermissions` is keyed by role
id alone and the intersection is computed per request from cached
per-role entries.

## Not in this PR

`workflow-execution-context.service.ts` falls back to the **admin** role
when an application has no `defaultRoleId`, and to
`shouldBypassPermissionChecks: true` if admin is not found. That is the
inverse of the rule here and an escalation in its own right, but
workflow execution is sensitive, so it is tracked separately in
twentyhq/core-team-issues#2753.

Three resolvers still carry their own principal precedence and do not
use this seam: `rest-api-base.handler.ts`, `mcp-protocol.service.ts`
(which never builds a user or application context at all), and actor
attribution in `actor-from-auth-context.service.ts`.

Separately, `computeRecordGqlOperationFilter` silently discards
predicates under any parentless group after the first, with no test
coverage. That is a latent bug independent of this work and lives in
`twenty-shared`, shared with the front-end filter system.

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



<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23680?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-03 17:01:21 +00:00
Raphaël Bosi 4a5c623ece Improve the workspace setup kickoff prompt (#23594)
Rewrites the workspace setup chat kickoff prompt for conversion: the
goal is a real workspace the team keeps using, with the setup doubling
as a tour of what Twenty can do.

- Replaces the arbitrary bands (2-4 custom objects, 3-6 fields, 250
words) with admission tests, favoring custom fields on standard objects
over custom objects.
- Opens by sharing what we already know about the company and the user's
role, then lets them steer: propose a model right away, or hear their
use case first.
- After the data model there is no fixed script. The agent proposes the
single next capability worth building (workflow, dashboard, role) based
on what the user actually said, and names the ones it did not build
before closing so nothing stays hidden.
- Introduces each capability in one plain sentence where it comes up,
and drops the view-field step that #23585 made redundant.

Also passes the workspace member job title into the AI chat user
context, so the agent can shape the setup around what the user does.
This applies to every chat, not just onboarding.

Example: Creating an Apple workspace

<img width="2584" height="5022" alt="CleanShot 2026-08-03 at 15 33
48@2x"
src="https://github.com/user-attachments/assets/bc12ec95-e29d-42fd-8757-38ab3a8a5705"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23594?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-03 13:40:57 +00:00
Paul Rastoin 6afc3d33a4 fix: provision INDEX view fields for relations created in the same batch as their object (#23665)
## Context

Fixes twentyhq/core-team-issues#2749: when an app manifest creates an
object and its relation fields in a single sync, the engine-owned INDEX
view ended up with no viewField at all for RELATION / MORPH_RELATION
fields, not even a hidden one. Adding the same relation to a
pre-existing object in a second sync produced a visible viewField.

## Root cause

`fieldIndexViewFieldOnCreate` is the sole owner of caller-field view
fields on the INDEX view (`objectSystemFieldsAndIndexViewOnCreate` only
emits view fields for displayable system fields). Its same-batch branch
gated on `isFlatFieldMetadataDisplayableInDefaultView`, which excludes
RELATION / MORPH_RELATION, so relations were dropped and nothing else
picked them up.

The guard's other exclusions (reserved names like `id`/`deletedAt`,
system-only types TS_VECTOR / POSITION) are unreachable there: side
effect handlers only trigger on caller-authored entities (the engine
reads triggers from the pre-expansion matrix), and the flat field
validators reject those names/types for caller fields. The guard could
only ever drop relations.

## Fix

- Remove the displayability guard from
`buildViewFieldForObjectCreatedInSameBatch`.
- Remove the `displayableOnly` filter from
`computeCallerFlatFieldMetadatasForObject`: every caller field now gets
a view field, and both handlers keep deriving positions from the same
list, so the interleaved layout stays consistent by construction (label
identifier, caller fields in input order with relations, then
displayable system fields).
- Build the view field literal through a single
`buildIndexFlatViewFieldToCreate` helper on all handler branches instead
of `computeFlatViewFieldsToCreate`, whose internal displayability filter
would have dropped relations again. That util keeps its semantics for
its remaining callers (system-field view fields, object creation via
API, committed upgrade commands). Both identifier derivations are the
same deterministic uuid (asserted by an existing twenty-shared spec), so
emitted identifiers are unchanged.
- `isFlatFieldMetadataDisplayableInDefaultView` itself is untouched: the
committed 2-26 upgrade command and the system-field filtering still rely
on its current semantics.

## Tests

- New manifest-sync integration test (first commit, TDD red then green):
a single sync creating two objects and a MANY_TO_ONE / ONE_TO_MANY
relation pair asserts each object's INDEX view has a visible view field
for its relation, plus a control case adding the same relations to
pre-existing objects in a second sync.
- Unit spec: the test that locked in the noop now asserts a visible view
field at the expected position for RELATION and MORPH_RELATION.
- Verified locally: all 62 metadata-side-effect unit tests, the new
integration spec, `successful-sync-application-workspace-migration` (4
snapshots), `relabel-onto-new-field-manifest-sync`,
`create-one-field-metadata-relation`, plus twenty-server typecheck and
lint.
2026-08-03 09:23:12 +00:00
github-actions[bot] 9df893ead1 chore: sync AI model catalog from models.dev (#23682)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-08-03 09:01:12 +02:00
github-actions[bot] d77b1017aa chore: sync AI model catalog from models.dev (#23666)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-08-01 08:47:11 +02:00
Paul Rastoin 4f8aaeaab0 refactor(navigation-menu-item): validate universal properties instead of ids (#23566)
Follow-up to the discussion on #23485 and closes
https://github.com/twentyhq/twenty/issues/23484.

`FlatNavigationMenuItemValidatorService` receives
`UniversalFlatEntityValidationArgs<'navigationMenuItem'>`, so the entity
it validates is a `UniversalFlatNavigationMenuItem`: `viewId`,
`pageLayoutId` and `targetObjectMetadataId` do not exist at that scope.
The validator read the right universal keys but passed them through a
bag of booleans named after the ids (`hasViewId`, `hasPageLayoutId`,
...) and then reported the id names in its errors. Nothing tied a
message to the property it checked, so fixing one message string leaves
the other five wrong.

## Changes

- Replace the private `validateNavigationMenuItemType` boolean bag with
`validateNavigationMenuItemTypeRequiredProperties({
flatNavigationMenuItem })` under
`flat-navigation-menu-item/validators/utils/`, in line with
`validateAgentRequiredProperties` and
`validateNavigationMenuItemPageLayoutReferenceCrossEntity`. It takes the
universal entity, so a message can only name a property that exists at
that scope.
- The util is an explicit `switch` on `NavigationMenuItemType` closed by
`assertUnreachable`, so adding a type fails to compile until its
contract is declared.
- Each case validates its own properties instead of checking presence
generically:
  - `FOLDER`: non blank `name`
- `OBJECT`, `VIEW`, `PAGE_LAYOUT`:
`targetObjectMetadataUniversalIdentifier` / `viewUniversalIdentifier` /
`pageLayoutUniversalIdentifier` must be valid uuids
- `RECORD`: `targetRecordId` and
`targetObjectMetadataUniversalIdentifier`, both uuids, reported
separately
  - `LINK`: `link` must pass `isValidUrl`
- Both call sites spread the result; the update path passes the merged
`{ ...from, ...update }` entity, which removes the redundant `name`
re-merge.

`targetRecordId` stays an id: it points at workspace record data rather
than metadata, so it has no universal counterpart.

## Behaviour

- Errors name the universal property (`viewUniversalIdentifier`) instead
of the id (`viewId`).
- Blank strings are now uniformly treated as missing; creation
previously accepted `link: " "`.
- `RECORD` reports each missing property separately instead of one
merged error.
- Values that are present but malformed are now rejected: non uuid
identifiers and links that are not urls. Standard application
identifiers are all v4 uuids and the create/update inputs already carry
`@IsUUID`, so this only tightens the app manifest path.

## Verification

- Unit tests for the util cover each type valid and invalid, blank
names, non url links and non uuid identifiers (23 tests pass alongside
the sibling suite)
- `nx typecheck twenty-server` clean
- oxlint (type-aware) and oxfmt clean on the changed files

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23566?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 16:35:16 +00:00
Félix Malfait f663cd3c68 Move open-record-in to object metadata and member preference (#23614)
Replaces the per-view "Open in" setting with a two-level model,
following up on #23422 / #23424 and superseding the closed #23446 and
#23457:

- `objectMetadata.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` |
`USER_CHOICE` (default `USER_CHOICE`)
- `workspaceMember.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` (default
`SIDE_PANEL`), editable in Settings > Experience

The rule: records open where the member prefers, unless the object pins
them, and never in a panel there is no room for (mobile always resolves
to the record page).

## Why

Having the setting on views, objects and members at once was heavy, and
view-level resolution was fragile: a chip rendered outside a view
(notes, front components, kanban cards pointing at another object) had
no view to read from, which is the class of bug behind #23422.
Resolution is now context-free: it needs only the object, the current
member and the viewport, so chips behave identically everywhere by
construction.

## Changes

**Object level**
- New `openRecordIn` enum column on `objectMetadata`, editable through
`updateOneObject` and surfaced in Settings > Data model > Object >
Layout ("Open records in": Member preference / Side Panel / Record Page)
- Standard definitions pin `workflow`, `workflowVersion`, `dashboard`
and `messageCampaign` to the record page (matching the previously
hardcoded list) and `calendarEvent` to the side panel (it has no curated
record page); everything else, including `workflowRun`, follows the
member preference
- Apps can set it in `defineObject()` via the object manifest

**Member level**
- New `openRecordIn` standard field on `workspaceMember`, persisted
through the existing settings path (same as `colorScheme`) and exposed
in Settings > Experience

**View level (deprecated)**
- `view.openRecordIn` is no longer read or written by the frontend; the
"Open in" entry is gone from the view options dropdown
- The column, DTO field and inputs are kept for one release for API
compatibility: the output field carries a `deprecationReason`, the
inputs keep accepting the value with a `Deprecated:` description (NestJS
silently drops input fields that have a `deprecationReason`, which would
have been a breaking change)

**Upgrade (2.27)**
- Fast instance command adds the `objectMetadata.openRecordIn` column
defaulting to `USER_CHOICE`
- Workspace command adds the `workspaceMember.openRecordIn` field
- Workspace command seeds the object column from the standard
definitions (any non-`USER_CHOICE` value), then lifts deliberate
per-view record page choices onto objects the definitions don't pin

**Debt removed**
- `canOpenObjectInSidePanel` hardcoded object list and its test
- `ObjectOptionsDropdownLayoutOpenInContent` and the `layoutOpenIn`
dropdown wiring
- `DefaultViewOpenRecordIn`
- Context-store/view-based resolution in `useResolveOpenRecordIn` (now
reads object metadata + member + viewport)
- Front components no longer guess from the current view: an explicit
side-panel call honours a pinned object and the viewport, nothing else

## Verification

- Ran the three upgrade commands against a live database: column
created, the pinned standard objects seeded per workspace (record page
pins plus calendarEvent to side panel), member field backfilled to
`SIDE_PANEL`; seed rerun is a no-op
- Seed command verified on a simulated pre-upgrade workspace (index view
set to record page on company): pins the standard objects plus company,
idempotent on rerun
- Both packages typecheck and lint clean; affected unit suites and the
application sync, view creation and metadata cache integration specs
pass

---------

Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
2026-07-31 18:28:55 +02:00
Raphaël Bosi 0f06fccdee Make page layout tab layoutMode diffable and default standalone pages to vertical list (#23596)
Reported on Discord: an app declared a `STANDALONE_PAGE` layout with one
`FRONT_COMPONENT` widget and got a small bordered card instead of a
full-bleed page, and setting `layoutMode` afterwards changed nothing.

Two bugs:

- `pageLayoutTab.layoutMode` was `toCompare: false`, so it was written
once at create and never diffed again. Changing it in a manifest and
redeploying was a silent no-op, and `updatePageLayoutTab(layoutMode:)`
was accepted by the API then dropped by the runner's update sanitizer.
- A manifest tab that omits `layoutMode` defaulted to `GRID` regardless
of page layout type, and a `GRID` tab always renders its widgets as
cards on a 12-column grid. Standalone pages now default to
`VERTICAL_LIST`, where a lone widget owns the tab.

Also fixes the SDK scaffolder (`twenty add page-layout` emitted a tab
with no `position`, which does not typecheck) and the docs claim that a
single widget is always full-bleed.

Worth knowing for review: this does not migrate workspaces holding
legacy `CANVAS` tabs. The standard-app sync only runs against a fresh
schema, so those rows stay `CANVAS` and keep rendering correctly through
the derived presentation.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23596?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 13:12:19 +00:00
Félix Malfait 5d88cf7f2f feat(server): add role management tools for AI chat (#23613)
Adds role management tools to the AI chat so the agent can create and
configure roles, including row-level permissions.

## What

A `RoleToolProvider` in `tool-provider/providers/`, mirroring
`webhook-tool.provider.ts`, registered in `tool-provider.module.ts` and
gated behind `PermissionFlagType.ROLES` via
`PermissionsService.checkRolesPermissions` (same pattern as the
VIEWS/WORKFLOWS gating). Tools:

- `list_roles` — global record permissions, settings access, per-object
overrides, permission flags, assignability; optionally includes
row-level rules
- `create_role`, `update_role`, `delete_role`
- `assign_role_to_workspace_member` — via
`UserRoleService.assignRoleToManyUserWorkspace`, which goes through
role-target
- `upsert_object_permissions` — per-object overrides, e.g. read-only on
a given object
- `upsert_row_level_permission_rules` — reuses
`RowLevelPermissionPredicateService.upsertRowLevelPermissionPredicates`
and the predicate-group service, so the agent can express rules like
"members with this role only see records where the owner field matches
the current user" (a predicate with `workspaceMemberFieldMetadataId`
pointing at the workspaceMember `id` field, resolved to the current user
at query time)

Everything routes through the existing role services and DTOs
(`RoleService`, `ObjectPermissionService`, `UserRoleService`, the
row-level predicate services) rather than reimplementing them. A new
`ToolCategory.ROLE` is added to `twenty-shared`, along with its label in
the exhaustive switch in `build-tool-catalog-section.util.ts`.

## Safeguards

- Any mutation on a role with `isEditable: false` is rejected. That
covers the Admin role, which is created non-editable, and matches what
Settings blocks.
- Deleting the role the caller is currently acting under is rejected,
since deletion would rebind them to the workspace default role.
- Setting `canUpdateAllSettings: false` on the caller's own role is
rejected unless that role keeps an explicit ROLES permission flag.
- Changing your own role via `assign_role_to_workspace_member` is
rejected, checked both by workspace member id and by resolved user
workspace id.

The tool-layer checks are deliberate pre-checks: the migration
validators and services enforce the same rules downstream
(`validate-role-is-editable.util.ts`, default-role deletion, last-admin
unassignment, write-without-read consistency), but catching them early
gives the model a named, actionable message instead of a build failure
report. Where the deeper layer does reject, `formatValidationErrors`
expands the migration exception so the underlying per-entity errors
reach the model rather than a generic summary.

Worth flagging for reviewers: the self-lockout protection currently
lives only at the tool layer. A human admin can still strip settings
access from their own role through Settings/GraphQL. Closing that would
mean changing `RoleService`/`UserRoleService` behavior for the human
path, which felt like a separate decision than what this change is
scoped to.

## Notes

`ToolCategory.ROLE` is intentionally left out of
`WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES`, so workflow agents don't get
these tools; only the chat surface and the MCP/tool-index paths that
share the registry do.

## Testing

- 24 unit tests in `providers/__tests__/role-tool.provider.spec.ts`,
covering permission gating, descriptor exposure, each safeguard, the
N+1-free list path, and validation-error surfacing
- 333 tests pass across the tool-provider, role, object-permission and
ai suites
- `npx nx lint:diff-with-main twenty-server` and `npx nx typecheck
twenty-server` are clean


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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23613?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 11:49:23 +00:00
Raphaël Bosi a9084604b4 Use the fast model for the onboarding setup chat (#23586)
The workspace setup chat ran on the smart model. The hidden kickoff turn
enqueued its job without a `modelId`, and the frontend sends none unless
the user picks one, so every turn fell through to `modelId ??
workspace.smartModel` in `chat-execution.service.ts`.

Two halves, since the kickoff is server-initiated and the frontend never
sends it:
- `startHiddenKickoffStream` takes a `modelId` and the setup chat passes
`workspace.fastModel`.
- `useAgentChatModelId` requests `workspace.fastModel` on the setup
page, so user turns follow. Everywhere else it still sends nothing and
the server fallback is unchanged.

`workspace.fastModel` defaults to the `default-fast-model` sentinel, so
the model still resolves through the registry and stays
admin-overridable. An explicit pick from the model picker still wins.
2026-07-31 08:21:02 +00:00
github-actions[bot] a5680d1732 chore: sync AI model catalog from models.dev (#23615)
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-07-31 08:55:36 +02:00
Remi Huigen 830404b215 fix: Decrypt encrypted front component variables (#23494)
## Summary

Fixes #23492

Fixes front-component application variables returning their encrypted
at-rest value instead of their configured plaintext value.

Non-secret application variables (`isSecret: false`) are now decrypted
server-side before being injected into the front-component environment.
Secret variables remain excluded and are never decrypted or exposed to
the browser.

## Root cause

The front-component resolver filtered secret application variables
correctly, but forwarded the cached `encryptedValue` directly. As a
result, `getApplicationVariable()` returned an `enc:v2:...` envelope
rather than the configured value.

## Changes

- Decrypt recognized versioned envelopes for non-secret application
variables.
- Preserve empty and legacy/plain values unchanged for backwards
compatibility.
- Add `SecretEncryptionModule` to the front-component module.
- Add coverage for:
  - decrypting public variables;
  - retaining plaintext compatibility;
  - excluding secret variables without attempting decryption.

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

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-07-30 16:49:52 +00:00
Paul Rastoin bc0ec6b104 Maintain INDEX view system side effects on deactivated views (#23590)
# Introduction

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

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

# Why

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

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

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

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

# Changes

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

`deletedAt` gates are unchanged in both.

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

# Follow-up

Both handlers also drop inactive view fields when computing positions,
while `FlatViewFieldValidatorService` builds its `otherFlatViewFields`
with no `isActive` filter. An inactive view field below all active ones
would make a handler emit a label identifier position the validator then
rejects. Unreachable today (view fields are only ever soft-deleted,
never deactivated), so left out of this PR.
2026-07-30 15:42:30 +00:00
Paul Rastoin 08891db8be Create INDEX view fields visible on field creation (#23585)
# Introduction

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

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

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

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

`objectSystemFieldsAndIndexViewOnCreate` and the 2-26 reconcile command
are untouched.
2026-07-30 15:16:36 +00:00
Raphaël Bosi 5848c9bd30 Display object, field and view links as chips in the AI chat (#23573)
<img width="3840" height="1876" alt="CleanShot 2026-07-30 at 15 37
46@2x"
src="https://github.com/user-attachments/assets/9fe178b9-c2fa-4b05-9c9d-0cdc80270b67"
/>



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



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

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

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

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

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

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


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23573?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 15:08:21 +00:00
Weiko ad271ee639 Add connected account handle/provider index (#23580)
## Context

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

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

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

## What changed

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

The webhook handler and query behavior remain unchanged.

## Why this index

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

## Expected impact

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

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

## Safety and rollout

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

## Validation

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


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23580?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 14:35:24 +00:00
martmull 65155fe50c feat(apps): add enqueueJob to run a logic function on the workers (#23527)
Closes twentyhq/core-team-issues#2742

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

## What it looks like for an app author

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

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

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

## Changes

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

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

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

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

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

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

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

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

## Tests

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

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

## Notes for review

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

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

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-30 14:20:34 +00:00
Raphaël Bosi a3beea893d Revert the external link confirmation popup for front components (#23567)
Reverts #23270 and #23404.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23567?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 12:55:55 +00:00
Paul Rastoin dbd2eac69c Let the instance upgrade version reach releases without instance commands (#23552)
Fixes the CI failure on #23520:

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

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


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

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

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

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

## What changed

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

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

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

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

## Testing

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

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23552?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 12:32:38 +00:00
Raphaël Bosi 38ad13655c Auto-start the workspace setup chat with a data model proposal (#23437)
https://github.com/user-attachments/assets/d447372b-c1b4-4c95-bac9-a8f8efa7d4a1



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

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


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23437?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 12:06:13 +00:00
nitin 079e9b8e56 feat(dashboard): extend number format option to bar, line and pie charts (#23505)
https://discord.com/channels/1130383047699738754/1509604545381142649

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

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

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

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


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



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23505?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 09:42:21 +00:00
Thomas Trompette 4ff9cba76d fix(server): stop the global catch-all filter from shadowing typed GraphQL exception filters (#23508)
## Context

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

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

## Root cause

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

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

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

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

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

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

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

## Why the existing test did not catch it

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

## Fix

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

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

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

## Test

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

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

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

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

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

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

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

## CI follow-up

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

The old `{}` was an artifact of the mock:
`MockedUnhandledExceptionFilter` rethrew, the exception escaped Nest's
handler into Express's default error handler, and supertest saw an empty
body. Production has always run the real `UnhandledExceptionFilter`,
which writes `response.status(status).json(exception.response)` — the
new snapshot. Production HTTP behaviour is unchanged by this PR: no
other global filter matches an `HttpException` (the typed ones rethrow
outside GraphQL), so the same filter handles it whether it is evaluated
first or last.
2026-07-30 07:13:00 +00:00
Etienne a276f3277f feat(workflow): pin concrete model on AI agent node creation and exclude interactive tools from workflow runs (#23447)
## Context

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

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

## What this PR does

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

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

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

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

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

## Test coverage

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23447?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-29 16:48:24 +00:00
Paul Rastoin 4ec65ed08d System view tooling explicit params key naming (#23506)
# Introduction
View field system always result from a field existence, the application
universal identifier should be the related field one
Same but for views and object

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23506?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-29 14:33:40 +00:00
Paul Rastoin 0c545bcdeb [BREAKING-CHANGE] Centralize system View viewField side effect (#23081)
# Introduction

Closes https://github.com/twentyhq/core-team-issues/issues/2669

Part of the `isSystemSideEffect` engine-ownership effort. Until now, a
custom object's default **INDEX** table view (`All {objectLabelPlural}`)
and its view fields were built imperatively in `ObjectMetadataService`
with random `v4()` identifiers, while `twenty-standard` authored its own
copies with hardcoded literals. The two never converged, an object
rename could drift the view, and nothing marked these rows as
engine-owned.

This PR makes the metadata side-effect engine the **single owner** of
the INDEX view and its view fields, on name-free deterministic
identifiers, for custom and standard objects alike.

## Core design

- **Name-free deterministic identity.** The INDEX view identifier
derives from `object identifier + ViewKey.INDEX`
(`getSystemViewUniversalIdentifier`); each view-field identifier derives
from `view identifier + field identifier`
(`getViewFieldUniversalIdentifier`). An object rename (with a pinned
object identifier) keeps the same view, losslessly.
- **`isSystemSideEffect: true` is provenance.** Every INDEX view / view
field the engine emits is flagged system-owned, so manifest deletion
inference never drops it. The flag follows the view: a view field
inherits its parent view's flag.
- **The engine is the sole owner of the INDEX view.** It always emits
it; a caller providing one with the same derived identifier is a genuine
conflict surfaced by the engine's reserved-identifier collision, not
silently deferred.

## Changes

### Shared (`twenty-shared`)

- `getIndexViewUniversalIdentifier` →
`getSystemViewUniversalIdentifier`, now taking a `viewKey` (generalizes
to any singleton engine-owned view).
- Standard field identifiers extracted into a new
`STANDARD_OBJECT_FIELDS` constant, so both an object's `fields` and its
INDEX view read the same field identifiers.
- `buildStandardObjectIndexView` derives the standard INDEX view +
view-field identifiers from `STANDARD_OBJECT_FIELDS`, replacing the
hardcoded literals in `standard-object.constant.ts`.

### Metadata side-effect engine (custom objects)

- **`objectSystemFieldsAndIndexViewOnCreate`** (replaces
`objectSystemFieldsOnCreate`): on object creation, provisions the 7
reserved system fields **and** the INDEX view with one view field per
displayable system field, all `isSystemSideEffect: true`.
- **`fieldIndexViewFieldOnCreate`** (new): on field creation, provisions
the field's INDEX view field. Object created in the same batch →
visible, positioned before the system view fields; pre-existing object →
hidden, appended (preserving the historical `createOneField` behavior).
Both branches resolve the INDEX view by its derived identifier (single
map access, never a scan).
- **`fieldSystemViewFieldsOnDelete`** (new): on field deletion,
cascade-deletes every engine-owned view field displaying it.
- **`objectSystemSideEffectsOnDelete`** (extended): now also
cascade-deletes the object's engine-owned views and their view fields
(in addition to system fields, indexes, searchFieldMetadata). Every
lookup walks a foreign-key aggregator down from the deleted object, so
the work is proportional to what the object owns, never to workspace
size.
- Object-create and field-create positions are derived from the same
caller-input field list, so the INDEX view layout is contiguous with no
handler-ordering dependency.
- `view` / `viewField` added to the side-effect companion metadata names
for `fieldMetadata` and `objectMetadata`.

### Reserved-identifier invariant

A caller can never define an entity whose identifier collides with one a
system side effect produces: caller inputs are forced
`isSystemSideEffect: false` at every entry point (API and app-manifest
transpilers), and the engine raises
`RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER`, aborting the operation, when a
system emission lands on a caller-claimed identifier. Covered by a new
engine-level test.

### Caller-side provisioning removed

The imperative INDEX view + view-field provisioning is removed from
`ObjectMetadataService.createOneObject`. The record-page `FIELDS_WIDGET`
view is intentionally left caller-side and deferred to the follow-up
(see below).

### `twenty-standard` convergence

Standard INDEX views and their view fields converge on the same
derived-identifier + `isSystemSideEffect: true` scheme as the engine.
`twenty-standard` syncs through the from/to migration path (which never
runs the side-effect engine), so it authors this INDEX surface itself,
matching what the engine produces for custom objects.

## Rollout

Two `2.26.0` workspace commands, running after the `2.25`
messageCampaign commands:

- `upgrade:2-26:reconcile-index-view-universal-identifier` re-owns the
INDEX views of the **twenty-standard and workspace-custom applications**
and all their view fields to the derived identifiers with
`isSystemSideEffect: true`, in a single per-workspace transaction. Each
view field identifier is keyed on the application of the **displayed
field** (an app or user column on a standard INDEX view converges too).
Soft-deleted views and view fields are skipped: one can coexist with an
active successor on the same derivation inputs and both would derive the
same identifier. Children reference the view by primary key, so the
re-own is lossless.
- `upgrade:2-26:demote-and-backfill-application-index-view` handles
**manifest-installed applications**, which never had their INDEX view
auto-provisioned: every caller-authored INDEX view of another
application is demoted to `key: null` (a plain additional view under its
manifest identifier), then every application object gets the
engine-owned INDEX view and its full view-field layout backfilled
through the migration pipeline's legacy path (no side-effect expansion),
views committed before view fields across applications since a view
field belongs to the application owning its field. Idempotent and
retry-safe: engine-owned INDEX views are neither demoted nor
re-backfilled, and view creation and view-field creation are gated
independently, so a retry after a partial failure still backfills the
missing view fields of an already-committed view.

Both support `--dry-run` and invalidate the full flat-maps closure
(parents aggregate the re-owned identifiers, children resolve them as
universal foreign keys, and page-layout widget universal configurations
resolve view PKs at cache-build time).

The `2.25` `upgrade:2-25:add-message-campaign-name-field` command is
adapted to resolve the campaign INDEX view by its INDEX key on the
object instead of by universal identifier: it now runs before the
reconcile, on workspaces still holding legacy identifiers.

## ⚠️ Breaking change

This PR **mutates 187 previously hardcoded universal identifiers** — the
standard objects' INDEX views and their view fields (the literals
removed from `standard-object.constant.ts`), now derived.

- **Handled by the `2.26` commands above** for all existing workspaces.
- **The INDEX key is now engine-reserved.** The flat view validator
rejects caller-created INDEX views (API and manifest inputs are forced
`isSystemSideEffect: false`) and enforces a single non-deleted INDEX
view per object; `view.key` is no longer a comparable/updatable
property, so no writer can promote or demote a view after creation.
`ViewManifest.key` is deprecated and ignored (manifest views are always
additional views, so old apps keep syncing and demoted views are not
promoted back); the REST/GraphQL create path now rejects `key: INDEX`.
In-repo example apps (`hello-world`, `document-generator`) no longer
declare it.
- **12 declared-but-never-seeded standard INDEX view field identifiers
deleted** (the former `preservedViewFields` on `timelineActivity`,
`workflowRun` and `workspaceMember`): after the reconcile, no workspace
row references them.
- **`computeFlatViewFieldsToCreate` now derives view field identifiers**
instead of drawing `v4()` ones, which also changes what the committed
`1-23` record-page backfill produces going forward (deliberate,
documented in-code).
- **Record-page views and view fields are not affected** (identifiers
unchanged).
- **In-repo apps: `twenty-last-contact` updated.** It was the only app
declaring explicit INDEX view fields (10 columns across `allPeople` /
`allCompanies` / `allOpportunities`) through manifest `viewFields`.
Those target identifiers are now engine-owned and derived, so the
manifest inputs no longer resolve and install failed with `View not
found`. The app now declares only its fields; the engine's
`fieldIndexViewFieldOnCreate` provisions the matching INDEX view field
automatically. No other app under `packages/twenty-apps` references any
of the 187 mutated identifiers, and apps that target standard views
point at record-page views (e.g. `real-estate` →
`opportunityRecordPageFields`) or their own objects (`twenty-partners`),
all unchanged.

### Loss of granularity for app maintainers

The engine now owns the INDEX view field of every field a caller adds to
an object, so app maintainers lose direct control over those columns.
Previously an app could target the engine-owned INDEX view with an
explicit manifest `viewField` and set its `position` and `isVisible`.
Now `fieldIndexViewFieldOnCreate` appends a **hidden** view field in
caller-input order on field creation, so:

- Columns an app previously showed at a **dedicated position** and
**visible** (e.g. `twenty-last-contact`'s last-contact columns) become
**hidden** and **appended in input order** after install.
- There is currently **no manifest way to override** the
engine-provisioned INDEX view field's position, visibility, or size.

This is a deliberate regression accepted for the sake of
single-ownership, and app maintainers should expect their INDEX columns
to move/hide after upgrading. A follow-up override API will let
maintainers reclaim per-field control over the engine-provisioned INDEX
view field.

## Testing

- Unit specs for each handler: object create (system fields + INDEX
view/view fields, override, position offset), field create (same-batch
vs existing-object, non-displayable noop, no-INDEX-view noop), field
delete, object delete (fields/indexes/searchFieldMetadata/views/view
fields cascade, reverse-relation view field on another object).
- Engine-level test for the reserved-identifier collision.
- `twenty-standard` guard test that its INDEX views/view fields stay on
the derived scheme and stay system-owned.
- Integration test: full engine provisioning of the INDEX view/view
fields on object creation, same view id preserved across an object
rename, and cascade delete on object deletion.

## Follow-up

The full record-page stack (record-page view, its view fields, view
field groups, page layout / tab / widget) is still built imperatively
and moves into the engine in
https://github.com/twentyhq/core-team-issues/issues/2721.
2026-07-29 13:32:21 +00:00
Abdul Rahman ea7863dc4e feat(ai-agent): lazy tool loading for the open-ended runAgent path (#23454)
## Context

`AgentAsyncExecutorService.executeAgent` is shared by workflow agent
nodes and the `runAgent` mutation (Slack assistant, apps). Workflow
nodes run a scoped task, so pre-loading the few explicitly-granted
object tools is fast and skips
the `learn_tools` round trip (the behavior settled in #23400 / #23358).
`runAgent` is open-ended: its role grants broad object access, so
pre-loading inlines every CRUD/action schema on every step. That is what
makes the Slack assistant take 3-4 minutes for a prompt Ask AI answers
in seconds. Confirmed still slow with #23400 merged, so this is the
payload, not object scoping.

## What

Add a `toolLoadingStrategy` to `executeAgent` (default `'preload'`, so
workflow nodes and evals are unchanged). `AgentRunService.run` opts into
`'lazy'`, which exposes a compact tool catalog in the system prompt plus
the `learn_tools` / `execute_tool` meta-tools, using composed role
permissions rather than explicit grants only, so the agent keeps broad
access without the full-payload latency.

## How

- Split tool provisioning into two focused methods on the executor; a
3-line dispatch chooses per strategy. The pre-load path is unchanged.
- `buildLazyRegistryToolset`: one reusable definition of lazy registry
provisioning (catalog + meta-tools), so the chat and agent executors can
share it.
- Extract `buildToolCatalogSection` out of `SystemPromptBuilderService`
into a `tool-provider` util so both paths format the catalog identically
(no dup).
- Replace the meta-tools' `excludeTools` denylist with a single
`isToolAllowed` predicate: the agent path passes an allowlist closed
over the shown catalog (enforced at call time), MCP passes its existing
deny predicate.

Workflow node and eval behavior is unchanged.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23454?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-29 12:15:56 +00:00
Etienne 5ebcce0a51 feat(ai-tool): resolve and default icons in AI metadata tools (#23480)
## Context

Objects and fields created through the AI chat / MCP metadata tools
almost never get an icon, so they all render with the meaningless `123`
fallback icon. Two causes:

- The `icon` tool input was described only as `"Icon name"`, so the
model had no idea what the value space is and mostly skipped an optional
field it couldn't fill confidently.
- Any invalid name is silently swapped for `Icon123` by
`useIcons.getIcon` on the frontend, so near-misses were
indistinguishable from unset.

## What this PR does

**Guide the model** (icon names are Tabler names, which LLMs know well):
- `icon` / `targetFieldIcon` schema descriptions now state the
convention with examples (`IconBuildingSkyscraper`, `IconPaw`, …) and
ask for one to always be set
- The `metadata-building` skill gains an "Icons" section; the MCP server
instructions gain a one-line reminder

**Normalize server-side** (new `resolveIconName` util, used by all
create/update/batch metadata tool executes incl.
`relationCreationPayload.targetFieldIcon`):
- Fixes shape mistakes: raw tabler slugs (`"building-skyscraper"`),
separators, missing or lowercased `Icon` prefix
- Deliberately does NOT validate existence against the full ~4.2k icon
registry — an unknown name is harmless since the frontend falls back to
its default icon, exactly as for icons stored via the API today
- Unusable input (empty/garbage) resolves to nothing: creates fall back
to a default, updates keep the existing icon

**Fall back sensibly for fields**:
- New `FIELD_TYPE_DEFAULT_ICONS` in `twenty-shared/constants` maps every
`FieldMetadataType` to a sensible icon (mirroring the settings UI type
illustrations), applied when the model provides no usable icon — an
AI-created field always gets a meaningful icon
- Lives in twenty-shared so the frontend can reuse it later (e.g. as
`getIcon`'s custom default for fields)

The REST/GraphQL metadata APIs are untouched — this only affects the AI
tool layer.

## Test plan

- `resolve-icon-name.util.spec.ts` — canonical pass-through,
slug/prefix/separator fixes, unknown-name pass-through (FE fallback
contract), unusable inputs, icon-key dropping on updates
- `FieldTypeDefaultIcons.test.ts` — every field type mapped, all values
canonically shaped (values hand-checked against the twenty-ui
`ALL_ICONS` registry)
- `nx typecheck` twenty-server + twenty-shared, oxlint/oxfmt clean on
changed files

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23480?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-29 09:39:55 +00:00
Abdul Rahman 965e033f3f [breaking-change] fix(server): return runAgent execution failures as result errors (#23390)
## Summary

- When `executeAgent` throws, `runAgent` now returns `{ success: false,
error }` instead of a GraphQL exception
- Callers (workflows, Slack assistant, etc.) can surface the failure to
users instead of hanging or failing opaquely

Run agent exception response error format breaking-change, errors moved
from the GraphQL error channel into the response payload.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23390?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 19:16:57 +00:00
Raphaël Bosi f15fabb5d9 Enrich workspace company via People Data Labs during onboarding (#23199)
https://github.com/user-attachments/assets/fb9001c4-195d-4735-898b-07ccbab01677


During onboarding, the workspace creator's work-email domain is enriched
through People Data Labs and stored client-side. The stacked
workspace-setup PR folds it into the invisible prompt that kicks off the
setup chat, so the assistant knows the company from its first reply.

- New `enrichWorkspaceCompany` mutation: throttled, creator-only, work
domains only. Off by default: requires the
`IS_WORKSPACE_COMPANY_ENRICHMENT_ENABLED` instance config variable
(default false), a `PEOPLE_DATA_LABS_API_KEY`, and the
`IS_ONBOARDING_AI_CHAT_ENABLED` workspace feature flag (the enrichment
only feeds the AI-chat workspace setup). Every attempt past the throttle
is recorded per workspace in a `keyValuePair`.
- The frontend fetches once during onboarding and stores a matched
result in localStorage. This PR does not deliver it to the model: the
hidden-message plumbing it adds (`isHidden` on `agentMessage`, excluded
from the chat UI, thread ranking and the admin transcript, included in
the model conversation) is what the stacked workspace-setup PR uses to
send the context and the setup prompt as one invisible first message.
- The PDL wire protocol (base URL, wire types, envelope parsing, error
extraction) is kept as a small self-contained copy inside the server
`company-enrichment` module. The standalone people-data-labs app keeps
its own copy; the two are intentionally not shared, since the app and
the core-engine usage are expected to evolve independently.
- `WorkspaceCompanyEnrichment` lives in `twenty-shared/workspace` so
server and front share one shape.

## Flow

```mermaid
flowchart LR
  effect[Onboarding effect] -- enrichWorkspaceCompany --> checks{creator + work domain?}
  checks -- no --> unavailable[unavailable]
  checks -- yes --> throttle{throttle 10/h/workspace}
  throttle -- limited --> transient[transientError]
  throttle -- ok --> pdl[PDL GET /company/enrich]
  pdl --> log[(keyValuePair attempt log)]
  pdl --> matched[matched]
  matched --> storage[(localStorage)]
  storage -- consumed by the stacked workspace-setup PR --> kickoff[hidden kickoff prompt]
```

1. **Onboarding effect** — mounted app-wide, fires once per session
while onboarding is in progress (before workspace activation), guarded
by a sessionStorage attempt flag and the cached value.
2. **enrichWorkspaceCompany** — metadata-schema mutation returning a
typed `WorkspaceCompanyEnrichmentResult` (`outcome` enum
`matched`/`unavailable`/`transientError` + `enrichment` JSON).
3. **Creator + work domain checks** — only the workspace's earliest
user, only non-consumer email domains, only when the config flag, API
key and `IS_ONBOARDING_AI_CHAT_ENABLED` workspace flag are all on;
anything else returns `unavailable` without consuming throttle quota.
4. **Throttle** — token bucket, 10 requests/hour per workspace, the sole
cost bound on PDL calls; when limited the mutation returns
`transientError` instead of surfacing an error.
5. **PDL call** — `GET /v5/company/enrich` with `website` +
`min_likelihood` per the PDL spec; body-level statuses win over HTTP
ones, 408/429/5xx map to `transientError`, other failures to
`unavailable`. Every attempt past the throttle is recorded (`domain`,
the pre-collapse PDL `outcome`, `httpStatus`/`message` when present,
`attemptedAt`) in a workspace-scoped `keyValuePair`.
6. **matched** — the PDL payload is mapped to
`WorkspaceCompanyEnrichment` through the same sanitizer as client input
(all fields length-capped and control-character-stripped; summary 600
chars, 8 tags max) and returned.
7. **localStorage** — the frontend stores only a matched enrichment and
never refetches it, making it the only cache; cleared on sign-out.
Non-matched outcomes are not persisted; a sessionStorage flag caps
retries at one attempt per browser session.
8. **Delivery** — out of scope here. The stacked workspace-setup PR
reads the stored enrichment and combines it with the data-model proposal
prompt into a single hidden `USER` message when the setup chat starts;
it is never injected into the system prompt.

Reviewer notes: sending the creator's email domain to a third party at
signup is not yet disclosed in onboarding copy.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23199?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 13:29:43 +00:00
Etienne 902bc6db63 fix(ai-node) - scope AI agent node database tools to explicitly granted objects (#23400)
## Context

An AI agent node scoped to a single object was still loading CRUD tools
for the
whole workspace, inflating every run's prompt to ~200k tokens (~110k on
a
standard seed workspace: 146 tools across 19 objects, 18 of them system
objects). Two mechanisms caused this: the roles permissions cache
force-grants
every system object to every role (`isSystem ? true`), and blanket role
flags
(`canReadAllObjectRecords`, ...) grant all remaining objects. The
per-object
rows written by the agent Permissions tab were additive on top of that,
so
scoping an agent had almost no effect on its tool payload.

## What

**Backend: explicit grants only for the agent node**

- New opt-in flag `requireExplicitObjectGrants` on
`ToolProviderContext`, set
  only by the workflow agent executor.
- With the flag, `DatabaseToolProvider` generates CRUD tools exclusively
from
the role's explicit `objectPermission` rows: no row means no tools, and
each
verb gate reads the row directly (`canReadObjectRecords` for find tools,
`canUpdateObjectRecords` for create/update/upsert,
`canSoftDeleteObjectRecords`
for delete). A verb left null is not granted; composed defaults and the
system force-grant can no longer leak through. Composed permissions are
still
  used for `restrictedFields`.
- Explicit rows are read from the `flatObjectPermissionMaps` workspace
cache
key, fetched in the same `getOrRecompute` call as `rolesPermissions`: no
  extra query.
- Without the flag (chat, MCP, tool index, workspace stats), behavior is
unchanged: composed permissions, verified live (`getToolIndex` for an
Admin
  returns the same 245 CRUD tools as before).
- Removed the `CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT` guard on
  `upsertObjectPermissions` so system objects can be granted explicitly.

**Frontend: grant system objects from the agent Permissions tab**

- The objects picker in the workflow agent side panel ends with a new
"System objects" submenu listing all active system objects; picking one
opens
  the same CRUD grant flow as regular objects.
- Permissions granted on system objects now resolve their labels in the
  existing permission list and can be deleted (both previously looked up
  non-system objects only, which would have hidden such grants).

Result: an agent granted one object ships ~10 tools instead of 146,
cutting the
prompt from ~110k tokens to a few thousand and the per-run cost
accordingly.

## Notes

- Removing the system-object guard affects the whole upsert path: user
roles
can also receive explicit system object rows via the API. A `canRead:
false`
row on a system object now takes effect at the query layer for that
role.
- The agent role is resolved as the first role of the permission config,
matching `getObjectsPermissionsFromRolePermissionConfig` (multi-role is
not
  supported yet).

## Tests

- `database-tool.provider.spec.ts`: three new cases for the flag (object
without a row emits nothing, partial row emits only granted verbs,
absent
flag keeps composed behavior even with zero rows, which guards the chat
  regression).
- `object-permission.service.spec.ts`: the system-object case now
asserts a
  successful upsert.
- Integration: dropped the failing "system object" upsert case and its
snapshot, added a successful system object upsert case. Both suites pass
  against a live server.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23400?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 13:24:33 +00:00
Etienne dfea3af778 fix(ai-chat): enrich zero-output stream captures and keep client-error exceptions out of Sentry (#23426)
## What & why

Two related fixes that clean up Sentry reporting for the AI chat flow.

### 1. Enriched zero-output stream captures

The AI chat stream's rejection handler previously skipped only
`AbortError` and captured everything else to Sentry as-is. Two problems:

- The SDK's bare `NoOutputGeneratedError` carries no troubleshooting
context, so the Sentry issues were unactionable (no model, provider,
workspace, or conversation size).
- Expected interruptions (user abort, `STREAM_INTERRUPTED`) still
generated noise.

The rejection handler now handles three cases inline:

- `AbortError` and `STREAM_INTERRUPTED` are expected interruptions and
are not captured.
- `NoOutputGeneratedError` is replaced with a single error whose message
carries the full context as plain JSON: model, provider, workspace,
thread, stream, turn, message count, conversation size, elapsed time,
and the underlying stream error - recorded via a new `onError` handler,
which also keeps stream-level errors visible in the worker logs.
- Anything else is captured unchanged.

The stable message prefix and single capture site keep zero-output
events grouped separately from raw provider errors in Sentry.

### 2. Keep client-error domain exceptions out of Sentry

`BILLING_CREDITS_EXHAUSTED` (a 402, i.e. an expected "user out of
credits" condition) was landing in Sentry. Root cause: `CustomException`
carries no HTTP status, so the worker/BullMQ path hands the raw
exception to `shouldCaptureException`, which can't tell a 4xx client
error from a 5xx server error and captures everything. The GraphQL/REST
edges convert exceptions first, but background jobs bypass those
converters.

Fix, mirroring how `HttpException.getStatus()` already works:

- `CustomException` gains an intrinsic `statusCode`.
- `shouldCaptureException` skips a `CustomException` whose `statusCode <
500`, as a branch symmetric to the existing `HttpException` check. This
covers every path, including the worker.
- `BillingException` populates `statusCode` from the existing
`getBillingExceptionStatusCode` mapping, so credits-exhausted (402)
stays out of Sentry while the 500-mapped billing codes are still
captured.

Exceptions that don't set `statusCode` default to undefined and are
captured exactly as before, so other domains are unaffected until they
opt in.

## Tests

- ai-chat unit suite passes (13 suites, 76 tests).
- Existing billing exception handler tests pass.
- `typecheck` passes.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23426?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 13:21:30 +00:00
Paul Rastoin ceb699c43f Message campaign backfill search field metadata (#23428)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23428?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 15:07:00 +02:00
Abdul Rahman 6c66d4c862 fix(ai-agent): use a non-workflow base system prompt for programmatic agent runs (#23394)
`AgentAsyncExecutorService` hardcoded `WORKFLOW_SYSTEM_PROMPTS.BASE`, so
every caller was told "You are executing as part of a workflow
automation" and "your output may be used by downstream workflow nodes".
That is only true for the workflow AI-agent action. The `runAgent` API
(used by apps such as the call recorder) and agent evaluations got the
same framing, which does not describe how they run or where their output
goes.

The executor no longer asserts its own execution context: `executeAgent`
now takes a required `baseSystemPrompt` and each caller supplies its
own.

- Workflow AI-agent action passes `WORKFLOW_SYSTEM_PROMPTS.BASE`
(unchanged behavior)
- `runAgent` and evaluations pass the new `AGENT_RUN_BASE_SYSTEM_PROMPT`

The param is required rather than defaulted so every call site states
its context and no future caller silently inherits the wrong one.

Prompt constants are also split one export per file, with the shared
tool-usage guidance extracted into `TOOL_USAGE_STRATEGY` so both bases
compose it.

No GraphQL schema, SDK, or database changes.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23394?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 11:53:31 +00:00
neo773 1e58c3073c Feat/email composer improvements (#23188)
- Move composer to dedicated page
- Add test email option
- Auto saved as draft can be revisited from `objects/messageCampaigns`
later
- Campaign stats component



https://github.com/user-attachments/assets/9e523116-e79b-496d-9c9d-3887e0c9213f



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

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-07-28 13:13:00 +02:00
Etienne 74260a161d fix(ai): stop double-counting cache-creation tokens in reported token totals (#23405)
## Context

Under AI SDK v6 usage normalization, `usage.inputTokens` is the **full
prompt**: fresh (noCache) + cache-read + cache-creation tokens. Our
`totalTokens` formulas still added `cacheCreationTokens` (extracted from
provider metadata) on top of `inputTokens` — a leftover from the pre-v6
SDK generation, where flat `inputTokens` excluded cache tokens. The v6
upgrade changed the semantics under the formula's feet, so every Claude
run using prompt caching reported a `totalTokens` inflated by exactly
`cacheCreationTokens`.

## Evidence, traced through AI SDK source

**1. The Anthropic provider folds cache tokens into `inputTokens`.** The
raw Anthropic API reports `input_tokens` *excluding* cache tokens; the
provider sums all three components — [`convertAnthropicMessagesUsage`,
`@ai-sdk/anthropic@3.0.84`](https://github.com/vercel/ai/blob/%40ai-sdk/anthropic%403.0.84/packages/anthropic/src/convert-anthropic-messages-usage.ts):

```ts
inputTokens: {
  total: inputTokens + cacheCreationTokens + cacheReadTokens,
  noCache: inputTokens,
  cacheRead: cacheReadTokens,
  cacheWrite: cacheCreationTokens,
}
```

**2. ai core surfaces that total as the app-visible
`usage.inputTokens`** — [`asLanguageModelUsage`,
`ai@6.0.97`](https://github.com/vercel/ai/blob/ai%406.0.97/packages/ai/src/types/usage.ts):

```ts
inputTokens: usage.inputTokens.total,
...
totalTokens: addTokenCounts(usage.inputTokens.total, usage.outputTokens.total),
```

So the SDK's own `totalTokens` is already "full prompt (incl. cache read
+ creation) + output".

**3. The value we were adding on top is the same one already inside
`inputTokens`.** The provider also exposes the raw API field in metadata
(`@ai-sdk/anthropic` dist):

```ts
const anthropicMetadata = {
  usage: response.usage,
  cacheCreationInputTokens: response.usage.cache_creation_input_tokens ?? null,
  ...
```

`extract-cache-creation-tokens.util.ts` reads exactly
`providerMetadata.anthropic.cacheCreationInputTokens` — the same
`cache_creation_input_tokens` that step 1 already folded into
`inputTokens.total`. Adding it again counts it twice.

**Worked example** (matches the new pinning test): API returns
`input_tokens: 400, cache_read_input_tokens: 600,
cache_creation_input_tokens: 200, output_tokens: 500` → app sees
`usage.inputTokens = 1200`,
`providerMetadata.anthropic.cacheCreationInputTokens = 200` → old
formula reported `1200 + 500 + 200 = 1900`; actual tokens processed:
`1700`.

All snippets are verbatim from the version tags in `vercel/ai` and match
the installed `node_modules` dists.

## Provider independence

`inputTokens + outputTokens` is correct for every provider Twenty routes
through, not just Anthropic:

- The v3 provider spec (`@ai-sdk/provider`) defines `inputTokens.total`
as "the total number of input (prompt) tokens used", with
`noCache`/`cacheRead`/`cacheWrite` as its components — and all 8
installed provider packages comply (verified in dists): `anthropic` and
`amazon-bedrock` sum the components explicitly ([`convertBedrockUsage`,
`@ai-sdk/amazon-bedrock@4.0.117`](https://github.com/vercel/ai/blob/%40ai-sdk/amazon-bedrock%404.0.117/packages/amazon-bedrock/src/convert-bedrock-usage.ts):
`total: inputTokens + cacheReadTokens + cacheWriteTokens`); `openai`,
`azure`, `google`, `mistral`, and `openai-compatible` pass through wire
values that already include cached tokens; `xai` even detects which wire
convention the API used and normalizes either way.
- The removed `cacheCreationTokens` term was already 0 for every
provider except Anthropic/Bedrock
(`extract-cache-creation-tokens.util.ts` only reads those two metadata
namespaces), so this PR is a strict no-op for OpenAI-style providers and
only removes the double-count where it existed.

Caveat: a custom `AI_PROVIDERS` entry pointing at a legacy V2-spec
provider package bypasses this normalization (ai core's shim passes flat
usage through verbatim); that path could misreport under any formula,
and none of the built-in providers use it.

## What changed

Four sites computed the inflated total:

- `ai-billing.service.ts` — `quantity` on the emitted AI token usage
event
- `chat-execution.service.ts` — chat-turn usage event
- `agent-async-executor.service.ts` — workflow-agent usage event
- `build-ai-agent-step-log.util.ts` — workflow step log (display)

The first three now compute `totalTokens = inputTokens + outputTokens`;
the step-log util uses the SDK's `usage.totalTokens` directly (it
receives the `generateText` usage object, where the field is
guaranteed). The explicit sum is used where usage objects are
hand-assembled or merged — e.g. the streaming path in
`stream-agent-chat.job.ts` builds usage literals with no `totalTokens`
field at all, so `usage.totalTokens ?? 0` would silently emit 0. Both
forms are definitionally identical where the SDK object exists, since ai
core computes `totalTokens` as `input + output` (see evidence above).

**Impact: reported/analytics quantities only.** Billed credits
(`creditsUsedMicro`) come from `computeCostBreakdown`, which already
handles the cache-inclusive convention correctly and is unchanged.

**Ops note:** `usageEvent.quantity` for cache-heavy workspaces steps
down on deploy — dashboards trending this metric may want an annotation.
Historical rows are not backfilled (per-row component fields aren't
stored, so mixed-era rows can't be reliably corrected).

## How tested

- Updated `build-ai-agent-step-log.util.spec.ts` expectation (155 → 150
with `cacheCreationTokens: 5` still present)
- New pinning test in `ai-billing.service.spec.ts`: emitted `quantity`
is 1700 (not 1900) for inclusive Anthropic usage with
`cacheCreationTokens: 200`
- New pinning test in `agent-async-executor.service.spec.ts`: emitted
total is 150 (not 180) when steps carry
`providerMetadata.anthropic.cacheCreationInputTokens`
- 3 suites / 20 tests pass; oxlint, oxfmt, and `nx typecheck
twenty-server` clean

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

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

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

## What changed

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

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

## Expected impact

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

## Validation

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

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

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

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

## What changed

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

The webhook handlers and their queries remain unchanged.

## Why this design

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

## Expected impact

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

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

## Validation

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

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

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

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

## What changed

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

## Expected impact

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

## Validation

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

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

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

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

## What changed

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

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

## Expected impact

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

## Validation

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

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

## Problem

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

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

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

## Fix

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

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

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

## Tests

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

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

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

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

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

## What changed

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

The new flow:

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

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

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

## Why this is safe

The authorization behavior remains unchanged:

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

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

## Expected impact

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

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

## Test coverage

The permission service tests cover:

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

The agent-role tests also verify that deleting an unused agent-only role
refreshes the relevant cache maps, while a role that remains assigned
does not trigger deletion or invalidation.
2026-07-27 16:32:54 +00:00
Raphaël Bosi 2899058b5f Warn users before front components navigate to an external site (#23270)
https://github.com/user-attachments/assets/af3fb042-d066-4e0c-9348-f86ea92a6fcd



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

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23270?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-27 13:21:38 +00:00
neo773 3fb29db28a Feat/email settings v2 (#23180)
Settings pages changes

- Add `displayName`
- Unsubscribers Page

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


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

---------

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

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

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-25 08:43:55 +02:00
Etienne 4851489ebc fix(ai-chat) fix AI chat tool-output spill leaks: spill learn_tools, cap navigation tools, truncate on spill failure (#23286)
## Context

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

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

## What changed

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

## Test plan

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

## Post-deploy

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

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

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23289?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 16:50:57 +00:00
Thomas Trompette d6c186a71e Fix system objects bypassing role object permission overrides (#23280)
## Bug

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

## Root cause

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

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

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

## Fix

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

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

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

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

## Testing

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


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