Commit Graph

5541 Commits

Author SHA1 Message Date
nitin 079e9b8e56 feat(dashboard): extend number format option to bar, line and pie charts (#23505)
https://discord.com/channels/1130383047699738754/1509604545381142649

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

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

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

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


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



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23505?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 09:42:21 +00:00
Abdul Rahman 72322a4d72 feat: Slack conversational assistant (#22984)
## Summary

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

## How it works

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

## App-owned orchestration

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

## Notes

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

## Follow-ups

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

## Screenshots


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


<img width="426" height="686" alt="Screenshot 2026-07-27 at 3 58 38 PM"
src="https://github.com/user-attachments/assets/b0a62e7c-c5e4-4c96-9389-5e47d7ef8c77"
/>
<img width="1053" height="726" alt="Screenshot 2026-07-29 at 12 54
45 AM"
src="https://github.com/user-attachments/assets/4e14b3fb-fbe5-4f4d-a380-cc45cc60a01a"
/>
2026-07-30 09:36:38 +00:00
Félix Malfait 0b335d15b3 Refresh billing state after ending trial period (#23534)
Fixes #23530

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

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

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

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

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

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

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-30 09:21:19 +02:00
Thomas Trompette 4ff9cba76d fix(server): stop the global catch-all filter from shadowing typed GraphQL exception filters (#23508)
## Context

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

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

## Root cause

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

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

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

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

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

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

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

## Why the existing test did not catch it

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

## Fix

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

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

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

## Test

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

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

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

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

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

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

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

## CI follow-up

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

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

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

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

## What this PR does

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

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

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

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

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

## Test coverage

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23447?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-29 16:48:24 +00:00
martmull c4a79c50c3 Install pre-installed apps in a dedicated job after the workspace upgrade cursor is written (#23517)
## Problem

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

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

The install path validates app/workspace version compatibility:

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

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

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

## Changes

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

## Notes

Workspaces created before this fix can be repaired with the existing
`install-pre-installed-apps` backfill command, which is idempotent.
2026-07-29 16:30:00 +00:00
nitin e5c6cbcf80 Resolve route-trigger workspace from bearer token on bare hosts (#23490)
When a request reaches the `/s` route on a host that names no workspace
(bare `SERVER_URL` on a multiworkspace instance), resolve the workspace
from the bearer token — the same source `/graphql` uses — instead of
failing with `WORKSPACE_NOT_FOUND`. Hosts that do name a workspace keep
host resolution unchanged, and requests without a token are unaffected.

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

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

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-29 16:55:13 +02:00
Paul Rastoin 133b3375b6 [Upgrade] Stop command gracefully on SIGINT/SIGTERM (#23481)
Ctrl+C on `upgrade` used to kill the process wherever it happened to be,
potentially in the middle of a workspace command. It now stops at the
next iteration boundary instead.

## Behavior

- **First SIGINT/SIGTERM** — the runner finishes what it started, then
stops instead of starting new work. Exits with `130` (SIGINT) or `143`
(SIGTERM), following the 128+signal convention, so orchestrators can
tell an interruption apart from a failure.
- **Second signal** — immediate exit, leaving the command in progress
unfinished.
- **SIGKILL** — untrappable, same outcome as a second signal.

Nothing is rolled back on stop: the run resumes from the last command
recorded in `upgradeMigration`.

## Opt-in per command

Registering a `SIGINT` listener removes Node's default kill-on-signal
behavior, so a command that installs a handler without honoring the flag
would ignore the first Ctrl+C entirely. Handlers are therefore opt-in
via `CommandShutdownService.listenToShutdownSignals()`, called by the
two commands that stop at a boundary:

- `UpgradeCommand`
- `WorkspaceCommandRunner`, the base for standalone workspace commands

Everything else keeps today's behavior and dies on the first signal,
`run-instance-commands` included: instance commands are transactional
and cursor-guarded, so a hard kill rolls back and a rerun skips what
completed. `install-application`, `rebuild-application-default-deps` and
`install-pre-installed-apps` iterate over workspaces without going
through `WorkspaceCommandRunner`, so they are not armed either; they are
one call away if we want them.

The server and worker processes share these services and never arm
anything, so their shutdown semantics are unchanged.

## Where the flag is checked

`CommandShutdownService` exposes a single boolean,
`isShutdownRequested()`, read only by the iteration runners:

- `UpgradeSequenceRunnerService.runInner` — before each sequence step
- `WorkspaceIteratorService.iterate` — before each workspace

There is deliberately no `AbortSignal`: in-flight work is never
cancelled, it is allowed to finish. Individual commands know nothing
about shutdown, so a workspace that has started runs its whole pending
segment before the run stops. Each workspace ends up either fully done
with the segment or untouched, never scattered at some cursor inside it.
That keeps resume state coarse and the change out of the command layer,
at the cost of a longer stop latency, which the second Ctrl+C covers.

`WorkspaceIteratorReport` gained an `interrupted` flag. The sequence
runner needs it: stopping partway through the workspace list and then
advancing the cursor would run an instance step against workspaces that
are not aligned yet, so it returns instead.

## Deployment note

Under Kubernetes, `terminationGracePeriodSeconds` must exceed the time
for one workspace to finish its segment, otherwise the SIGTERM path
degrades into a SIGKILL. Documented in `docs/UPGRADE_COMMANDS.md`.

## Testing

- New unit test for `CommandShutdownService` (7 cases); 293 tests pass
across `database/commands` and `core-modules/upgrade`
- `tsgo -p tsconfig.json` clean
- oxlint and oxfmt clean on all touched files
2026-07-29 14:49:47 +00:00
nitin 742f76e318 [BREAKING-CHANGE] Add NOT_RECORDED call recording status (#23478)
Adds NOT_RECORDED to the CallRecording status select, for meetings where
nothing was captured (bot never admitted, meeting not started, nobody
joined). First part of twentyhq/core-team-issues#2706, split out so
existing workspaces are upgraded before the call-recorder app starts
writing the new status.

- NOT_RECORDED enum value + standard select option
- 2-26 workspace upgrade command adding the option to existing
workspaces (idempotent, same option id as the standard definition)

App-side classification from Recall sub codes comes in a follow-up PR.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23478?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:44:51 +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
Félix Malfait 33970dd45d Align campaign view column positions with the standard layout (#23496)
Follow-up to #23493 (merged). Now based on `main`.

## Problem

`AddMessageCampaignNameFieldCommand` places the new `name` column below
the lowest existing position when it is the label identifier (`min -
1`). That satisfies `isViewFieldInLowestPosition` once, but does not
hold: `viewField.position` is compared by the standard-application sync
(`position: { toCompare: true }`), so the stored position is pulled back
to the standard one and re-fails
`validateLabelIdentifierFieldMetadataIdFlatViewField`, throwing
`WorkspaceMigrationBuilderException` on every sync. That sync runs
during `WorkspaceManagerService.init`, which is what `ActivateWorkspace`
calls.

cubic flagged this on the original PR:
[discussion_r3653452416](https://github.com/twentyhq/twenty/pull/23188#discussion_r3653452416).

## Confirmed against prod

Queried the workspace failing in Sentry
([TWENTY-SERVER-JJ2](https://twenty-v7.sentry.io/issues/7639768673/)):
the `allMessageCampaigns` view has no `name` column at all, `subject` at
position `0` (still the label identifier), and every column at the old
layout. The activation sync tries to create `name` at standard position
`0` while repointing the label identifier in the same batch, ties with
`subject` at `0`, and throws. Exactly the mechanism this PR fixes.

## Change

A `2-25` workspace command
(`upgrade:2-25:align-message-campaign-view-field-positions`, timestamp
`1785332560000`) that aligns the `allMessageCampaigns` columns to the
standard layout, so the sync has nothing left to change:

- Standard columns take their standard positions (`subject 0→1`, `status
1→2`, …), freeing slot `0` for `name`.
- Columns the standard application does not know about keep their
relative order and move above the standard ones.
- Updates run in two migration passes: everything else first, then the
lowest-target column alone. The migration builder validates updates one
at a time against optimistic maps it mutates as it goes, so a single
batch is order-dependent (caught by Greptile below); two passes keep
every intermediate state valid.

Placed in `2-25/` rather than `2-26/` so it can ship in a 2.25.x patch —
the failures are on `v2.25.0` instances. This is why
`server-previous-version-upgrade-mutation-guard` is red: it needs the
`ci:allow-previous-version-upgrade-mutation` label (same as #23493).

The position arithmetic and the pass-splitting are pure utils with 12
tests.

## Traced against the confirmed prod state

Pass 1 moves `status`…`createdAt` up while `subject` (label identifier)
stays lowest at `0`; pass 2 moves `subject` to `1`, still strictly
lowest. The subsequent activation sync then creates `name` at `0` below
`subject` at `1` and repoints the label identifier. Converges.

Run order matters only relative to the sibling command from #23493:
removal (`…550000`) sorts before alignment (`…560000`), which is
correct.
2026-07-29 16:16:52 +02:00
Félix Malfait e79424ddb0 Stop building the Campaigns navigation menu item (#23493)
Campaigns show up in the sidebar of every workspace, including brand new
ones, while the feature is still behind `IS_EMAIL_GROUP_ENABLED`.

## Why it leaked

`navigationMenuItem` has no `conditionalAvailabilityExpression` column.
Only two entities do:

- `page-layout-widget.entity.ts`
- `command-menu-item.entity.ts`

So there was no flag to attach. #23188 gated every surface that supports
gating — the campaign command menu items carry
`featureFlags.IS_EMAIL_GROUP_ENABLED`
(`standard-command-menu-item.constant.ts:720` and `:735`) and the page
layout widgets are gated the same way — but `allMessageCampaigns` was
added to `FLAT_NAVIGATION_MENU_ITEM_NAMES`, which builds
unconditionally.

`messageCampaign` is `isSystem: true`, so the navigation item was the
only thing exposing the feature.

## Change

- Drop `allMessageCampaigns` from `FLAT_NAVIGATION_MENU_ITEM_NAMES`. Its
definition stays in `STANDARD_NAVIGATION_MENU_ITEMS` so the identifier
remains reserved and re-enabling is a one-line change.
- Add workspace command `1785324390000` to delete the rows from
workspaces already provisioned with the item. It collects every matching
row rather than looking the identifier up once, because each user
workspace gets its own.

## Trade-off

Campaigns become unreachable from the sidebar even with the flag on,
which restores the pre-#23188 state. The durable fix is adding
`conditionalAvailabilityExpression` to `navigationMenuItem` so the item
can be gated like the command menu items — a schema change, deliberately
not done here.

## Verification

Reproduced on a fresh `database:reset` against `main`: two
`20202020-b00b-4b0b-8b0b-c0aba11c000b` rows (type `OBJECT`, position 7)
for the single seeded workspace, structurally identical to the other nav
items. Typecheck, `oxfmt --check src/` and `oxlint --type-aware src/`
clean on this branch. The post-fix database check did not complete —
local Postgres died mid-reset — so the removal is verified by
construction and by the build-list change, not yet by a second reset.


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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23493?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:08:27 +02:00
Paul Rastoin e99452e00d feat(server): attach app attributes to application rate-limited metric (#23500)
## What

`MetricsKeys.CommonApiApplicationQueryRateLimited`
(`common-api-query/application-rate-limited`) is emitted from the
per-application throttler in `common-base-query-runner.service.ts`
**without any attributes**. Downstream that means a single
undifferentiated counter — there is no way to tell *which* application
is hitting `APPLICATION_API_RATE_LIMITING_LIMIT`.

This attaches the app dimension:

```ts
await this.metricsService.incrementCounterForEvent({
  key: MetricsKeys.CommonApiApplicationQueryRateLimited,
  shouldStoreInCache: false,
  attributes: {
    universal_identifier: authContext.application.universalIdentifier,
    app_name: authContext.application.name,
    source_type: authContext.application.sourceType,
  },
});
```

## Why these three attributes

Same trio already emitted by `application-registration.service.ts`,
`application-install.service.ts` and `application-gauge.service.ts`, so
per-app API rate limiting joins cleanly against the existing app
lifecycle metrics rather than introducing a second naming scheme.

## Notes

- **No new data fetching.** `authContext` is already narrowed to
`ApplicationWorkspaceAuthContext` by the `isApplicationAuthContext`
guard at the top of the method, and the throttler key a few lines above
already reads `authContext.application.universalIdentifier`. `name` and
`sourceType` are plain columns on `FlatApplication`, present on the same
in-memory object.
- **Bounded cardinality.** `universalIdentifier` is stable for an
application across workspaces (see the unique index on
`(universalIdentifier, workspaceId)`), so the label set is bounded by
the number of distinct applications, not by installs.
- **`source_type` typing.** `ApplicationRegistrationSourceType` is a
string enum, assignable to OTel's `AttributeValue`.

## Context

The consuming dashboard panels are already merged-pending in
`twentyhq/twenty-infra` ([PR
#835](https://github.com/twentyhq/twenty-infra/pull/835)) and written
with a `coalesce(nullIf(Attributes['app_name'], ''),
nullIf(Attributes['universal_identifier'], ''), 'unknown')` fallback, so
they render today as a single `unknown` series and start splitting per
app automatically once this ships — no dashboard change needed on either
side of the deploy.

## Test plan

- Behaviour is unchanged: the counter still fires once per
`ThrottlerException`, and the error is still rethrown. Only the
attribute bag is new.
- I did not run the twenty-server test suite or typecheck locally — this
was authored against a shallow clone without a monorepo install, so I'm
relying on CI for both.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23500?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 13:55:04 +00:00
github-actions[bot] 6db019686e i18n - translations (#23507)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-29 15:42:20 +02: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
Thomas Trompette a0281f635b Restore soft-deleted junction record when re-adding a junction relation (#23371)
Fixes #23305.

Removing a junction relation soft-deletes the intermediate junction
record. Re-adding the same relation created a brand new record, which
the composite unique index on the junction object (e.g. `personId` +
`companyId`) rejected with "This record already exists".

`useUpdateJunctionRelationFromCell` now creates the junction record
through `useCreateManyRecords` with `upsert: true`. The server matches
on the unique index with `withDeleted()` and clears `deletedAt` on the
matched row instead of inserting.

## This revives, it does not create

Worth being explicit, because it is a deliberate trade and not obvious
from the diff.

When a soft-deleted row exists for the pair, the user gets that row
back. Same id, same `createdAt`, same `createdBy`, and anything attached
to it (notes, files, and any fields the app added to the junction
object). Verified on a dev instance: after re-adding a link through the
picker, the row still reported a creation date from days earlier.

That is fine for a pure link. It is a lie for a junction that carries
data, for instance a `PersonCompanyRelationship` with a role and dates.
The alternative designs are hard-deleting on detach (needs
`canDestroyObjectRecords` on the junction object, which most roles do
not grant) and partial unique indexes (needs `indexWhereClause` exposed
to app-declared indexes, which the SDK does not support today). Both are
larger changes. This one unblocks affected apps without requiring
anything from them.

Note that upsert conflict detection ignores `indexWhereClause`, so
shipping partial indexes later would not change this behaviour on its
own.

## Why the id handling changed

The hook no longer sends a client-generated id. Under upsert the server
decides which row you get, so the id in the input would be discarded.

The optimistic store entry still uses a local id so the chip appears
immediately, then adopts the persisted id once the mutation resolves.
Without that, a revive left an id in the store that exists nowhere on
the server, and the next removal failed with "This record does not exist
or has been deleted".

Supersedes #23365, which took the same approach but added `$upsert` to
the shared `createOne` mutation. That capability already exists per call
on `createMany`, and widening the shared document broke the
`useCreateOneRecordMutation` and `useCreateOneRecord` tests.

## Testing

Verified end to end on a dev instance with a junction object carrying a
non-partial composite unique index, since the dev seed does not create
one and the bug cannot reproduce without it:

- before: re-adding after a removal fails with "This record already
exists"
- after: the original row is restored, `deletedAt` cleared, one row
throughout, and removing again in the same session works, with no
GraphQL errors

Added an integration test for the path this depends on: a soft-deleted
record matched on its unique fields alone, with no id in the input. The
existing coverage only exercised upsert by explicit id.

## Known gaps, deliberately not addressed here

**Toggling a link off while its creation is still in flight.** The store
id is provisional until the mutation returns, so a removal issued inside
that window deletes an id the server never received. Reproduced by
stalling the create and clicking remove during it:
`CombinedGraphQLErrors: Record not found`, and the link stays active
despite the user removing it. The window is one mutation round trip.
Left for a follow-up; the likely fix is a per-record operation queue so
adds and removes on a field run in click order.

**Junction objects with no unique index.** Remove then re-add still
creates a duplicate row there, on this branch as on main, because
conflict detection is driven by unique indexes.

---------

Co-authored-by: Shinu Cherian <129690295+Shinu-Cherian@users.noreply.github.com>
2026-07-29 12:17:18 +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
Thomas Trompette 9dd9709e97 test(workflow): cover the workflow core mirror end to end (#23434)
## What

Adds the integration coverage `core.workflow` mirroring never had:
create, rename, delete, restore and destroy, asserting the core row
appears, follows the rename, disappears and comes back.

No production code changes. The async `WorkflowCoreDualWriteListener`
stays as the mirror for the workflow entity.

## Why this PR changed shape

It originally replaced the listener with query hooks, to remove the last
async best-effort write path. That was the wrong call, for three reasons
found while reviewing it:

1. **It would have introduced drift, not removed it.**
`workflow-trigger.workspace-service.ts` updates `lastPublishedVersionId`
via `workflowRepository.update(...)` when a version is activated. That
is a repository write, not an API mutation, so query hooks never fire
for it and `core.workflow.lastPublishedVersionId` would have gone stale
on **every workflow activation**. The consistency cron compares that
field, so it would surface as `fieldMismatch` drift. The listener
catches it because events are emitted at the ORM layer.

2. **Hooks are no more atomic than the listener here.** Workflow CRUD
goes through the generic API, so there is no dedicated mutation to wrap
and the generic runner holds no transaction: it commits, then runs
post-hooks. Both approaches are post-commit, with the same drift
guarantees.

3. **Events carry the full record; hook payloads do not.**
`workspace-insert-query-builder` passes the full formatted result to the
event while the client response is filtered by `returning`. That partial
payload is what forced the re-fetches, the `coreWorkflowId` pre-hook
injection, and the destroy pre-commit special case. All three were
workarounds for a problem the listener does not have.

The principle we settled on: **use the transaction where one exists, use
the listener where there isn't one.** Post-hooks were the worst of both
here. Version *content* writes keep their transactional mirror, since
those go through dedicated mutations that own a transaction.

## Note on the test

The mirror is asynchronous, so the assertions poll (20 attempts, 250ms)
rather than reading core immediately after the mutation returns. Without
that it would be racy.

## Verification

- `nx typecheck twenty-server` green
- `oxfmt` + `oxlint --type-aware` green
2026-07-29 11:20: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
BOHEUS a3b54e834c PDF upload fix (#23473)
Sometimes uploading PDF files resulted in "Non-whitespace before first
tag." error, updating parsing library fixes the error

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23473?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:16:29 +00:00
Paul Rastoin 25d20731ac Include root cause errors in workspace migration runner exception message (#23416)
closes https://github.com/twentyhq/core-team-issues/issues/2733

```diff
- "message": "Migration action 'create' for 'index' (universalIdentifier: 67c6811e-...) failed"
+ "message": "Migration action 'create' for 'index' (universalIdentifier: 67c6811e-...) failed: [workspaceSchema] could not create unique index "IDX_UNIQUE_85951922a2..." (pg code: 23505, detail: Key ("externalId")=(DUPLICATED-VALUE) is duplicated.)"
```

## Problem

When a workspace migration action fails, the `EXECUTION_FAILED`
exception message only states which action failed:

```
Migration action 'create' for 'index' (universalIdentifier: 9e20a0f6-7a18-51c5-a422-5dc4dbd1d972) failed
```

The underlying errors (`metadata`, `workspaceSchema`,
`actionTranspilation`) are attached to the exception instance but
dropped by most surfaces: the SDK CLI only prints `errors[0].message`,
server logs only log `error.message`, and the REST/GraphQL paths lose
the postgres driver details. Debugging a failed app install (like the
stale index universalIdentifier case in the issue) requires guessing.

## Change

- New `formatWorkspaceMigrationRunnerExecutionErrors` util that builds a
compact one-line summary of the underlying execution errors, including
the postgres error code and `detail` for `QueryFailedError`, capped at
1500 chars.
- The `EXECUTION_FAILED` exception message now appends that summary:

```
Migration action 'create' for 'index' (universalIdentifier: 9e20a0f6-...) failed: [workspaceSchema] relation "IDX_..." already exists (pg code: 42P07)
```

Since every surface (CLI, server logs, Sentry, REST, GraphQL) shows
`error.message`, the root cause now propagates everywhere without
touching those surfaces.

- Since actions run inside a single transaction, when one branch fails
with `25P02 current transaction is aborted` (collateral of the other
branch's statement aborting the transaction), the summary keeps only the
real root cause. A lone 25P02 error is still shown.

## Notes

- The SDK's `getSyncErrorRecoveryHint` matching (`/migration action .*
failed/`) still works with the suffixed format.
- Commit-time failures from `DEFERRABLE INITIALLY DEFERRED` FK
constraints still bypass this path (wrapped as `INTERNAL_SERVER_ERROR`
with no action attribution) and are left as a follow-up.

## Tests

- New spec for the formatter util (labels, pg code/detail, 25P02
demotion, truncation).
- Extended `workspace-migration-runner.exception.spec.ts` with a
root-cause message assertion.
- Updated `format-upgrade-error-for-storage` snapshots (first line now
carries the enriched message).

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23416?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:15:30 +00:00
Thomas Trompette 198e3969df feat(workflow): read workflow version content from core in the engine, behind a flag (#23403)
## Scope: engine only

Behind the existing `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` flag (**off by
default**), `getWorkflowVersionOrFail` sources a version's `trigger` /
`steps` / `status` from `core.workflowVersion` instead of the workspace
row. That covers its 11 call sites: workflow build, validation, schema,
run, and trigger dispatch.

**The UI is not switched here.** `useWorkflowVersion` and the content
fetch in `useWorkflowWithCurrentVersion` still go through generic object
CRUD against the workspace columns. That work needs the client to stop
treating the workspace record as the home for content, and is planned
separately around `flowComponentState` (the jotai state the builder
already reads from). It is the read that must land before the workspace
`trigger` / `steps` columns can be dropped.

## Why an overlay, not a repository swap

`core.workflowVersion` is a content-only projection: its own `id` (not
the workspace version id), `workflowId`, `triggers[]`, `steps[]`,
`status`. No `name`, no `position`. So core cannot fully back the
entity.

The flip is therefore an overlay: identity, name and position stay from
the workspace row, and only content comes from core (`triggers[0] ->
trigger`).

## Reversibility

Falls back to workspace content when the flag is off (default), when the
version has no `coreWorkflowVersionId` soft-ref, or when the core row is
missing. Merging changes nothing until the flag is enabled per
workspace, and it can be flipped back at any time.

## Verification

- `nx typecheck twenty-server` green, `oxfmt` + `oxlint --type-aware`
green
- Unit test covering four branches: flag off, flag on with the core row
present (overlays content **and** preserves `id` / `name` from the
workspace row), flag on with the core row missing (falls back on
`trigger`, `steps` and `status`), flag on with no soft-ref (skips the
core read)

## Enablement gate

Do not enable the flag in any workspace until the drift dashboard
reports zero drift for `core.workflowVersion` and legacy drift is
repaired. Enabling before that turns latent drift into live dispatch
behaviour.
2026-07-29 08:07:08 +00:00
martmull 00e418a039 Show call recorders as calendar event participants (#23380)
Closes twentyhq/core-team-issues#2729

Call recordings attached to a calendar event are now displayed next to
the human participants, in the timeline event card
(`EventCardCalendarEvent`).

They are rendered as the source app's `AppChip`, rounded so it sits in
the participant avatar group, with the recording status in tooltip



https://github.com/user-attachments/assets/e0c393c8-fd65-4468-8c4f-503dd22c13d5

## Before
No call recorder chip displayed 

TODO: add this in the calendar views (`CalendarEventRow`)
2026-07-28 21:26:59 +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
twenty-pr[bot] 4730542087 chore: bump version to 2.26.0 (#23451)
## Summary

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

## Checklist

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

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

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-07-28 19:03:51 +02:00
martmull f5da810e59 feat(server): add application:install command (#23430)
Adds `application:install` to install an application on workspaces that
do not have it yet, and moves both application commands onto
`WorkspaceIteratorService`.

### Behavior

- Iterates provisioned workspaces through `WorkspaceIteratorService`
(workspace id resolution, workspace context, per-workspace success/fail
report), or the ones passed with `-w`.
- Workspaces where the application is already installed are skipped,
with a log line pointing at `application:upgrade`. This command never
upgrades.
- Installed workspaces are detected by `universalIdentifier`, the same
identity `ApplicationInstallService` uses to tell a fresh install from a
version upgrade, checked per workspace on the `(universalIdentifier,
workspaceId)` unique index.
- `--workspace-count-limit` caps both the iterator's own selection and
an explicitly targeted `-w` list.
- Fails fast for `LOCAL` and `OAUTH_ONLY` registrations, which have no
code artifacts to install.
- Per-workspace failures are collected in the iterator report and never
abort the run; the command ends with an installed / skipped / failed
summary.

### Options

| Flag | Description |
| --- | --- |
| `-u, --application-registration-universal-identifier` | Application
registration universal identifier (required) |
| `-w, --workspace-id` | Target a specific workspace, repeatable |
| `--workspace-count-limit` | Cap the number of workspaces to iterate
over (max 50) |
| `-d, --dry-run` | Print the workspaces that would be installed without
installing |
| `-y, --yes` | Skip the confirmation prompt |

### Example

```
yarn command:prod application:install -u UNIVERSAL_IDENTIFIER --dry-run
```

### Changes to application:upgrade

- `ApplicationUpgradeService.upgradeApplications` iterates through
`WorkspaceIteratorService` and returns its report, replacing the
hand-rolled parallel batching.
- `--batch-size` dropped from the command, and `batchSize` dropped from
the service and from `UpgradeApplicationsJobData`, since `iterate()` is
sequential.
- `parseBoundedPositiveInteger` moved to `src/database/commands/utils/`
and is shared by both commands.

### Files

- `application-install/commands/install-application.command.ts` (new)
- `src/database/commands/utils/parse-bounded-positive-integer.util.ts`
(new)
- `application-upgrade/application-upgrade.service.ts`,
`application-upgrade/commands/upgrade-application.command.ts`,
`jobs/upgrade-applications.job*`: iterator instead of batching
- `application-install.module.ts` / `application-upgrade.module.ts`:
register the command, wire `WorkspaceIteratorModule`
- `database-command.module.ts`: import `ApplicationInstallModule` so the
command is discovered by the CLI

### Testing

- `npx jest src/engine/core-modules/application` (32 suites, 181 tests
passing)
- `npx nx typecheck twenty-server`
- `oxlint --type-aware` and `oxfmt` on the changed files
2026-07-28 15:02:32 +00:00
martmull 942755d0dd fix(applications): display the installed application icon (#23411)
## Problem

After installing an app, its icon is missing across the UI, while
application *registration* icons render fine.

`Application.logo` holds the manifest path (`public/logo.svg`), which is
package-relative and not displayable. The server exposes a `logoUrl`
resolve field that turns it into
`/public-assets/{workspaceId}/{applicationId}/{logo}`, but on the front
end:

- `APPLICATION_FRAGMENT` and `FIND_MANY_APPLICATIONS` never selected
`Application.logoUrl`.
- So the only source of a usable logo url was
`currentWorkspace.installedApplications`, which is fetched by
`GetCurrentUser` at bootstrap. Nothing refreshed it after
`installApplication`, so a freshly installed app was absent from that
list.
- `useApplicationChipData` then fell through to
`fallbackApplicationData`, which callers populated with the raw `logo`
path. `getAbsoluteImageUrl('public/logo.svg')` yields
`{serverUrl}/public/logo.svg`, which 404s, so the avatar rendered as a
letter placeholder.

## Before / After

An app installed while the applications page is open, so the workspace
snapshot loaded at bootstrap does not know about it yet:

| Before | After |
|---|---|
| <img
src="https://raw.githubusercontent.com/twentyhq/twenty/e27a817fd95c5e44c1fbb64fd4fd68525daeee61/.pr-assets/app-icon-before.png"
width="480"> | <img
src="https://raw.githubusercontent.com/twentyhq/twenty/e27a817fd95c5e44c1fbb64fd4fd68525daeee61/.pr-assets/app-icon-after.png"
width="480"> |

## Changes

- Select `logoUrl` on `Application` in `APPLICATION_FRAGMENT` and
`FIND_MANY_APPLICATIONS`.
- Drop `logo` from `ApplicationDisplayData` and from the `AppChip` /
subtable fallback props, so a package-relative path can no longer reach
an `img` src. Call sites that already passed a url under `logo` now pass
`logoUrl`.
- `SettingsApplicationDetails` and `SettingsApplicationsTable` pass the
application's own `logoUrl`.
- On install, add the returned application to
`currentWorkspace.installedApplications` instead of reloading the
current user, so the chips that resolve by `applicationId` only (nav
menu items, object/field tables, tool rows, workflow nodes) pick it up.
- Stop exposing `logo` on the `Application` GraphQL type: nothing
selects it anymore, and having both `logo` (package-relative path) and
`logoUrl` (display url) was the source of the bug. The column is still
read server-side to build `logoUrl`.
- Regenerated `generated-metadata/graphql.ts`.

## Verification

Ran the stack locally against a seeded workspace with an installed app
whose logo lives at `public/logo.png`:

- `findManyApplications` returns a `logoUrl` under `/public-assets/...`,
and that url serves `200 image/png`.
- Reproduced the bug and the fix in the browser with the scenario shown
above (screenshots taken on the base commit and on this branch).
- `npx nx typecheck twenty-front`, `npx nx typecheck twenty-server`,
`npx nx lint:diff-with-main` on both, and the application settings jest
suites pass.

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

[Review in
cubic](https://cubic.dev/pr/twentyhq/twenty/pull/23411?utm_source=github)
2026-07-28 14:59:13 +00:00
Raphaël Bosi 9509c737e0 Replace the onboarding AI chat feature flag with an environment variable (#23439)
Follow-up to #23199.

The AI-chat onboarding is an instance-level rollout decision, not a
per-workspace experiment, so `IS_ONBOARDING_AI_CHAT_ENABLED` becomes an
instance config variable (default `false`, editable from the admin
panel) exposed to the frontend through `ClientConfig`. The workspace
feature flag is deleted; leftover `featureFlag` rows are inert since the
column is plain text.

`IS_WORKSPACE_COMPANY_ENRICHMENT_ENABLED` is removed as redundant: the
PDL client already skips everything when no API key is set. Enrichment
now runs when the AI chat is on and `PEOPLE_DATA_LABS_API_KEY` is
configured.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23439?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 14:46:52 +00:00
Paul Rastoin 1fb1232a17 Message campaign backfill method sort view field (#23433)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23433?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 14:07:52 +00:00
github-actions[bot] 8e5969ea55 i18n - translations (#23432)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23432?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-28 15:34:08 +02: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
Paul Rastoin 7b46c3ed31 use legacy validate build and run for standard metadatas (#23419)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23419?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:57:55 +00: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
github-actions[bot] 897d29b603 i18n - translations (#23417)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-28 13:22:25 +02: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 d7b1ccaa21 Cache field metadata while processing common query results (#23353)
## Context

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

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

Before this change:

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

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

## What changed

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

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

## Why this is safe

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

## Expected impact

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

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

## Tests

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

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

## What changed

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

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

The scan-and-count cache helper is removed.

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

## Tradeoffs

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

## Testing

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

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

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

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

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

## What changed

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

The new flow:

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

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

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

## Why this is safe

The authorization behavior remains unchanged:

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

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

## Expected impact

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

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

## Test coverage

The permission service tests cover:

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

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

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

## Why

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

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

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

## Coverage after this change

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

## Notes

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

## Verification

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

## Merge gate

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

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