Commit Graph

14052 Commits

Author SHA1 Message Date
Weiko fc6a95a37f Throttle local cache expiration sweeps (#23579)
## Context

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

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

## What changed

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

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

## Safety

This does not change cache freshness:

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

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

## Expected impact

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

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

## Validation

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

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

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

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

## Changes

New logic functions (auto-discovered):

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

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

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

## Tests

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23569?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 14:24:05 +00:00
Paul Rastoin 2be01df271 docs: state v1.23 as prerequisite for cross-version upgrades (#23575)
Fixes #23568

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

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

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

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

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

## What it looks like for an app author

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

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

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

## Changes

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

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

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

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

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

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

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

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

## Tests

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

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

## Notes for review

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

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

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-30 14:20:34 +00:00
Thomas Trompette 6447b7f935 feat(workflow): make the flow atom authoritative for version content, read core behind a flag (#23499)
## What

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

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

## Why the seeding change is required

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

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

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

## The dead wire, now the refresh path

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

## Untouched by design

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

## Verification

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23499?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 14:00:10 +00:00
Félix Malfait c25ae72914 Delete PRODUCT.md (#23577) 2026-07-30 15:30:20 +02:00
Félix Malfait 2d74eca41c Delete DESIGN.md (#23576) 2026-07-30 15:29:50 +02:00
github-actions[bot] 5977185c34 i18n - translations (#23572)
Created by Github action

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-30 15:04:14 +02:00
Paul Rastoin 5adc3ab4a2 Pin last-contact to twenty &gt;=2.26.0 (#23520)
Follow-up to #23081.

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

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

## Why the range matters

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

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

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

## No SDK bump needed

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

## Note on the version number

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

## Not covered here

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


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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23520?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 15:04:08 +02:00
Raphaël Bosi a3beea893d Revert the external link confirmation popup for front components (#23567)
Reverts #23270 and #23404.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23567?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 12:55:55 +00:00
nitin 66e7093524 Use client SDK /s dispatch for call-recorder own-route posts (#23558)
Migrates call-recorder's own-route self-invoke helper off hand-resolved
`TWENTY_FUNCTIONS_URL` and onto the client SDK's built-in `/s` dispatch
(#22863): `postToOwnRoute` now constructs `RestApiClient` with no base
URL and posts to `/s${path}`, letting the SDK resolve
`TWENTY_FUNCTIONS_URL` itself and fall back to `${TWENTY_API_URL}/s`
when it is empty (works on bare multiworkspace hosts since #23490).
Removes the now-unused `resolveOwnRouteBaseUrl` util, its test, and the
env-var-name constant.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23558?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 12:40:43 +00:00
Weiko e4e1d24731 Prevent overlapping workspace cleanup executions (#23522)
## Context

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

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

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

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

## What changed

### Prevent overlapping scheduled cleanups

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

### Shorten the workspace deletion transaction

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

## Why a PostgreSQL advisory lock

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

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

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

## Expected impact

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

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

## Validation

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

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

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


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

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

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

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

## What changed

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

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

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

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

## Testing

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

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23552?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 12:32:38 +00:00
Raphaël Bosi 0ff9e77fd3 Hide app record-selection commands when all records are selected (#23561)
PDL enrichment and call-recorder's call summary are `RECORD_SELECTION`
commands backed by headless front components, which only receive record
ids in selection mode. Under select all the context store switches to
exclusion mode, so they received an empty `recordIds` array and still
reported success while enriching nothing.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23561?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 12:30:10 +00:00
Paul Rastoin eb69e56441 [Upgrade] Add a detached-run wrapper for the upgrade command (#23497)
Long upgrades are started over `kubectl exec` into the command-runner
pod, where the process is a child of the exec'd shell: a dropped SSM
tunnel, a closed laptop or a dead VPN kills the run mid-way. There is no
tmux in the image (Alpine, `sh: tmux: not found`).

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

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

## Commands

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

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

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

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

## Example

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

## Not a concurrency guard

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

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

## Dockerfile

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

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

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-30 14:14:49 +02:00
Raphaël Bosi 38ad13655c Auto-start the workspace setup chat with a data model proposal (#23437)
https://github.com/user-attachments/assets/d447372b-c1b4-4c95-bac9-a8f8efa7d4a1



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

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


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23437?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 12:06:13 +00:00
Raphaël Bosi 014b3cdc67 Mirror host element geometry into front component workers (#23264)
Front components run in a Web Worker whose fake DOM has no layout APIs,
so any library that measures itself crashes. This is the reported
recharts bug: `ref.getBoundingClientRect is not a function`.

### Why this is needed

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

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

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

### How the mirror works

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

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

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

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

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

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

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

### What changed

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

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

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

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

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

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

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

## Changes

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

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

tested on twenty dev recall workspace 

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

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

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

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

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


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



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

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

## How it works

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

## App-owned orchestration

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

## Notes

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

## Follow-ups

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

## Screenshots


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


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

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

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-30 11:35:44 +02:00
Raphaël Bosi 40dd01c47d Document current front component limitations (#23549)
Front components are still under active development, but the docs did
not say so, and two of the three field reports we got on Discord were
misdiagnosed because the sandbox fails silently.

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23549?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-30 09:01:35 +00:00
Félix Malfait 0b335d15b3 Refresh billing state after ending trial period (#23534)
Fixes #23530

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

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

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

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

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

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

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

---------

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

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

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

## Root cause

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

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

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

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

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

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

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

## Why the existing test did not catch it

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

## Fix

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

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

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

## Test

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

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

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

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

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

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

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

## CI follow-up

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

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

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

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

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

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

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

## After

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

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

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-29 18:54:49 +02:00
Etienne a276f3277f feat(workflow): pin concrete model on AI agent node creation and exclude interactive tools from workflow runs (#23447)
## Context

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

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

## What this PR does

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

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

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

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

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

## Test coverage

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

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

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

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

The install path validates app/workspace version compatibility:

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

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

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

## Changes

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

## Notes

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



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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23495?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-29 16:14:46 +00:00
Raphaël Bosi 684259fd5e Make onboarding cards contrast with the page background (#23516)
Onboarding card surfaces used `background.secondary`, the same token as
the onboarding page background, so they blended in (visible on the
workspace selection step).

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23516?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-29 16:04:45 +00:00
Thomas Trompette 58b8e8afee Fix clipped New chat button label in localized navigation drawer (#23511)
Fixes #23503

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

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

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

### Verified locally (German locale)

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

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

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

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

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

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-29 16:55:13 +02:00
Weiko adfb96c7ce Keep email participant avatar colors consistent (#23444)
## Summary
- Fixes issues where the same record didn't share the same avatar in
multiple places of the inbox.
- Add a shared avatar color seed resolver for email participants.
- Apply consistent placeholder colors across participant chips and email
thread previews.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23444?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:52:14 +00: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
github-actions[bot] 63d34e0133 i18n - translations (#23509)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23509?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:06:23 +02:00
Weiko e2e2142998 Add calendar date field submenu with grouped field options (#23445)
## Summary

- Add a submenu for selecting calendar date fields as it was not clear
enough that you could pick 2 date fields.
- Group date and datetime fields with non-selectable headers

### Before
<img width="240" height="252" alt="Screenshot 2026-07-29 at 11 32 54"
src="https://github.com/user-attachments/assets/169bd965-676f-4ef9-8ba6-9ecac80547c6"
/>

### After
<img width="260" height="246" alt="Screenshot 2026-07-29 at 11 31 58"
src="https://github.com/user-attachments/assets/a4471bb0-5c1f-4f5e-bddb-165a5c9da4c7"
/>
2026-07-29 13:56:39 +00: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