Commit Graph

5407 Commits

Author SHA1 Message Date
martmull e14c32015f Make upgrade applications batch size a job parameter defaulting to 5 (#23101)
Makes the batch size used when upgrading applications a parameter
instead of a hardcoded constant, defaulting to 5, and lets admins set it
from the upgrade confirmation modal.

Backend:
- `UpgradeApplicationsJobData` gains an optional `batchSize` field,
passed through by `UpgradeApplicationsJob` to the service.
- `ApplicationUpgradeService.upgradeAllApplications` accepts an optional
`batchSize` parameter, defaulting to
`UPGRADE_APPLICATIONS_DEFAULT_BATCH_SIZE = 5` (previously a fixed batch
size of 20). The value is sanitized to a positive integer to avoid an
infinite batching loop.
- The `upgradeRegistrationApplications` admin mutation accepts an
optional `batchSize: Int` argument and forwards it to the job.

Frontend (admin panel):
- The "Upgrade existing installations" confirmation modal now includes a
"Batch size" number input, defaulting to 5, sent with the mutation.
- Updated the admin GraphQL document and generated types.

## Screenshots

Upgrade section on the admin app registration page:

![Upgrade
section](https://raw.githubusercontent.com/twentyhq/twenty/8f6f7b5b87b8218e10f9f8ed8a8b705cf25f22f6/upgrade-section.png)

Confirmation modal with the new batch size input (defaults to 5):

![Upgrade confirmation modal with batch size
input](https://raw.githubusercontent.com/twentyhq/twenty/8f6f7b5b87b8218e10f9f8ed8a8b705cf25f22f6/upgrade-modal-batch-size.png)

---------

Co-authored-by: Martin <martin@twenty.com>
2026-07-21 14:22:19 +00:00
Weiko 024b379e1a Instrument Node runtime and workspace cache metrics (#23107)
## Context

High API tail latency can come from either downstream work or the
Node.js process itself being unable to schedule work. Existing traces
expose database and HTTP spans, but they do not provide a continuous
event-loop signal or identify time spent rebuilding individual workspace
metadata cache entries.

## What changed

- Enable Sentry's built-in Node runtime integration with a 30-second
collection interval.
- Collect only event-loop delay p99, event-loop delay max, and
event-loop utilization. CPU, memory, p50, and uptime metrics remain
disabled because existing infrastructure telemetry already covers those
areas.
- Add a parent span around workspace metadata cache invalidation and
recomputation.
- Add child spans around cache-provider computation, including the cache
key, recomputation strategy, and whether the provider uses local data
only.

## Telemetry scope

- Cache hits do not create spans.
- Cache spans use `onlyIfParent`, so they are recorded only inside an
already-sampled trace.
- Runtime metrics are three low-cardinality values every 30 seconds per
server process.
- This does not add Prometheus histograms, per-cache-key metric labels,
database pool gauges, or a custom runtime collector.
- No cache behavior or invalidation semantics change.

This should let us distinguish event-loop stalls from downstream
latency, then identify which cache provider contributes to a slow cache
rebuild without materially increasing telemetry volume.
2026-07-21 14:02:58 +00:00
github-actions[bot] 64891c561d i18n - translations (#23108)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23108?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-21 15:48:30 +02:00
Félix Malfait 3ad3e8bd1a feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context

Dashboard view widgets previously only rendered flat tables. This PR
ships the full feature: **Table with group-by**, **Kanban**, and
**Calendar** layouts for dashboard view widgets — server API + frontend,
end-to-end. (Originally staged as a 4-PR stack — #22966, #22967, #22968
— consolidated here per review.)

## Server / API

- **View typing.** Adds `KANBAN_WIDGET` and `CALENDAR_WIDGET` to
`ViewType` (following the `TABLE_WIDGET` precedent) so widget-backing
views keep their layout in `view.type` while staying excluded from
record-index pickers. Shared `getViewLayoutFromViewType()` maps widget
types to their base layout; `isWidgetViewType()` centralizes the
exclusions that were previously hardcoded per-site.
- **Migrations.** Two fast instance commands (**2.23**): `ALTER TYPE
core.view_type_enum ADD VALUE` for both values, and a widened
`CHK_VIEW_CALENDAR_INTEGRITY` constraint covering `CALENDAR_WIDGET`
(entity `@Check` updated for fresh installs).
- **Validation.** `FlatViewValidatorService` keys kanban/calendar
validation on the mapped layout, so widget views get the same invariants
as index views (kanban needs a groupable group-by field; calendar needs
a date field + layout). Calendar widget views default to month; a
non-month (DAY/WEEK) layout is rejected at the API level **unless** the
`IS_CALENDAR_WEEK_VIEW_ENABLED` feature flag is enabled for the
workspace — the same flag that gates day/week on index calendars.
- **API.** `upsertViewWidget` (LAYOUTS permission) accepts a nested
`view` settings input (`type`, `mainGroupByFieldMetadataId`,
`shouldHideEmptyGroups`, kanban aggregate/column-width, calendar
layout/fields). Routes through the standard update path, so `viewGroups`
auto-generate from SELECT options exactly like index views. Only widget
view types accepted; only `RECORD_TABLE` widgets can change view
settings.
- **AI tools.** `create-complete-dashboard` + `create_view` now
use/allow the `*_WIDGET` types (previously they created plain `TABLE`
views that leak into index pickers).

## Frontend

**Settings panel.** The **Source** (object) row comes first, since which
layouts are available depends on it. The **Layout** row below is a
working dropdown (Table / Kanban / Calendar); layouts the source object
can't support are **disabled with a hint** ("Needs a Select field" /
"Needs a Date field") rather than hidden. Group-by row (select fields;
searchable) with a **Hide empty groups** toggle while grouped; **Date
field** row replaces Group by while Calendar is active, and — when the
`IS_CALENDAR_WEEK_VIEW_ENABLED` flag is on — a **Calendar view** row
(Day / Week / Month) appears beside it; **Limit** row hidden while
grouped (only the flat virtualized loader enforces it). Kanban keeps its
group-by locked (no `None` option).

**Instant edit-mode preview.** Draft snapshots carry `viewGroups`;
picking a group-by synthesizes them client-side
(`buildDraftViewGroupsForFieldMetadataItem`, mirroring the server&#39;s
generation), so grouped tables/boards preview immediately before
dashboard save. On save, `upsertViewWidget` responses hand back the
server-generated groups, which replace the client-generated ones in the
persisted snapshot.

**Renderers.** `RecordTableWidgetRendererContent` branches on the
backing view&#39;s layout: `RecordBoardWidget` (wraps the standard
`RecordBoardContainer`) and `RecordCalendarWidget` (mounts the existing
`RecordCalendar`, which renders month / day / week) inside the same
per-widget provider sandbox the table uses.

**Read-only semantics.** Two flags with distinct scopes, each documented
on its state:
- `isRecordBoardViewSettingsReadOnlyComponentState` — locks the board
chrome that edits view settings (add group, column reorder/resize/menu,
aggregates); **card drag still updates records** under object
permissions.
- `isRecordCalendarReadOnlyComponentState` — widget calendars are
read-only by default (no drag, no add-new, no in-calendar layout
switch); cards open the side panel. The one exception, behind
`IS_CALENDAR_WEEK_VIEW_ENABLED`: a **live (non edit-mode) day/week**
widget calendar allows drag-to-reschedule and record creation under
object permissions. Month calendars and edit-mode previews stay
read-only.

**Calendar state componentization.** The calendar module&#39;s three
settings move from global atoms to component states keyed on
`RecordCalendarComponentInstanceContext` (same pattern as record-board),
so several calendar widgets and an index-page calendar can coexist
without leaking state. All readers resolve the ambient instance;
calendar unit tests updated.

**Multi-instance fixes that also fix index pages:** record drag states
were written against a different instance than every reader resolves
(now use the ambient instance); the board sticky-header DOM id is
namespaced per board; dragged board cards portal to `document.body`
while dragging so react-grid-layout&#39;s transforms can&#39;t offset
the clone from the pointer.

## Scope (v1)

- Widget calendars are month-only and read-only by default. With
`IS_CALENDAR_WEEK_VIEW_ENABLED` enabled, day/week layouts become
selectable (UI + API) and live day/week widget calendars support
drag-to-reschedule and record creation under object permissions.
- Widget group-by offers SELECT fields only (server auto-generates
groups from options; widgets have no per-record add-group flow).

## Tests

- Integration: `upsert-view-widget-view-settings.integration-spec.ts` (9
tests — group auto-creation, invalid type/field rejections, non-month
calendar widget rejected while the week/day flag is off and accepted
once it&#39;s enabled, combined settings+fields call); pre-existing
`upsert-view-widget` suite (20) green.
- Front: new suites for draft view-group generation and snapshot
clone/build utils; calendar suites componentized; full `twenty-front`
jest, typecheck, oxlint green; `twenty-server` typecheck + lint green.
- Browser-verified end-to-end (real dev server + seeded workspace):
configure → live edit-mode preview → save → reload for all three
layouts; measured drag with pointer inside the card; index-page calendar
re-verified (with the week/day flag enabled).

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-21 15:41:08 +02:00
Paul Rastoin 2ef7b7824e Upgrade call-recorder, people-data-labs, last-contact and partners apps to twenty-sdk 2.23.0-alpha.1 (#23098)
## What

Upgrades the two breaking-change-prone apps to `twenty-sdk` /
`twenty-client-sdk` `2.23.0-alpha.1`, and adds the server-side hook that
lets the 2.23 upgrade install them:

- **people-data-labs**
- **partners**

Follows up on #22882 (System side effect relations), which re-derived
the system relation field universal identifiers name-free and shipped
`getSystemRelationFieldUniversalIdentifier` in the SDK.

## How

- **people-data-labs**: bump the SDK to `2.23.0-alpha.1`. The enriched
views temporarily hardcoded the new system relation identifiers with a
TODO because the SDK still embedded the old values; now that the
name-free identifiers ship in `2.23`, derive them from
`STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.{company,person}.fields.{noteTargets,taskTargets,attachments,timelineActivities}.universalIdentifier`
(identical to the previously pinned values, verified). Engine already
pinned `twenty >=2.23.0`; app stays `1.0.7` (manifest unchanged).
- **partners**: bump the SDK to `2.23.0-alpha.1`. The partner role
references
`opportunity.fields.{taskTargets,noteTargets,attachments,timelineActivities}`
universal identifiers, which the SDK now resolves to the `2.23`
name-free values. Pin `engines.twenty >=2.23.0` and bump the app to
`1.3.1`.
- **server**: add an opt-in `skipWorkspaceCompatibilityCheck` to the
install/upgrade path. The `upgrade-people-data-labs-application` 2.23
command runs mid-upgrade, before the workspace is marked as having
completed 2.23, so the workspace-compatibility check would otherwise
reject installing `1.0.7` (`engines >=2.23.0`). The server is already on
2.23, so the command passes the flag to install `1.0.7` and close the
desync window. Version-progression (downgrade/same-version) checks still
run.
- **call-recorder** and **last-contact** are intentionally left
unchanged (reverted): they don't define custom objects and don't
reference the system relation identifiers, so they aren't
breaking-change-prone and need no SDK bump.

## Breaking change constraints

- **people-data-labs** and **partners** reference system relation
identifiers that only exist on a `2.23` server, so both pin
`engines.twenty >=2.23.0`. Their `dockerhub-latest` integration leg is
red by design until a >=2.23 server image is published (same accepted
state as #22882); the `local` leg is green.

## Validation

- Regenerated the app lockfiles against the published `2.23.0-alpha.1`.
- `people-data-labs` typechecks cleanly against the real `2.23` SDK
types.
- CI: people-data-labs and partners green on `local`, red on
`dockerhub-latest` by design; server/SDK/all other checks green.
- Rebased onto latest `main`.
2026-07-21 15:40:14 +02:00
Marie bc3112a999 Fix: allow API key creation without Roles permission (#23102)
## Problem

A user with the **API keys & webhooks** permission but **without** the
**Roles** setting permission cannot create an API key through the UI.
The role selector relies on the `getRoles` query, which is guarded by
the `ROLES` permission, so the roles list comes back empty,
`SettingsDevelopersRoleSelector` early-returns, and no role can be
selected — leaving the form unsavable.

<img width="1058" height="408" alt="Screenshot 2026-07-21 at 13 38 34"
src="https://github.com/user-attachments/assets/fe97ba78-e116-458d-af10-11c5969c4636"
/>

## Fix

Expose the assignable roles through the API-key permission scope so
users can **pick** a role to assign to an API key without being able to
**edit** roles.

- **Backend**: add `getApiKeyRoles` query on `ApiKeyResolver` (already
guarded by `API_KEYS_AND_WEBHOOKS`), backed by
`ApiKeyRoleService.getApiKeyAssignableRoles` which returns roles where
`canBeAssignedToApiKeys = true`.
- **Frontend**: add a `GetApiKeyRoles` query and use it in the API key
create and detail pages instead of `getRoles`. The role selector prop
type is narrowed to the fields it actually uses.

<img width="1025" height="455" alt="Screenshot 2026-07-21 at 13 45 01"
src="https://github.com/user-attachments/assets/f1be8f97-5a30-4afc-9eee-c928f4607471"
/>
2026-07-21 13:31:05 +00:00
Marie e5fc5054cc Fix "Go to roles settings" command (#23105)
**Fix the "Go to Roles Settings" command** — it pointed at the
non-existent `/settings/roles` route and now navigates to
`/settings/members#roles`.

**Backfill existing workspaces** — added the
`upgrade:2-23:fix-go-to-roles-settings-command-menu-item-path` workspace
command, which rewrites the seeded command menu item payload for
existing workspaces. It is idempotent and only touches workspaces still
holding the legacy path.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23105?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-21 13:30:36 +00:00
Weiko 26227eff31 Add metadata cache tracing and avoid duplicate cache lookups (#23080)
## Summary
- Add Sentry tracing around metadata GraphQL cache reads with operation
tags and cache-phase attributes.
- Record cache hits on the request path so the response hook skips a
duplicate lookup after an early return.
- Cover the cache-hit, request-miss/response-hit, and allowlist
filtering cases with unit tests.

Before, even a cache hit performed two Redis reads:
```
onRequest  → GET → hit → return cached response
onResponse → GET → hit → do nothing
```
GraphQL Yoga still calls onResponse for an early cached response, so
that second lookup was redundant in most cases.
Now
```
onRequest  → GET → hit → mark request in WeakSet → return cached response
onResponse → request marked → remove marker → return immediately
```

onResponse cache mechanism is also there to prevent race conditions
like:
```
Did another request populate this key while I was executing?
  yes → keep it
  no  → cache my response
```
2026-07-20 19:22:57 +02:00
github-actions[bot] c32bc5e8ac i18n - translations (#23082)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-20 19:00:28 +02:00
Paul Rastoin 1be5a0e54a System side effect relations (#22882)
Closes twentyhq/core-team-issues#2667

## What

Default relations to the standard relation objects
(`timelineActivities`, `attachments`, `noteTargets`, `taskTargets`) are
now fully owned by the **metadata side-effect engine**. Neither the API
transpilers nor the SDK manifest builder provision them anymore: any
object creation, rename or deletion — regardless of the caller — goes
through the same engine handlers.

## Why

- Provisioning was duplicated across the API path and the SDK manifest
builder, with diverging behavior.
- Universal identifiers of relation fields were derived from object
**names**, so renaming an object mutated them and forced lossy
delete+create cycles on manifest sync.

## How

### Engine-owned lifecycle (side-effect handlers)

- `objectSystemRelationsOnCreate`: provisions the 8 forward/reverse
relation fields (+ join column indexes) when an object is created.
- `objectSystemRelationsOnUpdate`: renames the reverse morph fields
(`target<ObjectName>`) when their host object is renamed — a lossless
`fieldMetadata.update`.
- `objectSystemSideEffectsOnDelete`: cascades deletion of engine-owned
fields/indexes when the object is deleted.
- The API transpilers and the SDK `buildManifest` no longer inject these
fields; `isSystemSideEffect: true` marks engine-owned entities, guarded
by a granular property allowlist (only `isActive` is user-editable) and
excluded from manifest deletion inference.

### Name-free deterministic universal identifiers

New `getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier, objectUniversalIdentifier,
relationTargetObjectUniversalIdentifier })` in `twenty-shared`, exported
from `twenty-sdk/define`. The identifier is keyed on the two **object**
identifiers instead of field names (direction encoded by argument
order), so object renames never mutate relation field identifiers. It
cannot collide with the name-based `getFieldUniversalIdentifier`
derivation (field names cannot contain `:`).

### twenty-standard re-owned

All 48 forward/reverse system relation field declarations in
`STANDARD_OBJECTS` now pin the derived name-free identifiers (computed
inline via the shared util) and carry `isSystemSideEffect: true`, with
labels/icons declared explicitly (translated via `msg`).
`twenty-standard` is projected as if the engine had generated these
fields itself.

### 2.23 upgrade commands

- `reconcile-system-relation-field-universal-identifier`: structurally
matches existing default relation fields per workspace and backfills the
derived universal identifiers, `isSystemSideEffect` flags, and standard
labels/icons.
- `upgrade-people-data-labs-application`: upgrades installed PDL apps to
`1.0.7` right after the backfill to close the desync window (its views
reference the re-derived identifiers).

### Misc

- `people-data-labs` `1.0.7`: views temporarily pin the new derived
identifiers (TODO: import from the next released `twenty-sdk`).
- `UpgradeStatusModule` split out of `UpgradeModule` so the application
module cluster can consume upgrade status/migration services without
importing the versioned command bundles (fixes a require cycle that
crashed boot).
- Docs: `system-fields.mdx` documents the system relation fields and
their resolver; `sync-and-recovery.mdx` plan example no longer shows
auto-injected relations.

## Known red CI

`people-data-labs (dockerhub-latest)` fails by design until the 2.23
server image is published: the app pins the new identifiers which only
exist on a 2.23 server. The `local` leg (server built from this branch)
is green.

## System fields are no longer manifest-authorable (accepted regression)

The manifest converter no longer derives `isSystem` /
`isSystemSideEffect` from field names. Reserved-system-named manifest
fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`,
`updatedBy`, `position`, `searchVector`) are now skipped at conversion
time when they carry the exact derived universal identifier (keeps
manifests built with older SDKs installable), and rejected with
`INVALID_INPUT` when they pin any other identifier. System fields are
therefore fully engine-canonical: nothing a manifest carries can produce
a system-flagged entity anymore.

**Accepted regression**: a manifest can no longer influence system field
properties at all. Previously a (legacy) re-declaration could shape them
at creation — which actually produced broken system fields, e.g. a
nullable, non-unique `id` — and could still toggle the allowlisted
`isActive` / `universalSettings` afterwards. We consider this acceptable
for now: per-app granularity over system fields will be reintroduced
later through the **override framework**, which will also settle update
semantics by forbidding direct updates over `isSystemSideEffect: true`
entities and expressing divergence as overrides.

`isSystemSideEffect`-only entities (the default relation fields
provisioned by this PR) still have no engine-level update guard (see
Follow-up below); that part is unchanged and also lands with the
overrides refactor.

## Follow-up

`isSystemSideEffect` field update/delete guards intentionally live at
the API layer (`sanitize-raw-update-field-input.ts`,
`from-delete-field-input-...util.ts`) rather than in the engine-level
`FlatFieldMetadataValidatorService`. Moving them into the validator
requires threading operation-origin (direct field mutation vs engine
cascade) through the migration matrix, otherwise legitimate object
rename/delete cascades (which carry `isSystemBuild=false`) would be
rejected. Tracked in twentyhq/core-team-issues#2671.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22882?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-20 18:53:24 +02:00
github-actions[bot] 8a35f78d70 i18n - translations (#23076)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-20 17:51:00 +02:00
martmull 8d84a0b9f3 feat(app): allow non-admin developers to claim and list marketplace apps (#22621)
## Context

Follow-up to #22609. Lets a non-admin developer claim ownership of a
public Twenty app they published to npm, then request a marketplace
listing that a server admin reviews. Marketplace state is per-instance
for now.

## Claiming

- Developer tab gets a **Claim an application** section: look up an
unclaimed npm app by package name or universal identifier.
- Ownership is proven with GitHub OAuth against the package's npm
provenance (trusted publishing): the connected account must own the
GitHub account or organization the package was published from.
- Errors from the GitHub callback come back as a code and are shown
inline with a link to the relevant documentation.
- The old one-click claim stays admin-only.
- A **Sync catalog** button triggers a catalog refresh instead of
waiting for the hourly cron.
- Gated behind the `IS_APP_CLAIMING_ENABLED` feature flag.

## Listing requests

- Catalog-synced apps are created **unlisted**; a data migration unlists
previously auto-listed unclaimed npm apps (owned or vetted rows are left
untouched).
- Owners request a listing from the Distribution tab (logo + description
required); a server admin approves or rejects it from a **Listing
requests** section in the Admin Panel.

## Screenshots

<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/788d4362-97c4-4e42-810c-ef1f11517bec"/>
<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/d6246190-c82a-4f64-87be-3bb668527645"/>
<img width="1512" height="828" alt="image"
src="https://github.com/user-attachments/assets/21a8dad4-610b-4d1f-8948-b9acab40d373"/>
<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/58246130-41f7-451e-ae7f-57bd21d04bb6"/>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-07-20 15:43:40 +00:00
Raphaël Bosi 240e185323 Show connect emails step to invited users without granting credits (#23058)
Invited users never saw the connect-emails onboarding step, so they
could not connect their inbox while onboarding. Now they do, but only
the first user (the workspace creator) earns the import-contacts reward
for it.

The credit is gated on the workspace having a single member, reusing the
same "first user" signal the frontend already uses to gate the
invite-team step. The connect-account step is still claimed for everyone
so invited users' onboarding advances normally.

The frontend hides the "free credits" tag and the header counter bump
for invited users, so we do not promise credits they will not receive.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23058?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-20 17:00:39 +02:00
martmull baa84bb2e0 Add auto-upgrade-in-apps (#23001)
We need to auto upgrade application, lets add this column in application
entity, and add an admin button to autoupgrade all applications to
latest app registrration version manually

<img width="1131" height="372" alt="image"
src="https://github.com/user-attachments/assets/4e755abc-38ad-4895-a2e8-d55ee1948ac2"
/>

<img width="906" height="533" alt="image"
src="https://github.com/user-attachments/assets/ce002057-0341-4581-bf9a-66ac2bd84a9b"
/>
2026-07-20 16:12:44 +02:00
Félix Malfait d4ac6e752b fix(server): stop cross-pod recompute cascade on localDataOnly workspace cache keys (#22980)
## Context

Prod investigation (Sentry, last 7 days) traced the current slowness to
the per-pod workspace cache. Every recompute of a `localDataOnly` key
(`ORMEntityMetadatas`, `flatWorkspaceMemberMaps`) published a fresh
`crypto.randomUUID()` as the shared Redis validation hash. Because these
keys recover from a hash mismatch by recomputing (their data never
enters Redis), one miss on one pod invalidated the local copy on every
other pod; each of their recomputes minted yet another hash,
re-invalidating everyone else. The fleet never converges.

Measured impact in prod:
- The `ORMEntityMetadatas` rebuild (full `objectMetadata` +
`fieldMetadata` + `application` queries, ~220ms combined, plus
`EntityMetadataBuilder.build`) ran **~963k times in 24h** (~11/s),
roughly 58h of cumulative Postgres time per day.
- The hottest single workspace recomputed its schema metadata 51k
times/day (once per 1.7s).
- Second-order effects: `POST /metadata` averaged 26.6s (p95 2.3s, so a
tail hangs for minutes on pool/event-loop starvation), GraphQL p95 went
846ms (v2.20.0) to 1744ms (v2.21.0), `Query read timeout` on trivial
cron queries at 18x baseline.

The random hash was correct in the original design (#15962): it is a
generation token, and Redis-backed keys recover absorptively by adopting
hash+data from Redis. #16287 added `localOnly` keys (EntityMetadata[] is
not serializable) whose recovery is generative, which silently broke the
invariant later documented in #18649 ("hashes change only on
invalidateAndRecompute").

## What this does

- Recovery recomputes now **adopt** the hash already present in Redis
instead of minting a new one, and write nothing back. A miss costs one
recompute on one pod instead of an unbounded fleet-wide loop.
- Minting is reserved for `invalidateAndRecompute` (real metadata
changes, propagation semantics unchanged, including the frontend
collectionHashes contract) and the bootstrap case where Redis has no
hash.
- The bootstrap write uses **SET NX** (new
`CacheStorageService.setIfAbsent`) instead of a plain overwrite: a slow
bootstrap recompute could otherwise land after a concurrent
`invalidateAndRecompute` mint and clobber it with a hash of
pre-migration data. Under the old code that clobber self-healed via the
cascade; with adopt semantics it would pin stale data, so the bootstrap
write must lose that race. A losing pod keeps its result locally as
provisional and converges on the winning hash at its next revalidation
(covered by a dedicated race test).

Redis-backed keys are untouched: same fetch-on-mismatch recovery, same
mint-and-write on `missingInRedis`.

## Expected effect and how to verify

`FieldMetadataEntity`/`ObjectMetadataEntity`/`ApplicationEntity`
full-workspace query counts in Sentry should collapse from ~1M/day to
the true metadata-change rate, and with them the DB pool pressure behind
the `/metadata` latency tail. This also makes local-cache eviction
(`MAX_LOCAL_CACHE_ENTRIES`, #22946) cheap: the cap can be tuned purely
for RAM.

Complementary to, not competing with, the planned Redis pub/sub
invalidation: a version token in Redis is still needed for restart
catch-up, and this PR gives it sound semantics.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22980?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-20 15:26:49 +02:00
Paul Rastoin 6b55a6b51c Fix duplicate searchFieldMetadata inserts in the 2.16 backfill upgrade command (#23060)
## Context

A self-hosted instance upgrading from 2.0.3 to v2.22.0 got stuck with
one workspace failing at `2.16.0_BackfillSearchFieldMetadataCommand`:

```
[QueryFailedError] duplicate key value violates unique constraint "IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE"
Detail: Key ("objectMetadataId", "fieldMetadataId")=(...) already exists.
```

The failure happened on a retry after a previous partial run, and
reproduced even though the command already recomputes
`flatSearchFieldMetadataMaps` before deriving the create-set (#22884).

## Root cause

The idempotency dedupe compares `(objectMetadataId, fieldMetadataId)`
pairs across two differently-fresh caches:

- The **existing rows** side comes from `flatSearchFieldMetadataMaps`,
which is recomputed from the database (real current ids).
- The **candidate** side resolves ids through `flatObjectMetadataMaps` /
`flatFieldMetadataMaps`, which are **not** invalidated. During a
cross-version upgrade these can be stale, since the migration runner
only invalidates the cache keys a migration touched.

When a stale map resolves a candidate to an outdated id, the dedupe key
doesn't match the existing row and the row is re-emitted. The migration
runner then re-resolves the universal identifiers against fresh maps at
execution time and inserts with the real current ids — exactly the pair
already committed by the earlier partial run (each per-application
migration commits independently) — tripping the unique constraint and
failing the upgrade.

## Fix

Two independent layers, either of which would have prevented the
failure:

1. **Consistent snapshot for the build phase**: the command now
invalidates and recomputes all three maps the dedupe depends on
(`flatObjectMetadataMaps`, `flatFieldMetadataMaps`,
`flatSearchFieldMetadataMaps`), so candidate resolution, existing-row
keys, and the runner all see the same database state.
2. **Id-churn-proof dedupe**: every row this command creates carries a
deterministic universal identifier (`getSearchFieldUniversalIdentifier`,
derived from application + field universal identifiers, no database ids
involved) and `(workspaceId, universalIdentifier)` is unique. The build
util now also skips any candidate whose deterministic universal
identifier already exists, catching leftovers from a previous partial
run even if objects/fields were recreated under new ids in between.

Deliberately **not** done: `ON CONFLICT DO NOTHING` in the create action
handler — it is shared by all runtime `searchFieldMetadata` creation,
and swallowing a conflict would leave the flat-entity cache holding an
entity id that differs from the row actually in the database.

## Test

Added a regression test reproducing the failure shape: an existing row
with the same deterministic universal identifier but stale metadata ids
must not be re-emitted by the backfill.

Note: `ReconcileSearchFieldMetadataCommand` (2.20) has the same
stale-cache exposure; hardening it is left to a follow-up.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23060?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-20 13:23:26 +00:00
Thomas Trompette 98c71d7b3d fix(server): recover workflow runs whose queue job was lost - monitoring only (#22995)
## Context

A workflow run can get permanently stuck in RUNNING when the queue job
executing a step dies without failing (worker crash/restart, lost BullMQ
job). The step stays `RUNNING` in the persisted state, nothing ever
recomputes the run status, and the run never terminates. If a user
clicks Stop, it wedges in STOPPING instead (the stuck-STOPPING sweeper
from #22900 then catches it after 1h, but only because of the manual
stop). Self-hosters also have no way to tell that a job was lost, or
when.

## What this PR does

### Detect runs stuck in RUNNING (monitoring only, no finalization yet)

New `handleStuckRunningRunsForWorkspace` in the staled-runs sweeper
(same cron/job/CLI wiring as the stuck-STOPPING recovery):

- Targets RUNNING runs with `updatedAt` older than 1h (`updatedAt`
refreshes on every step-info write, so staleness means zero progress).
- Skips any run that still has a job in the queue: new `getInFlightJobs`
on the message queue driver
(active/waiting/waiting-children/paused/prioritized/delayed), matched by
run-id-prefixed job id with a `job.data.workflowRunId` fallback for jobs
enqueued before this deploys.
- A truly orphaned run (orphaned RUNNING step, lost between two steps,
failed branch, or finished-but-never-finalized) is **flagged, not
finalized**: warn log + `WorkflowRunStuckRunningDetected` metric + entry
in a per-workspace cache.
- On every subsequent sweep, flagged runs are re-checked. One that ended
or got a new queue job on its own is recorded as
`WorkflowRunStuckRunningFalsePositive` (warn log with the status it
reached) and unflagged. The cron keeps sweeping a workspace as long as
it has flagged runs.

This validates the detection before it is allowed to act: if flagged
runs never resolve on their own (no false positives) while `Detected`
counts real incidents, a follow-up PR can turn the flag into an actual
finalization (fail with a clear "job lost" error so Retry works). Runs
waiting on PENDING steps (delay, form) are never flagged.

### Make queue jobs traceable to their run

All RunWorkflowJob dispatches now set the job id prefix to the workflow
run id, so BullMQ job ids become `<workflowRunId>-<uuid>`. Worker logs
(`Processing job <id>` / `processed`) and Redis job keys are now
greppable by run id. A new opt-in `allowDuplicatedPrefixes` queue option
bypasses the one-waiting-job-per-id dedup (which would otherwise drop
parallel-branch continuations); existing `id` users keep dedup by
default.

### Observability

- `stalled` worker event listener: warn log + new `JobStalled` metric —
emitted when BullMQ detects a job whose worker stopped renewing its lock
(i.e. died mid-job).
- `WorkflowRunStuckRunningDetected` /
`WorkflowRunStuckRunningFalsePositive` metrics as described above.

## Out of scope (follow-ups)

- Actually finalizing flagged runs once monitoring shows no false
positives.
- Recovering lost *delayed* resume jobs (PENDING delay step whose
scheduled job vanished).
- Persisting job ids on the run entity — unnecessary given derived ids.

## Testing

- 11 unit tests for the monitoring sweeper (flagging, id-prefix + data
fallback in-flight guards, pending skip, failed-branch precedence,
false-positive tracking, still-stuck retention, error isolation,
never-finalizes) plus find-options specs; 613 tests pass across workflow
and message-queue modules.
- Not covered: end-to-end kill-the-worker scenario against a real queue.
2026-07-20 14:21:33 +02:00
Thomas Trompette 87729a2822 feat(workflow): backfill + dual-write for core workflow entity (#22776)
Workflow-side soft-ref sync — the `coreWorkflowId` mirror of the merged
version side (#22821 / #22940 / #22944 / #22961). Rebased onto current
main; supersedes the original shared-UUID version of this PR.

## What
Gives `core.workflow` a per-workspace copy of each workflow (`name`,
`lastPublishedVersionId`), soft-reffed from the workspace record via
`coreWorkflowId`, so app-shipped workflows have a core home. Does not
touch reads/dispatch (that's Phase B).

- **Sync service** (`WorkflowCoreSyncService`): core rows get their own
id (`uuidv5(workspaceId:recordId)`), the workspace record links via
`coreWorkflowId` (written back after the upsert), and the write-back is
**guarded** on the `coreWorkflowId` field being present (skips with a
warning otherwise — mirrors #22940). Injected repo renamed
`coreWorkflowRepository`.
- **Dual-write listener** on the `workflow` object:
CREATED/UPDATED/RESTORED upsert, DELETED/DESTROYED delete by
`coreWorkflowId`. Always-on; failures routed to Sentry so they never
break the user write.
- **2-20 backfill** (`backfill-workflow-to-core`): reads via the
provided `RunOnWorkspaceArgs.dataSource`, upserts each workspace
workflow into core.
- **2-22 provisioning** (mirrors #22944/#22961):
- `add-workflow-core-soft-ref-field`: adds the `coreWorkflowId` system
field on existing workspaces (flat-entity legacy migration).
- `backfill-workflow-core-links`: full rebuild — per workspace, in one
raw-SQL transaction, wipes all `core.workflow` rows, inserts a fresh
own-id row per workflow, and re-links every record. (No trigger-map
cache to invalidate on `core.workflow`.)

Simpler than the version side: `core.workflow` has no
one-active-per-workflow index and no trigger-map cache, and it was never
backfilled in prod, so there are no legacy shared-id rows.

## Test
Fresh `database:reset` + full sequence (2-20 backfill → 2-22 add-field →
2-22 rebuild link): 4/4 workspace records linked via `coreWorkflowId`
(id != coreWorkflowId), links resolve, **0 dangling**, no duplicate core
rows, names populated. Typecheck + lint + oxfmt clean.
2026-07-20 14:04:32 +02:00
neo773 c04714b9e0 fix(messaging): register and harden webhook subscription renewal cron (#23006)
The renewal cron was never wired into cron:register:all, so
Gmail/Calendar/Graph watches were never renewed and went dark ~7 days
after connect (the max watch lifetime all three providers allow).
Register it, fan renewals out as per-channel queue jobs scoped to active
workspaces, retry FAILED channels, and recreate Google Calendar watches
before stopping the old one.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23006?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-20 17:26:47 +05:30
Raphaël Bosi 70e4d8d36e Skip install-apps onboarding step for invited users (#22823)
## What

The onboarding "install apps" step was proposed to every new user,
including those joining an existing workspace through an invitation
link. Now it is only proposed to the user who creates the workspace.

## Why

App installation only makes sense for the workspace creator. Invited
members should go straight to profile creation.

## How

`activateOnboardingForUser` set the `ONBOARDING_INSTALL_APPS_PENDING`
flag unconditionally, and that flag is the sole driver of the
`APPS_INSTALLATION` status (the frontend just follows the backend
status). Gated the setter behind a new `shouldShowInstallAppsStep` flag,
mirroring the existing `shouldShowConnectAccountStep` flag: creator path
passes `true`, join path (personal invitation, public invite link, SSO
into an existing workspace) passes `false`.

Backend-only change, no migration needed. Covered by unit tests for both
branches.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22823?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-20 09:25:52 +00:00
Félix Malfait 6e1e98f4ab fix(server): shard the server-test unit job to stop the intermittent crash (#23009)
## Problem

`server-test` fails intermittently: exit 1 with **no `FAIL` line and no
`Test Suites:/Tests:` summary** — the jest run is aborted mid-way,
before the reporter's `onRunComplete`.

## Root cause

The `test` target runs the entire unit suite (~6,600 tests) in **one
in-band jest process on a single runner VM** (`nx.json` sets
`maxWorkers: 1` for the `ci` configuration). A few minutes in, that
process is killed by an **external `SIGKILL`** — confirmed *not* OOM
(~15 GB free at kill time, no cgroup `oom_kill`) and *not* an in-process
crash (a Node diagnostic report armed with `--report-on-fatalerror` +
`--report-uncaught-exception` writes nothing). The whole-run kill is why
it fails intermittently with no summary. `maxWorkers=2` on one VM still
dies, so the threshold is **per-VM**, not per-process.

## Fix

Shard the unit suite across VMs, the same way `server-integration-test`
already does:

- A `twenty-server` `test:ci` target runs jest directly (so `--shard`
forwards) with `dependsOn: ["^build"]` so the workspace deps are built.
- `server-test` becomes a 4-way matrix; each shard runs a quarter of the
suite, well under the kill threshold.
- `ci-server-status-check` already aggregates `server-test`, so required
checks are unchanged.

Also provides two mocks a completed run needs but the SIGKILL had been
masking in `ApplicationRegistrationService.upsertFromCatalog` unit
tests: the `MetricsService` provider and
`applicationRegistrationRepository.createQueryBuilder`.
2026-07-20 06:39:13 +02:00
github-actions[bot] cdb7e56720 chore: sync AI model catalog from models.dev (#23015)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-19 08:48:44 +02:00
neo773 5bedd5b8cc feat(email-group): communications UX, per-record DNS status (#23002)
- Rename Communications label to singular, remove docs-home banner
- Provision unsubscribe Cloudflare records at domain creation and
surface per-record status badges; skip Cloudflare when not configured
- Move sending-domain status into the section header and only show the
records table when a record is unverified
- Reply to the original recipients when replying to your own message
- Fix DNS records table column/badge alignment; emit synthetic records
in the log driver for local testing

<img width="1496" height="849" alt="Screenshot 2026-07-17 at 7 47 24 PM"
src="https://github.com/user-attachments/assets/a5a59adb-2df4-4154-98b2-acf87a8008da"
/>
2026-07-17 22:41:25 +02:00
Félix Malfait 7e133a4930 Converge email recipient fields on existing patterns: shared parser/formatter, search-index members, one display-name rule (#22997)
# Why

Follow-up to #22668, addressing @charlesBochet's five post-merge review
comments. They all point the same direction: the recipient fields
rebuilt things the codebase already had. This PR converges on the
existing patterns where that holds up, and answers on the threads where
it deliberately does not.

# What changed, per comment

**Parser duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064242))**:
`parseEmailAddressList` now lives in twenty-shared (addressparser, group
flattening, try/catch). The server's `safeParseEmailAddresses` delegates
to it, the front wrapper keeps only paste normalization (newlines to
commas) and invalid-token preservation for red chips. The
`addressparser` dependency moves from twenty-front to twenty-shared.
Side effect worth knowing: RFC 5322 group members in inbound To/Cc
headers were previously dropped entirely (group entries have no
top-level address, so the filter removed them); flattening now imports
those participants. Covered by a new regression test.

**Formatter duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064243))**:
`formatEmailAddress` (quote only when specials require it) lives in
twenty-shared. The composer chips and the server's
`formatMessageFromHeader` both delegate to it. The Gmail From header
output is byte-identical: the name is mime-encoded first and encoded
words never contain characters that trigger quoting. CodeQL then caught
that the quoting (ported from the original front util) escaped quotes
but not backslashes, letting a crafted name close the quoted string
early; escaping now covers both as RFC 5322 quoted-pairs, with a
containment test proving a hostile name cannot split into extra
recipients on reparse.

**Member search divergence
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064231))**:
suggestions now search WorkspaceMember through the search index in the
same `useObjectRecordSearchRecords` call as Person (one ranked query),
and enrich hits from `currentWorkspaceMembersState`, exactly like
`SettingsRoleAssignmentWorkspaceMemberPickerDropdown`. The client-side
`filterBySearchQuery` pass is gone. The hook is now what the comment
described: the merge of context people, searched people, and members
into one ranked list, rendered with the same
`SelectableList`/`MenuItemAvatar` primitives the pickers use. Also fixed
while in there: searched person ids are sliced to the suggestion limit
before hydration, so top-ranked people can no longer be crowded out of
the hydration page.

**Chip resolution duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064236))**:
the display-name preference is now one rule,
`getEmailIdentityDisplayName`, used by both
`getDisplayNameFromParticipant` (threads) and the composer chip/menu, so
the same address renders identically everywhere. The order is workspace
member, then person, then display name, then handle: when an address
belongs to both a teammate and a Person record, the internal identity
wins (product call from Felix). `BaseChip.maxLabelWidth` is renamed
`maxWidth` to match the twenty-ui `Chip` API. `ParticipantChip` itself
is not used inside the field: it renders a navigating `RecordChip` when
a person is linked, and navigation from the composer destroys the draft
(no draft persistence yet), plus the field chips need
remove/selected/danger/edit affordances it does not have.

**Rebuilding on MultiItemFieldInput
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064221))**:
answered on the thread rather than in code, deliberately.
`MultiItemFieldInput` is a dropdown-panel list editor (vertical rows,
one input at a time, bound to record-field contexts and
`FieldMetadataType`), and its own TODO says the API should be refactored
into a hook before growing. The inline wrapping chip row commits batches
(paste), dedupes with a flash, and keeps a persistent inline input with
suggestions; layering that through `renderItem`/`renderInput` would
strain both components. On the menu overlap: after comparing side by
side, the shared surface between `MultiItemFieldMenuItem`'s dropdown and
the chip menu is three `MenuItem` rows with different copy, order, and
neighbors; `MenuItem` is already the shared primitive, and a
config-driven fragment would be indirection without deduplication. If
deeper convergence is wanted, the honest path is the existing TODO
(extract the multi-item state machine into a hook, rebase both editors
on it); that touches the Links/Phones/Emails/Array/Files cell editors
and deserves its own PR.

# Verification

- New twenty-shared suites for the parser and formatter (16 tests),
including parse/format round-trips, the encoded-word case, and the
backslash-escaping containment case.
- Server messaging util specs all pass (70 tests), including new
group-flattening regression tests; From-header spec output unchanged.
- Front email module suites all pass (59 tests) with the slimmed
wrappers.
- Typecheck and lint green on twenty-shared, twenty-front,
twenty-server; oxfmt clean on all three.
- Playwright smoke against the seeded dev stack passes end to end:
context suggestions on the Google company, typed search showing people
and the workspace member row (now served by the search index), Enter
picking the top suggestion, duplicate merge, keyboard delete, chip menu
with clipboard copy, Ctrl+Enter committing the buffer then triggering
send.
2026-07-17 21:20:14 +02:00
nicoko93 0d13db1d9c fix(twenty-server): re-list marketplace registration when catalog serves an app first installed locally (#22877)
## Problem

Fixes #22872.

An app first installed from a **local/CLI source** (`yarn twenty dev` /
tarball upload) gets its `applicationRegistration` created with
`isListed: false` — sensible for a dev app. But when that same app (same
`universalIdentifier`) is later **published to the configured app
registry**, the marketplace catalog sync's update branch in
`upsertFromCatalog` spreads the existing entity and updates
name/sourceType/sourcePackage/version/manifest **without ever setting
`isListed` back to `true`** (only the create branch does).

Result: the app is permanently invisible in the Marketplace tab
(`findManyMarketplaceApps` → `findManyListed()`), while
`installApplication(universalIdentifier)` still works — a confusing
split-brain state with no error anywhere. Reproduced on a self-hosted
v2.18.5 with a private Verdaccio registry (details and repro steps in
the issue).

## Fix

In the `upsertFromCatalog` update branch, re-list the registration
**only when its previous source was local** (`TARBALL`/`LOCAL`):

```ts
const isRelistedFromLocalSource =
  existing.sourceType === ApplicationRegistrationSourceType.TARBALL ||
  existing.sourceType === ApplicationRegistrationSourceType.LOCAL;
...
isListed: existing.isListed || isRelistedFromLocalSource,
```

This deliberately does **not** blanket-set `isListed: true` on every
sync: an operator who delisted a registry-sourced app (via
`updateApplicationRegistration`) keeps their decision — the hourly sync
won't override it.

## Tests

New unit spec `application-registration-upsert-from-catalog.spec.ts`:

- re-lists a registration first created by a local install once the
catalog serves it;
- preserves an operator delisting of a registry-sourced registration;
- keeps an already-listed registration listed;
- still creates new catalog registrations as listed.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22877?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: Nicolas Chanal <nicolaschanal@MacBook-Pro-de-Nicolas.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:51:33 +02:00
Weiko 716e67a276 Copy application source files during build and install (#22991)
## Summary
This is a requirement for the 2-way sync feature

- Copy logic function and front component source files into the build
output during SDK app builds.
- Share application file list construction between install and build
paths so source and built artifacts stay aligned.
- Allow app installs to skip missing optional source files for backward
compatibility while still requiring built artifacts.
- Extend watcher handling to track source-file uploads and restart when
the source set changes.
- Update integration coverage to assert built outputs and copied source
files for minimal apps.

<img width="940" height="165" alt="Screenshot 2026-07-17 at 14 53 40"
src="https://github.com/user-attachments/assets/b9306990-5fc0-4866-a390-3bb8c21a88ad"
/>
<img width="579" height="181" alt="Screenshot 2026-07-17 at 14 53 57"
src="https://github.com/user-attachments/assets/af282318-76c3-49a9-b62a-833a0aadc688"
/>
2026-07-17 17:41:39 +02:00
Félix Malfait aafee63706 Count self-hosted billable seats per distinct user (#22986)
## Context

Self-hosted enterprise pricing is documented as per user ("Pricing is
per user and each user needs a licence"), but seat counts reported to
the enterprise API counted active `userWorkspace` rows. A user belonging
to two workspaces on the same instance was billed as two seats.

## What this does

- Adds `EnterprisePlanService.getBillableSeatCount()`, which counts
`DISTINCT userId` over non-deleted userWorkspaces, keeping the existing
floor of 1.
- Uses it at all four seat-reporting sites: the checkout session
quantity, the seat reports on key activation (`setEnterpriseKey`) and
server-binding release, and the recurring validation cron report.
- Removes the two duplicated private `getActiveUserWorkspaceCount()`
counters and their `UserWorkspaceEntity` repository injections from the
resolver and cron job.
- Adds unit tests for the new method (dedup across workspaces, floor of
1, missing row).

## Intentionally unchanged

- The website `/api/enterprise/seats` and `/checkout` routes apply
whatever quantity the instance reports, so no Stripe-side change is
needed.
- Instance telemetry still reports both `activeUserWorkspaceCount` and
`distinctUserCount`, so the delta stays observable.
- Existing subscriptions need no migration: the next cron seat report
prorates affected instances down automatically.

## Test

- `enterprise-plan.service.spec.ts`: 58 passed (3 new)
- `lint:diff-with-main` and `typecheck` for twenty-server pass

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22986?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-17 16:16:12 +02:00
neo773 f6612e5a85 fix(server): stop treating Microsoft Graph 401 as a permanent failure (#22989)
In production we are seeing some accounts being occasionally marked as
permanent failure even though when you check with their refresh token it
never actually failed this PR stops treating 401 as permanent failure
and treats them as Transient error.

We already have token refresh stage that runs before the actual import
stage, so if it's an actual revoke token error, it should catch it.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22989?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-17 19:13:50 +05:30
Charles Bochet 39082cf787 Only show the install-apps onboarding step to workspace creators (#22990)
New users joining an existing workspace through an invite link were
routed through the "Install your first apps" onboarding step, which is
meant for workspace creation only.

#22347 set `ONBOARDING_INSTALL_APPS_PENDING` inside
`activateOnboardingForUser`, which is shared by both sign-up paths. Gate
it per path like the connect-account step: `true` on
`signUpOnNewWorkspace`, `false` on `signInUpOnExistingWorkspace`.

Verified locally: invited users now go straight from create-profile into
the workspace, and workspace creators still get the install-apps step.
Covered by a unit test on the invite path and integration tests
asserting the onboarding status per sign-up path (invite →
`PROFILE_CREATION`; workspace creation → `SYNC_EMAIL` then
`APPS_INSTALLATION`).
2026-07-17 15:33:42 +02:00
Félix Malfait 5e27e04c0a Add mostly-empty field hints to data model settings (#22962)
## What

Fields that are empty in almost all records now show a subtle `Mostly
empty` hint next to their name in the object's Fields settings table
(same visual treatment as `Deactivated`), with a tooltip explaining the
signal and a matching **Mostly empty** toggle in the search filter
dropdown. The goal is to nudge admins to clean up and deactivate fields
nobody uses, while keeping the page untouched when the data model is
healthy.

## How

**No table scans.** Emptiness is read from Postgres planner statistics,
so the cost is a catalog lookup regardless of table size:

- `pg_class.reltuples` gates the feature on an approximate row count (≥
100 records; never-analyzed tables mean no hints). Reuses the shared
helper extracted from `ObjectRecordCountService`.
- `pg_stats.null_frac` plus the sampled frequency of the column type's
empty sentinel (`''` for text columns, `'{}'` for arrays, `'{}'`/`'[]'`
for json — matched per physical column type) gives a per-column empty
fraction. A value dominating ≥ 95% of a column is guaranteed to appear
in the most-common-values list, so the approximation is reliable exactly
at the threshold we care about.

**Decision rules** (pure util, unit-tested):

- Flag when every relevant column is ≥ 95% empty and the object has ≥
100 records.
- Skip system fields, the label identifier, relations, booleans, and
actor fields (exhaustive switch — a new `FieldMetadataType` fails to
compile until classified).
- Composite fields must have all their columns empty, with column sets
derived from `compositeTypeDefinitions`; only default-bearing code
columns (`currencyCode`, phone country/calling codes) are excluded so
stamped defaults don't mask emptiness.
- Anything unknown (missing stats, new column since last ANALYZE)
degrades to silence — no hint is ever shown on missing data.

**API:** one `mostlyEmptyFieldMetadataIds(objectMetadataId)` query on
the metadata schema, guarded by the `DATA_MODEL` settings permission,
fetched lazily when the fields page opens.

**UI:** exception-based — no new columns, no persistent controls. The
badge and the filter toggle only materialize when at least one field
qualifies, and disappear once things are cleaned up.

## Test

- Unit tests for the decision util (threshold,
system/label-identifier/inactive exclusion, missing statistics,
composite all-columns rule, links label/secondary data, currency
narrowing, excluded types).
- Catalog SQL validated against Postgres 16 with a table mimicking
Twenty's column shapes (text `''` defaults, enums, arrays, jsonb,
currency pairs), including the type-aware sentinel matching (a text
column full of literal `"{}"` strings does not count as empty).
- End-to-end on a seeded dev instance: 899 companies with a mix of
filled/empty fields — the API returned exactly the five fields predicted
by the raw statistics (`annualRevenue`, `employees`, `introVideo`,
`tagline`, `workPolicy`) and correctly excluded `address` (city 33%
filled), actor/system fields, and the label identifier.
- UI driven with Playwright: badge, tooltip copy, filter toggle, and
filtered table all verified visually.
- `lint:diff-with-main`, `typecheck` (server + front), and all three
`graphql:generate` configurations + SDK metadata client regenerated and
committed.
2026-07-17 12:03:11 +00:00
Weiko 20e74d0553 Revert "Remove calendar week view feature flag from public flags" (#22987)
Reverts twentyhq/twenty#22950

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22987?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-17 13:41:05 +02:00
Charles Bochet 2e671342f5 [2/2] Write CREATED at activation, clean stale onboarding workspaces (#22915)
## Context

Follow-up to #22904 (merged), which introduced the `CREATED` activation
status (schema provisioned, onboarding incomplete — no billing
subscription), its enum migration, and the read path. This PR turns the
status on and closes the zombie-workspace leak (~60–110
subscription-less ACTIVE workspaces per day since v2 onboarding, 935+
total).

 **Deploy gating satisfied**: #22904's slow enum migration shipped with
the release deployed to prod on 2026-07-17, so writing `CREATED` is now
safe. Rebased on main (clean, no conflicts) — main's #22943/#22955
guarded-transition rework already handles `CREATED` correctly: the
webhook suspend switch only suspends `ACTIVE` workspaces, and
`reactivateWorkspace` promotes `CREATED`→`ACTIVE`.

## What this PR does

1. **Write path** — `activateWorkspace` sets `CREATED` instead of
`ACTIVE` when the workspace has no billing subscription.
`hasWorkspaceAnySubscription` returns true when billing is disabled, so
self-hosted workspaces keep going straight to `ACTIVE` — no behavior
change outside cloud.
2. **Cleanup** — `CREATED` joins `PENDING_CREATION`/`ONGOING_CREATION`
in the existing onboarding cleaning flow (cron +
`workspace:clean:onboarding` with `--dry-run`): workspaces older than
the same seven-day threshold are soft-deleted, then hard-deleted on a
later run. **No suspension step and no emails** — an abandoned
onboarding is treated as never having completed, exactly like a
workspace stuck in creation. A workspace that subscribes before cleanup
exits the flow (`CREATED`→`ACTIVE` synchronously via checkout).
3. **Backfill** — slow instance command moving `ACTIVE` workspaces with
no `billingSubscription` row, created since v2 onboarding shipped
(2026-07-01), to `CREATED`. Gated on `IS_BILLING_ENABLED` so self-hosted
instances are untouched.
4. **Resolves #22904's text-cast TODO on the billing activation update**
— this PR only deploys after the enum migration, so the
`CREATED`→`ACTIVE` promotion is a plain status-scoped update again. The
upgrade-path filters (`activationStatusIn`) keep the `::text` cast:
upgrade tooling has to run against databases coming from pre-2.22
versions, so its TODO now points at the real removal trigger (dropping
pre-2.22 upgrade support).

## Ops note before deploying

The backfilled zombies are all older than seven days, so the first cron
run after the backfill **soft-deletes them and the next run destroys
them (schema and data), with no user-facing communication**. The
backfill also catches any post-July-1 cloud workspace that is ACTIVE
without a subscription — including intentionally comped/demo/internal
ones if any were created since then (verified locally: the seeded demo
workspaces matched). **Run the backfill's SELECT as a dry-run against
prod and review the list before deploying.**

## CI note

~~`cross-version-upgrade` (and its `ci-server-status-check` aggregate)
is red due to a pre-existing regression on main — `Field metadata
"coreWorkflowVersionId" is missing in object metadata workflowVersion`
on the seed workspaces.~~ Resolved: the rebase picks up main's
#22944/#22961 which fixed that regression.

## Verification

Server-side (billing-enabled local instance, Stripe test mode) and
through the full onboarding UI in both billing modes:

- **Billing enabled, UI**: signup → workspace creation →
**`activationStatus: CREATED`** in DB mid-onboarding → profile/invite
steps work on the CREATED workspace → plan-required page → no-card trial
→ app loads, workspace **`ACTIVE`** with a `trialing` subscription
(exercises #22904's synchronous promotion).
- **Billing disabled, UI**: signup → workspace creation → **`ACTIVE`
directly**, no plan step anywhere, app loads — self-hosted behavior
unchanged.
- **Cleanup**: a `CREATED` workspace backdated 8 days is listed by
`workspace:clean:onboarding --dry-run`; the real run soft-deletes it
silently (no suspension, no email) and the next run hard-deletes it
(workspace row and schema gone).
- **Backfill**: synthetic `ACTIVE` no-sub workspaces — created
2026-07-05 flips to `CREATED`, created 2026-06-15 stays `ACTIVE`,
subscribed workspaces stay `ACTIVE`; billing-disabled short-circuit
returns without touching anything.
- Lint + typecheck green.
2026-07-17 11:26:46 +00:00
Joshua Freedman 4a7324c0c8 fix(serverless): flush IPC message before exiting local function runner (#22920)
Closes #22925

## Problem

`LocalChildProcessRunnerService.writeBootstrapRunner` generates a
child-process runner that returns the function result to the parent over
the Node IPC channel:

```js
const out = await handlerFn(msg.payload);
process.send && process.send({ ok: true, result: out });
process.exit(0);
```

`process.send()` is **asynchronous**. When the serialized payload is
larger than the OS pipe buffer (~64 KB on Linux), it can't be written in
a single synchronous step, and the `process.exit(0)` on the next line
tears the child down before the message is flushed.

On the parent side (`runChildWithEnv`), the lost message means the
`'message'` handler never fires — only `'exit'` with `code === 0` does,
which resolves:

```js
resolve({ ok: true, stdout, stderr }); // no `result`
```

`LocalDriver.execute` then returns `data: result ?? null` → **`null`**,
so the function's return value is silently discarded while the step is
reported as a *success* with an empty result.

## Symptom

Larger serverless / workflow **Code** step results intermittently come
back empty (`{}` / `null`). It is size- and load-dependent, so it
presents as flakiness. A common downstream failure is a workflow
**Iterator** fed the now-missing array:

```
Iterator input items must be an array
```

Results smaller than the pipe buffer always flush synchronously and
never reproduce it — which is why only larger payloads are affected.

## Root cause

`process.exit()` runs before the asynchronous `process.send()` (and the
stdout fallback `process.stdout.write()`) has flushed — the classic Node
footgun of exiting before pending async writes drain.

## Fix

Wait for the `send()` / `write()` flush callback before exiting, on
every exit path (success, error, stdout fallback, outer catch):

```js
if (process.send) {
  process.send({ ok: true, result: out }, () => process.exit(0));
} else {
  process.exit(0);
}
```

Behavior-preserving: it never delivers *less* than before; it only
closes the window where a large result is dropped. No change to the
small-payload happy path.

## Deterministic reproduction

Reproduces the dropped IPC message under back-pressure (parent stalls
before draining), comparing the current pattern vs the fix:

```js
const { spawn } = require('node:child_process');
const fs = require('fs');

const SIZE = 3_000_000; // beyond any pipe buffer
const child = (mode) => `
  process.on('message', () => {
    const out = 'z'.repeat(${SIZE});
    ${mode === 'fixed'
      ? 'process.send({ ok: true, result: out }, () => process.exit(0));'
      : 'process.send({ ok: true, result: out }); process.exit(0);'}
  });`;

const busy = (ms) => { const e = Date.now() + ms; while (Date.now() < e) {} };

function trial(mode) {
  return new Promise((resolve) => {
    const f = `/tmp/child_${mode}.cjs`;
    fs.writeFileSync(f, child(mode));
    const c = spawn(process.execPath, [f], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
    let got = false;
    c.on('message', (m) => { got = m?.result?.length === SIZE; });
    c.on('exit', () => resolve(got));
    c.send({ type: 'run' });
    busy(30); // stall parent so it doesn't drain the IPC pipe promptly
  });
}

(async () => {
  for (const mode of ['current', 'fixed']) {
    let ok = 0; const N = 25;
    for (let i = 0; i < N; i += 5) {
      ok += (await Promise.all([...Array(5)].map(() => trial(mode)))).filter(Boolean).length;
    }
    console.log(`${mode}: result delivered ${ok}/${N}`);
  }
})();
```

Output:

```
current: result delivered 0/25
fixed:   result delivered 25/25
```


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22920?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-17 10:09:28 +02:00
twenty-pr[bot] d99f57bc5e chore: bump version to 2.23.0 (#22975)
## Summary

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

## Checklist

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

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

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-07-17 09:54:30 +02:00
martmull 6360599943 Unify application version gate, registration writes and upgrade paths across sources (#22931)
Continues the source-unification arc after #22921. Three related
consolidations, one commit each.

## 1. Single semver version gate (`793f4849`)

The "incoming version must move forward" rule was hand-rolled twice:
workspace installs (validate semver, reject equal as
`APP_ALREADY_INSTALLED`, lower as `CANNOT_DOWNGRADE_APPLICATION`) and
tarball deploys (reject `lte` as `VERSION_ALREADY_EXISTS`).
`ApplicationVersionValidationService.validateVersionProgression` now
owns the comparison rules and messages; new maps in
`version-reason-to-exception-code.constant.ts` translate failure reasons
to each caller's existing exception codes, so error contracts observed
by the frontend/CLI are unchanged. A non-semver current version never
blocks, matching both previous behaviors.

## 2. One registration-metadata writer (`13eb5f91`)

Tarball upload and marketplace catalog sync wrote registration metadata
with their own repository calls, duplicating the gallery-image fileId
preservation and variable-schema sync, and bypassing the
per-registration lock and transaction that `updateFromManifest`
provides. Both now delegate to `updateFromManifest` (new
`additionalFields` allowlist for their extra columns: `tarballFileId`,
`isListed`, `isVetted`, `ownerWorkspaceId`, `sourcePackage`, `name`), so
every manifest-bearing registration write serializes on the same lock
and applies the same rules. The shared gallery fileId preservation moved
to a `buildRegistrationManifestUpdateFields` util. Tarball uploads can
no longer race installs on the registration row.

Behavior notes: a tarball re-upload whose manifest lacks
`application.displayName` now keeps the existing registration name
instead of resetting it to "Unknown App", and a re-upload without a
`package.json` version keeps the stored `latestAvailableVersion` instead
of nulling it — both strictly less destructive.

## 3. TARBALL upgrades (`b20aaba9`)

`upgradeApplication` only supported NPM; TARBALL apps had no update path
for installing workspaces. It now accepts TARBALL registrations and
re-installs the stored tarball, whose contents define the target version
— the install flow already gates same-version and downgrade installs.
The settings UI shows the latest-version row and the Upgrade button for
both NPM and TARBALL apps via a shared
`isUpgradableApplicationSourceType` util. LOCAL (dev-sync updates) and
OAUTH_ONLY (no code artifacts) stay rejected with a clearer message.

## Validation

- New tests: `validateVersionProgression` matrix in
`application-version-validation.service.spec.ts`,
`buildRegistrationManifestUpdateFields` gallery-preservation spec
- All 27 application suites (148 tests) pass; typecheck and lint green
on twenty-server and twenty-front
2026-07-17 09:06:20 +02:00
neo773 52b7aebddf fix(server): key connected-account lookup on handle and provider (#22964)
Connect flows (Google, Microsoft, IMAP/SMTP/CalDAV) looked up an
existing connectedAccount by `handle` alone, so connecting a second
account with the same handle but a different provider overwrote the
first instead of inserting a new row (e.g. IMAP inbox clobbering a
calendar-only Google account).

Fix: add the `provider` discriminator to the lookup. Same
provider+handle still updates; a different provider gets its own row.
Integration test covers the Google-then-IMAP case.
2026-07-17 00:28:23 +05:30
Thomas Trompette f8b7ecf680 fix(workflow): rebuild core workflowVersion rows in the 2-22 backfill (#22961)
## Problem
Syncing workflowVersion to core fails with `duplicate key value violates
unique constraint "IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW"`. The
sync's `INSERT ... ON CONFLICT ("id")` only dedupes the primary key — it
can't dedupe `IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW`
(`(workspaceId, workflowId) WHERE status='ACTIVE'`). When a leftover
ACTIVE core row exists for a `(workspaceId, workflowId)` with a stale id
(accumulated across the sync's earlier id schemes), inserting the new
active row collides, and the per-id purge misses it.

## Fix
Rewrite the 2-22 `backfill-workflow-version-core-links` command as a
**full rebuild**. Per workspace, in one raw-SQL transaction (core and
workspace schemas are the same database):
1. `DELETE` all `core.workflowVersion` for the workspace — clears every
stale/leftover row.
2. Insert a fresh own-id core row for **every** workspace version.
3. `UPDATE` `coreWorkflowVersionId` on **every** workspace record to its
new core id.

Because it wipes first and re-links all records, leftover ACTIVE rows
can't collide and no record is left pointing at a deleted core row — so
the dual-write's `linked → update` path stays correct afterward.

## Test (local)
Fresh reset, and a reproduced dirty state (leftover ACTIVE core row with
a stale id + a stale link + an unlinked record):
- rebuild runs with no `ONE_ACTIVE` / duplicate-key error,
- leftover wiped, stale link replaced, every record re-linked to a fresh
own-id row,
- 0 dangling/unlinked, no duplicate core rows, exactly one ACTIVE core
version per workflow.
Typecheck + lint + oxfmt clean.

## Note
The 2-20 `backfill-workflow-version-to-core` can still log per-workspace
conflicts on already-dirty instances, but they're non-fatal (the
iterator continues) and this rebuild corrects the end state. The
dual-write (`upsertToCore`) is unchanged and works on the clean data
this produces.
2026-07-16 16:12:50 +00:00
Paul Rastoin fe10975927 fix(billing): guard workspace suspend against concurrent soft-delete (#22955)
## Context

Follow-up to #22943, addressing the cubic review comments left on that
PR (handled in a follow-up as agreed in the thread).

In `BillingWebhookSubscriptionService.processStripeEvent`, the
suspend/reactivate decision re-reads the workspace and then acts on it.
A concurrent soft-delete landing in that window could transition an
already soft-deleted workspace to `SUSPENDED`, contrary to the
guarded-transition behavior (cubic P2).

## Fix

Added `deletedAt: IsNull()` to the `suspendWorkspace` compare-and-swap
WHERE clause, matching the guard already present in
`reactivateWorkspace`. A concurrent soft-delete now blocks the
suspension instead of transitioning a deleted workspace to `SUSPENDED`.

## On the delete guard (cubic P1)

Cubic also flagged that the `PENDING_CREATION` hard-delete path could
hard-delete a concurrently soft-deleted workspace. On review this is not
worth guarding:

- A `PENDING_CREATION` workspace has no DB schema and no records
(activation is what creates them), so there is no data to lose.
- The cleaner already hard-deletes soft-deleted workspaces by design
(`cleaner.workspace-service.ts` soft-deletes a pending workspace, then
hard-deletes it on a later run), so "hard delete an already soft-deleted
workspace" is a supported transition, not corruption.

So P1 is intentionally left out to keep `deleteWorkspace` and all its
callers unchanged.

## Tests

- `suspendWorkspace` update includes `deletedAt IS NULL` and reports
whether the guarded update applied.

Typecheck, lint, and format pass on the changed files.
2026-07-16 14:26:23 +00:00
Paul Rastoin 7939cd8684 feat(server): app lifecycle metrics (install/uninstall/upgrade + marketplace publish) (#22656)
## What

Adds product metrics for the app/marketplace lifecycle so they can be
graphed in Grafana. No app-lifecycle metrics existed before; the flows
only logged.

### New counters (`MetricsKeys`)
- `app-install/succeeded` · `app-install/failed`
- `app-upgrade/succeeded` · `app-upgrade/failed`
- `app-uninstall/succeeded` · `app-uninstall/failed`
- `app-registration/created` (new app published) ·
`app-registration/version-published` (new version available)

All carry `universalIdentifier`, `appName`, `sourceType` attributes
(plus `version`, and `errorCode` on failures).

### New gauge
- `twenty_app_installed_workspaces_total` — observable gauge emitting
the top 100 external apps by installed-workspace count (excludes
built-in LOCAL apps). Powers a "most installed apps" leaderboard;
combine with the 24h install/uninstall event counters for recent
activity.

## Where metrics are emitted
- **Install / upgrade**
(`ApplicationInstallService.doInstallApplication`): success + failure
branches, distinguished by the existing `isVersionUpgrade` flag.
- **Uninstall** (`ApplicationInstallResolver.uninstallApplication`): at
the resolver, deliberately *not* in the sync service, so
rollback-triggered internal uninstalls (fired from the install catch
block) don't pollute uninstall counts.
- **Publish / new version**: `upsertFromCatalog` (npm marketplace sync),
`checkForUpdates` (npm version poll), and the tarball CLI publish path.

### Exactly-once version-published
Both the catalog-sync and version-check crons converge
`latestAvailableVersion`. Each emission point is **change-guarded**
(`stored !== incoming`), so whichever cron observes the change first
emits, and the other becomes a no-op. No double counting, no
race-dependent misses.

## Pipeline
Metrics flow through the existing OTel -> ClickHouse path and can be
graphed from the `twenty-product-metrics` dashboard (dashboard changes
live in infra-twenty, not this PR).

## Test plan
- [x] `nx typecheck twenty-server`
- [x] oxlint + oxfmt on changed files
- [x] `oauth-discovery.controller.spec` (the one existing spec touching
these services) passes
- [ ] Reviewer: sanity-check metric names/attributes and cardinality
choices (no `workspaceId` attribute, LOCAL apps excluded from the gauge)


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22656?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: martmull <martmull@hotmail.fr>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-07-16 15:53:59 +02:00
Weiko a5108d512f Block filters on restricted fields (#22873)
## Summary
- Reject filter conditions that target fields without read access,
including relation traversals
- Add unit and integration coverage for denied field filter access

The issue is an information leak through filtering: a user who cannot
read a field can still infer its values from totalCount

### Manual reproduction
- Create or use a non-admin role.
- Give it read access to People records.
- Disable read access to the Person jobTitle field.
- Assign a test member to that role.
- Authenticate as that member.

Run:
```gql
query People($filter: PersonFilterInput) {
  people(filter: $filter, first: 0) {
    totalCount
  }
}
Variables:
{
  "filter": {
    "jobTitle": {
      "like": "Par%"
    }
  }
}
```
Ensure at least one Person has a matching jobTitle.
Before the fix, the request succeeds:
```gql
{
  "data": {
    "people": {
      "totalCount": 1
    }
  }
}
```
The caller can probe restricted values using different filters.
After the fix, it returns a permission error:
```gql
{
  "errors": [
    {
      "message": "Permission denied"
    }
  ]
}
```
The same should happen through a relation filter, for example filtering
Companies by a restricted Person field:
```gql
query Companies($filter: CompanyFilterInput) {
  companies(filter: $filter, first: 0) {
    totalCount
  }
}
{
  "filter": {
    "people": {
      "jobTitle": {
        "like": "Par%"
      }
    }
  }
}
```
2026-07-16 15:19:46 +02:00
Thomas Trompette 988f8ff900 feat(workflow): provision coreWorkflowVersionId on existing workspaces and link them (#22944)
Stacked on the write-back guard hotfix (#22940). Completes the version
soft-ref for workspaces that predate the `coreWorkflowVersionId` field.

## Why
New standard fields aren't auto-synced to existing workspaces; they're
only built at workspace creation or added by an explicit upgrade
command. Deployed 2.20/2.21 instances also carry **legacy core rows with
`id = record.id`** (the pre-soft-ref shared-UUID model, from the
already-run #22663 backfill). The original backfill has already run
there and won't re-run (tracking is by command name), so the migration
to soft-ref has to be **new appended commands**.

## What (two appended 2-22 workspace commands)
1. `add-workflow-version-core-soft-ref-field` (`1784193206000`): adds
the `coreWorkflowVersionId` system field to existing workspaces missing
it (flat-entity migration, `add-message-campaign-stat-fields` pattern).
Idempotent, dry-run aware, skips workspaces without the
`workflowVersion` object.
2. `backfill-workflow-version-core-links` (`1784193207000`): re-runs the
sync (`upsertToCore`). For each version, `upsertToCore` **purges the
legacy shared-id core row** (`id === record id`) then upserts a
deterministic own-id row and writes the link back onto the workspace
record. Targeted per-id delete — it does not wipe unrelated core rows.

Ordering: both run after the original 2-20 backfill. On instances that
run 2-20 fresh (e.g. 2.19 → 2.22) that backfill creates core rows and —
via the hotfix guard — skips the write-back until the field exists;
command 1 provisions the field; command 2 purges + relinks. On
already-migrated 2.21 instances the 2-20 backfill won't re-run, so
command 2 is what clears their shared-id rows.

The same per-id purge in `upsertToCore` also covers the dual-write path:
between the 2.22 deploy and command 2 running, an edited version would
otherwise collide with its shared-id row on the one-active-per-workflow
index.

Scope: version side only. The workflow-side equivalent
(`coreWorkflowId`) ships with the workflow-side sync PR; `core.workflow`
was never backfilled in prod, so it has no legacy shared-id rows.

## Test
- Unit: guard skips write-back when the field is absent; runs it when
present.
- Happy path (fresh reset): original backfill → add-field no-op → link →
4/4 linked, own ids, 0 duplicates.
- Deployed migration (simulated 2.21: shared-id ACTIVE core rows + field
removed): add-field re-provisions → link purges the shared-id rows and
rebuilds → records linked to own-id rows, 0 duplicates, exactly one
ACTIVE core version per workflow (index intact), no collision.
- Idempotent re-run: still 4 core rows, links resolve.
- Typecheck + lint + oxfmt clean.
2026-07-16 13:18:37 +00:00
Weiko fdb78b35d0 Handle scalar select filter values when recomputing view filters (#22930)
## Summary
Fixing `Internal Server Error: Unexpected invalid view filter value for
filter`

Updating a select field’s options failed with an INTERNAL_SERVER_ERROR
when the field had an associated IS_NOT_EMPTY view filter.
The affected filter stored an empty string as its value:
```
operand: IS_NOT_EMPTY
value: ""
```

### Root cause
The option-update side effect treated every associated select filter
value as an option array. It attempted to parse the empty string and
then rejected the result because it was not an array.
However, IS_NOT_EMPTY is a value-less operand, so its empty value is
valid and should not participate in option recomputation.

### Fix
Skip option-value recomputation for operands that do not expect a value,
including IS_NOT_EMPTY.
Normalize legacy scalar select-filter values using the same logic as
filter validation.
Leave subfield filters unchanged.
Preserve compatibility with legacy filter-value representations until
they are migrated to the canonical JSON format.
2026-07-16 12:52:10 +00:00
Thomas Trompette e9bc06a830 fix(workflow): skip core version id write-back when the field is missing (#22940)
## What
The version soft-ref sync (#22821, on main / the 2.22 line, not yet
released) added a **write-back** step: after copying a `workflowVersion`
into `core.workflowVersion`, it sets `coreWorkflowVersionId` on the
workspace record. On workspaces that predate that field (new standard
fields aren't auto-provisioned onto existing workspaces), the write-back
throws:

`Field metadata for field "coreWorkflowVersionId" is missing in object
metadata workflowVersion` (from `formatData`).

This breaks the sync wherever it runs on an unprovisioned workspace —
the backfill (when it runs under the new code) and the dual-write (on
any workflow-version create/update). Observed on staging while upgrading
to 2.22: 2 of 70 workspaces. **2.20 itself was fine** — it ran the old
shared-UUID sync, which had no write-back.

## Fix
Guard the write-back: check the workspace's `workflowVersion` object for
the `coreWorkflowVersionId` field (via `workspaceCacheService` flat
field maps) and skip it with a warning if absent, instead of throwing.
The core row is still upserted; the workspace gets linked later once the
field is provisioned.

## Follow-up
Provisioning `coreWorkflowVersionId` onto existing workspaces and
linking them is #22944 (version side). The workflow-side equivalent
follows with the workflow-side sync PR.

## Test
Unit test covers both branches: field absent → write-back skipped, no
throw, core upsert still runs; field present → write-back runs.
Typecheck + lint clean.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22940?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-16 14:25:11 +02:00
github-actions[bot] 5d9d33b513 i18n - translations (#22951)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-16 14:17:19 +02:00
Weiko 2a7e87125f Remove calendar week view feature flag from public flags (#22950)
## Summary
- Remove `IS_CALENDAR_WEEK_VIEW_ENABLED` from the public feature flag
registry
- Keep the remaining public feature flags unchanged

## Note
Needs more polish before being released in the Lab
2026-07-16 14:09:58 +02:00
Paul Rastoin 217cf650aa Decide workspace destiny from live data and all sub (#22943)
## Context

A production customer was stuck on the billing settings page: their
workspace was `SUSPENDED` (with `suspendedAt` set) while their
subscription was `active` in the database.

## Problem

Stripe webhook events can be delivered out of order or processed
concurrently ([Stripe explicitly does not guarantee
ordering](https://docs.stripe.com/webhooks#events-ordering)), and
`BillingWebhookSubscriptionService.processStripeEvent` made its
suspend/reactivate decision from stale and incomplete data:

- The decision used the **event payload's** subscription status, while
the subscription row was upserted from a **live Stripe fetch** — so the
two could diverge. Around trial end, Stripe emits `active → past_due`
then (once the customer pays) `past_due → active` within a short window.
If the stale `past_due` event is processed last, it suspends the
workspace while writing an `active` subscription to the DB. Nothing
self-heals from that state: the workspace stays suspended while the
cleanup cron warns and eventually soft-deletes it.
- The decision only looked at the **event's own subscription**, but
suspension is a workspace-level decision and a Stripe customer can hold
several subscriptions (plan switch, cancel-then-resubscribe). A
`customer.subscription.deleted` event for the old subscription is
*genuinely* canceled — only the sibling subscription proves the customer
is still paying.
- The workspace snapshot was read at the top of the handler, before
several slow awaits, so a concurrent event could change it mid-flight.

## Fix

Every event now converges the workspace to the current Stripe state,
regardless of delivery order:

- **Live input**: fetch the customer's not-ended subscriptions from
Stripe (`subscriptions.list` without a `status` param excludes the
unbounded canceled history server-side; an explicit
`NOT_ENDED_SUBSCRIPTION_STATUSES` filter additionally drops
`incomplete_expired`, which would otherwise block suspension forever
since it is neither suspend-worthy nor activating). The event's
subscription is taken from that list, or fetched directly by id when
absent (deletion events, deleted customers) — same retrieve the code
used before. The DB upsert uses this live object, never the payload.
- **Workspace-level decision over all live subscriptions**: suspend only
when **every** subscription warrants it, reactivate as soon as **one**
is activating (`active`/`trialing`), and deliberately do nothing in
between — e.g. a `past_due` subscription in its payment-retry grace
period blocks suspension without triggering reactivation.
- **Guarded transitions (compare-and-swap)**:
`WorkspaceService.suspendWorkspace`/`reactivateWorkspace` now apply
their UPDATE only when the workspace is still in a state the transition
is valid from, and return whether they applied. Repeated suspensions
keep the first `suspendedAt` so the cleanup countdown stays anchored to
the original suspension date; the deletion-warning cleanup job is only
enqueued when a reactivation actually applied. The suspend path re-reads
the workspace right before deciding and switches exhaustively on
`activationStatus` (`assertUnreachable` in `default`), preserving the
previous behavior including suspend-over-reactivate precedence.

## Tests

Unit tests cover the incident scenario and its neighbors: a stale
`past_due` event after payment reactivates instead of suspending; a live
`unpaid` state suspends even when the payload says `active`; a canceled
subscription event does not suspend (and reactivates) when a sibling
`active`/`trialing` subscription exists; a sibling in `past_due` grace
blocks suspension without reactivating; the direct-retrieve fallback
handles subscriptions absent from the customer list; the guarded
reactivation skips the cleanup job when it did not apply; and
soft-deleted workspaces are never transitioned.
2026-07-16 14:09:09 +02:00
Charles Bochet 9860871eac perf(server): lower workspace local cache cap to 6,000 entries (#22946)
## Context

#21954 raised `MAX_LOCAL_CACHE_ENTRIES` from 1,000 to 7,500 to stop the
per-pod workspace metadata cache from thrashing against Redis. That
worked: the cache node's egress dropped from ~1.1-1.8 TB/day to ~0.5-0.7
TB/day and has stayed there.

The memory side of the trade turned out tighter than the sizing assumed.
Prod-eu metrics over the past month:

- Average server pod working set rose from ~1.9-2.4 GiB (before the
rollout on 06/26) to ~3.0-3.4 GiB, with individual pods peaking at
3.5-3.6 GiB, right at the `--max-old-space-size=3500` heap ceiling on 4
GiB pods.
- The `workspace-metadata-cache/local-eviction` counter added in #21954
stayed at zero for three weeks, then on 07/15 between 08:30 and 09:30
UTC all 7 server pods hit the 7,500-entry cap within an hour (no deploy
that morning; organic growth crossed the threshold during the European
morning peak). Since then the fleet evicts continuously (~167k
entries/day).
- Server pods were terminated with reason OOMKilled on 8 days of the
past month (1-2 pods each time).

The per-entry payload also grew since the cap was sized: search field
metadata backfills, two new per-workspace cache components
(`flatSearchFieldMetadataMaps`, `workflowAutomatedTriggerMaps`), and the
heap-only `ORMEntityMetadatas` entries. So the byte ceiling implied by
7,500 entries now sits exactly at the heap limit instead of comfortably
under it.

## What this does

Lower `MAX_LOCAL_CACHE_ENTRIES` 7,500 → 6,000. At the measured ~170-200
KB average entry size this frees roughly 300-500 MB of steady-state heap
per pod, restoring headroom under the 3.5 GB old-space limit.

Traffic cost should be small: eviction churn at the 7,500 cap only moved
cache egress from ~0.55 to ~0.65-0.68 TB/day, so a 20% smaller cap
should keep the bulk of the #21954 reduction. The eviction counter gives
us the feedback loop; if egress climbs materially we can split the
difference, and if pods still run hot the next lever is byte-aware
eviction for the few known-heavy keys.
2026-07-16 12:34:02 +02:00
github-actions[bot] 1b9152d4c5 chore: sync AI model catalog from models.dev (#22939)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-16 08:47:13 +02:00
Weiko 9c2d87a846 Fix role lookup query and align calendar layout behavior (#22928)
## Summary
Fixes slow getRoles requests caused by loading several one-to-many role
relations in a single TypeORM query.

The previous query produced a Cartesian product across role targets,
permission flags, object permissions, and field permissions. In
production, a workspace with 9 roles expanded into more than 32,000
database rows before TypeORM hydration.

This change:
- Reads roles and their related permissions from the existing flat-map
caches.
- Hydrates relations using the same findManyWithRelationsFromCache
pattern as ViewService.
- Removes the expensive joined query from the getRoles path.
- Preserves the existing GraphQL response shape.

## Performance
### Before:
9 roles
32,257 SQL result rows
Approximately 10 seconds observed request latency
### After:
No Cartesian SQL query
Cache-backed lookups and in-memory relation hydration

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22928?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-15 20:56:09 +02:00
martmull 67ed2689ce Unify manifest apply pipeline between application install and dev sync (#22921)
First step of unifying application behaviors across sources (NPM,
TARBALL, LOCAL), following up on #22868. No storage layout changes.

## Problem

Marketplace/tarball installs (`ApplicationInstallService`) and CLI dev
sync (`ApplicationDevelopmentService`) each implemented the second half
of application delivery — metadata sync, SDK client generation,
registration refresh — with quietly diverging behavior:

- Install decided SDK regeneration on `!isVersionUpgrade` (application
row exists), dev sync on `!application.version`. The version-based check
is the robust one: a failed first install leaves an application row
without a version, and the row-based check then skipped SDK generation
on retry unless the schema changed.
- Install refreshed the registration manifest (`updateFromManifest`)
without syncing its variable schemas, while catalog sync, tarball
upload, and dev sync all do. An NPM install that bumped
`latestAvailableVersion` with new `serverVariables` left the
registration's variable schemas stale until the next catalog cron.
- The guards protecting shared registrations from workspace writes
(npm-sourced or not owned by the workspace) lived only inside dev sync's
`syncRegistrationMetadata`.

## Change

New `ApplicationManifestApplyService` (application-manifest module) owns
those steps for both flows:

- `applyManifestToWorkspace`: `synchronizeFromManifest` + SDK client
generation when it's the first successful apply (no persisted version)
or the schema changed. Used by install and dev sync.
- `refreshRegistrationFromManifest`: guarded registration update +
variable schema sync. Callers acting for a workspace (dev sync) pass
`onlyIfOwnedByWorkspaceId`, moving the npm/ownership guard into the
shared service; installs pass `latestAvailableVersion` +
`preventVersionDowngrade` as before. Returns whether anything was
written so dependent side effects (registration asset store in dev sync)
skip together with it.
- `ApplicationRegistrationService.updateFromManifest` now returns
whether it wrote, so variable schemas are only synced from manifests
that were actually applied — previously a downgraded install would still
have synced variables from the older manifest if we had naively added
the sync there.

Install and dev services lose the duplicated logic (and their direct
`SdkClientGenerationService` / variable-service dependencies); hooks and
file writes stay where they were.

## Behavior deltas (all deliberate)

- NPM/TARBALL installs now sync registration variable schemas when they
refresh the registration.
- Retrying a failed first install regenerates the SDK client even when
the schema diff is empty.

## Follow-ups (agreed plan, separate PRs)

Single semver version gate; one registration-metadata writer with lock
coverage for dev-sync/tarball; unified upgrade incl. TARBALL;
recoverable upgrades + stale file cleanup; tarball storage decoupled
from the owner workspace; orphan cleanup cron; faster public-asset
serving (ETag/304); parallel install file writes; PREBUILT execution
mode for app logic functions.

## Validation

- New spec `application-manifest-apply.service.spec.ts` (9 tests: SDK
generation matrix, ownership/npm guards, downgrade-skip short-circuits
variable sync)
- All 26 application suites pass; typecheck, oxlint (type-aware) and
oxfmt green on twenty-server

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22921?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-15 19:36:01 +02:00