Commit Graph

5071 Commits

Author SHA1 Message Date
martmull cb5d64fefc Add twenty-exa application to internal app ci (#21882)
renamed exa to twenty-exa
add twenty-exa to ci check

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21882?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-06-22 11:36:33 +02:00
Charles Bochet 153e41e036 ci: block bot contributors from PR commit history (#21926)
## What

Adds a CI check (`Blocked Contributors Check`) that runs on every PR and
**fails** if any commit is attributed to a known bot — via the commit
author, committer, or a `Co-Authored-By:` trailer.

Goal: keep automated agents (Claude, Cursor, Copilot, …) out of Twenty's
contributor history.

## How

- On `pull_request` (`opened`, `synchronize`, `reopened`) it fetches all
PR commits via the GitHub API and matches author/committer name+email
and the full commit message (for trailers) against an editable
blocklist.
- Patterns target **bot identities** (emails / `[bot]` handles), **not**
bare first names — so a human contributor named "Claude" is *not*
flagged.
- On failure it emits `::error::` annotations naming the offending SHA +
what matched, plus remediation guidance (rebase with `--reset-author`,
strip trailers, force-push).

Current blocklist:

```
noreply@anthropic.com
@anthropic.com
cursoragent@cursor.com
copilot-swe-agent[bot]
```

Add a line to block another bot — no logic changes needed.

## Notes

- This workflow only *reports* a failed status. To actually block
merges, add **Blocked Contributors Check** as a required status check in
branch-protection rules for `main` (repo Settings → Branches).
- `@anthropic.com` also blocks any Anthropic-domain identity; narrow to
just `noreply@anthropic.com` if real Anthropic employees may contribute
under their work email.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21926?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-06-22 09:37:13 +02:00
Abdul Rahman 1b7dc0367e fix(ai): gemini not working in ask ai (#21898)
Upstream issue: https://github.com/vercel/ai/issues/14369

Gemini 400s whenever a tool result contains JSON Schema `$ref`/`$defs`
(it reads `$ref` as a function declaration name and finds no match). We
hit this because `learn_tools` returns tool input schemas, and our
recursive filter schema emits `$ref`/`$defs`. Other providers accept it
fine, so this only blocks Gemini.

Adds a Google-only `wrapLanguageModel` middleware that serializes
ref-bearing tool results to text before they reach Gemini, so the
pointers travel as a string instead of structured keys. The model still
reads the full schema (same as the MCP path). Guarded so normal tool
results pass through untouched.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21898?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: Charles Bochet <charles@twenty.com>
2026-06-22 07:16:59 +00:00
Mani bharadwaj 3ad5259106 fix(twenty-server): remove uuid format from openapi pageInfo cursors (#21920)
## Summary

The OpenAPI schema for list responses declared `pageInfo.startCursor`
and `pageInfo.endCursor` with `format: 'uuid'`, but the API actually
returns base64-encoded cursor strings. This makes the documented schema
inconsistent with the real response and breaks client generators that
trust the `uuid` format.

Closes #20003

## Changes

- Removed `format: 'uuid'` from `startCursor` and `endCursor` in
`getFindManyResponse200`.
- Removed `format: 'uuid'` from `startCursor` and `endCursor` in
`getFindDuplicatesResponse200`.

## Verification

- `npx nx lint:diff-with-main twenty-server` passed.
- `npx oxlint` on the modified file passed with 0 warnings/errors.
- `npx nx jest twenty-server --testPathPattern=open-api/utils` passed
(11 tests, 4 snapshots).

Note: `npx nx typecheck twenty-server` and the default `nx test` target
hit pre-existing build errors in `twenty-ui` (unrelated Tabler icon/type
mismatches), so I used the project`s `jest` target for focused
verification.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21920?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: Claude <noreply@anthropic.com>
2026-06-22 08:38:58 +02:00
Félix Malfait 2abf9c2930 feat(workflow): Pick Record load balanced strategy (3/3) (#21902)
## Overview

Final PR in the Pick Record stack. Adds the **Load Balanced** strategy:
pick the candidate that currently has the *fewest related records*. This
is the "fair assignment" mode — e.g. assign a new company to the account
owner who currently owns the fewest companies, or route a lead to the
rep with the fewest open opportunities.

**Stacked on #21900** (which is stacked on #21899) — merge in order.
This PR's diff against `main` includes PRs 1 & 2 until they merge.

## What changed

- Widened the `strategy` enum to add `LOAD_BALANCED`, and added an
optional `loadBalance: { objectNameSingular, fieldName }` to the action
input.
- Editor: selecting **Load balanced** reveals a **Balance by** object
picker and a **Count by** field picker (the related object's many-to-one
relation fields).
- Executor: for each candidate, counts records of the chosen related
object whose chosen relation points at that candidate, then selects the
least-loaded one.

## How it works

Given pool = workspace members and config `{ objectNameSingular:
"opportunity", fieldName: "pointOfContact" }`, the executor counts, per
member, the opportunities whose `pointOfContact` is that member, and
picks the member with the lowest count.

## Design decisions & tradeoffs

1. **No persistent state — computed live each run.** Unlike round robin,
load balancing reads current data, so there's no cursor to store.
Correct by construction even under concurrency (each run recomputes
counts); the only caveat is two simultaneous runs can both see the same
"least loaded" candidate before either assignment lands (a small,
self-correcting skew), which is inherent to load-balancing and
acceptable.

2. **Count via per-candidate queries.** One filtered count per candidate
(`{ [relationField]: { id: { eq: candidateId } } }`), run in parallel.
For the realistic pool sizes this targets (a team), this is simple and
clear. A single `group_by` aggregate would scale better for very large
pools — noted as a future optimization, deliberately not done to keep
the logic obvious.

3. **Deterministic tie-break.** Candidates are pre-sorted by id (shared
with round robin), and the first minimum wins — so equal-load ties
resolve deterministically rather than arbitrarily.

4. **`Count by` lists all many-to-one relations of the chosen object**
(not filtered to those targeting the pool object). Keeps the editor
simple; picking an unrelated field just yields zero counts, which is
visibly wrong. Filtering options to relations that target the pool
object is a nice follow-up.

5. **Filter on the counted set** (e.g. only *open* opportunities) is
intentionally out of scope for this first cut — documented as a
follow-up.

## Testing

Added `pick-record-load-balanced-workflow.integration-spec.ts`: creates
two fresh companies (0 related opportunities each), attaches one
opportunity to the second, configures `LOAD_BALANCED` counting
opportunities by `company`, and asserts the step picks the **first**
company (0 < 1). Passes locally alongside the random and round-robin
tests (3 suites / 4 tests). `typecheck` + `lint:diff-with-main` green
for shared/server/front.

## The full stack

1. #21899 — Random (the action + the whole scaffold)
2. #21900 — Round robin (atomic Redis cursor)
3. this — Load balanced

Together these enable round-robin / load-balanced / random **assignment
workflows** in Twenty, composed via the standard variable picker (assign
the chosen record downstream with `{{step.<id>.id}}`).

https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21902?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-06-22 07:09:15 +02:00
Félix Malfait fa6d1394af feat(workflow): Pick Record round robin strategy (2/3) (#21900)
## Overview

Second PR in the Pick Record stack. Adds a **Round Robin** selection
strategy alongside Random, so an assignment workflow can distribute
records *evenly* across a candidate pool (e.g. rotate company ownership
across a set of workspace members) rather than just randomly.

**Stacked on #21899** — review/merge that one first. This PR's diff
against `main` includes PR 1's commits until #21899 merges.

## What changed

- Widened the `strategy` enum (`RANDOM` → `RANDOM | ROUND_ROBIN`) in the
shared schema and the server input type.
- Editor now shows a **Strategy** selector (Random / Round robin). The
candidate-pool label changed from "Pick at random from" to the neutral
"Pick from" since random is no longer the only mode.
- Executor implements round robin.

## Design decisions & tradeoffs

1. **State store: Redis `incrBy` (atomic), keyed
`pick-record:round-robin:{workspaceId}:{stepId}`.** Round robin needs a
persistent cursor, and workflow runs are **not** serialized — two runs
can execute the same step concurrently — so the increment must be
atomic. `CacheStorageService.incrBy` (workflow cache namespace) is a
single atomic Redis op, needs no schema change, and is already
injectable. Index = `(cursor - 1) % poolSize`.

**Tradeoff — durability:** a Redis flush/eviction resets the cursor,
which restarts the cycle from an offset. That causes a one-time
*fairness drift*, never a *correctness* bug (no double-assignment, since
each increment is atomic). If strict durability is ever required, the
cursor can move to a Postgres counter table with `INSERT … ON CONFLICT …
DO UPDATE SET cursor = cursor + 1 RETURNING cursor` (atomic + durable) —
deliberately **not** done here to avoid a migration for what is, in
practice, an acceptable reset.

2. **Deterministic pool ordering.** The resolved pool is sorted by `id`
before the cursor is applied, so position→record mapping is stable
run-to-run regardless of fetch order. Without this, round robin wouldn't
reliably cycle.

3. **Cursor key uses `stepId`.** Stable across runs of a published
version. Republishing a version may mint new step ids, which resets the
cursor — acceptable and documented here.

4. **Slot-on-increment.** The cursor increments when the step runs
(reserving a position); if a later step in the run fails, that position
is effectively skipped. Minor, acceptable unfairness — flagged rather
than adding cross-step compensation.

## Testing

Added `pick-record-round-robin-workflow.integration-spec.ts`: builds a
workflow with a 3-record pool and `ROUND_ROBIN`, runs it 4 times
sequentially, and asserts the picks are exactly `[p0, p1, p2, p0]` (full
cycle + wraparound) against the deterministically-ordered pool. Passes
locally alongside PR 1's random test (2 suites / 3 tests). `typecheck` +
`lint:diff-with-main` green for shared/server/front.

## Follow-up

- PR 3: `LOAD_BALANCED` (fewest related records wins).

https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21900?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-06-21 22:16:26 +02:00
Félix Malfait a682c8fa62 feat(sdk): declare row-level permission predicates in the role manifest (#21919)
## Why

Apps can declare object and field permissions on a role via
`defineRole`, but **not row-level security**. The RLS engine and the
metadata-sync machinery already support predicates fully — they're
first-class universal flat entities, the `FlatRole` already carries
`rowLevelPermissionPredicateUniversalIdentifiers`, and the
workspace-migration layer has builders/validators/handlers for them. The
only gap was the **manifest layer**: `RoleManifest` had no field for
predicates, so the sync converter always left them empty.

As a result, the only way to ship RLS with an app was a post-install
script that pushed predicates through the
`upsertRowLevelPermissionPredicates` mutation. That mutation assigns
predicates to the workspace's **generic custom application**, not the
app that owns the role — so a single role's definition ends up split
across two applications and drifts on every upgrade (you have to
remember to re-run the script). The Partner app does exactly this today
via `configure-partner-rls.ts`.

## What

Adds `rowLevelPermissionPredicates` and
`rowLevelPermissionPredicateGroups` to `RoleManifest` / `RoleConfig`,
mirroring how `objectPermissions` / `fieldPermissions` already flow
end-to-end:

- **twenty-shared** — predicate + predicate-group manifest types on
`RoleManifest` (referencing objects/fields by `universalIdentifier`,
operand/logical-operator from the existing GraphQL enums).
- **twenty-sdk** — `defineRole` accepts and validates them; the build
derives deterministic predicate `universalIdentifier`s (groups keep an
explicit one so predicates can reference them).
- **twenty-server** — two converters turn manifest predicates/groups
into universal flat entities during application-manifest sync, so they
are created/updated/deleted together with the role and **owned by the
app that ships it**.

### Bug fix found along the way

The migration build order ran the `rowLevelPermissionPredicate(Group)`
builders **before** the `role` builder, so a predicate declared
alongside a brand-new role failed validation with `ROLE_NOT_FOUND`. They
now run **after** the role builder, exactly like object/field
permissions.

## Partner app (second commit)

Converts `partner.role.ts` to declare its five predicates inline and
**deletes `configure-partner-rls.ts`** + the `rls:configure` scripts —
the workaround this PR is meant to retire. The predicates are
byte-for-byte the same semantics as the script produced.

> Live-deployment note: the existing script-created predicates are owned
by the *custom* application, so the Partner app sync won't touch them.
Clear them once (e.g. an empty upsert on the Partner role) around deploy
to avoid duplicates. Kept as a **separate commit** so it can be split
out if reviewers prefer.

## Testing

- **Integration (full app):** new
`successful-manifest-sync-row-level-permission-predicate.integration-spec.ts`
— installs an app whose role declares a predicate and asserts the
predicate row is created (and **owned by the app**, not the custom app),
updated in place on re-sync, removed when dropped from the manifest, and
removed on uninstall. Ran locally against a seeded test DB .
- Re-ran the existing cross-app permission + view-field manifest suites
to confirm the build-order change doesn't regress
object/field-permission sync (13/13 ).
- **Unit (utils only):** `defineRole` validation and
`fromRoleConfigToRoleManifest` deterministic-id derivation.
- Docs: new "Row-level security" section in `apps/config/roles.mdx`.

## Scope notes / possible follow-ups

- Surfacing RLS in the app-install permission summary UI was
intentionally left out (predicates *restrict* rather than grant, and
typically live on a non-default role) — easy follow-up if wanted.
- The `upsertRowLevelPermissionPredicates` mutation still homes
out-of-band predicates on the custom app for app-owned roles; making
that consistent (or rejecting it, like field permissions already do) is
a sensible follow-up.

https://claude.ai/code/session_01MipAis9z9okd4oCm9HCKEf

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21919?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-06-21 22:09:19 +02:00
Félix Malfait 573fd00ea7 feat(workflow): add Pick Record action (1/3 — random selection) (#21899)
## Overview

Adds a new workflow action, **Pick Record**, that selects **one** record
from a configured candidate pool and exposes the chosen record as the
step's output. Downstream steps can then reference it through the normal
variable picker — e.g. assign an owner in an _Update Record_ step by
setting **Account Owner = `{{step.<pickRecordId>.id}}`**.

This is the foundation for building **assignment workflows**
(round-robin / load-balanced owner assignment, reviewer rotation, etc.)
in Twenty.

## This is PR 1 of a 3-PR stack

| PR | Strategy | Adds |
|----|----------|------|
| **1 (this one)** | `RANDOM` | The whole `PICK_RECORD` action,
end-to-end, stateless |
| 2 | `ROUND_ROBIN` | A persistent, atomically-incremented per-step
cursor + the strategy selector UI |
| 3 | `LOAD_BALANCED` | "fewest related records wins" via an aggregate
count |

Each PR widens the `strategy` enum (a backward-compatible change), so no
data migration is needed between them.

## How it works

- **Editor**: pick an Object, then pick the candidate records (a
multi-record selector). A random record is selected from that pool at
run time.
- **Output**: a single record of the chosen object — the same output
shape as `CREATE_RECORD`/`UPDATE_RECORD` — so it drills into
`{{step.x.id}}`, `{{step.x.name}}`, … in the variable picker.
- **Execution**: reuses `FindRecordsService` to fetch the pool (`id IN
(recordIds)`, which also transparently drops any deleted candidates),
then returns one at random.

## Design decisions & tradeoffs

1. **Standalone step that outputs a variable, not an inline "random"
mode on the relation field.** This mirrors Attio's round-robin block.
The decisive reason is composition: the chosen record is almost always
reused (assign owner **and** create a follow-up task for them **and**
email them). A variable is chosen once and reused everywhere; an inline
per-field value would re-roll independently in each place. It also keeps
the (stateful) round-robin/load-balanced logic out of the field inputs.
Tradeoff: one extra step to wire up vs. an inline control — accepted for
the composability win. An inline "Assign automatically" entry point can
still be layered on later as sugar that inserts this step.

2. **Co-located in the `record-crud` action module and reuses
`FindRecordsService`.** Avoids duplicating module wiring (auth context,
permissions, object-metadata resolution) and the data-access path.
Tradeoff: "Pick" is a selection rather than a CRUD op, so the folder
name is slightly broad; chose reuse + low risk over a separate module.
Can be extracted if the family grows.

3. **`strategy` exists in the schema (defaulted `RANDOM`) but the
selector is hidden in this PR.** A dropdown with a single option would
be UX slop, and adding the field only in PR 2 would force a data
backfill for any `PICK_RECORD` steps created in between. Keeping the
field now (hidden) avoids both. PR 2 introduces the selector once
there's a real choice.

4. **Pool is an explicit static list (`recordIds`) for v1.** Matches the
most common assignment case ("rotate among these N people") and reuses
the existing `FormMultiRecordPicker`. A filter-based pool (reusing the
Find Records filter UI) and a list-from-a-previous-step pool are natural
follow-ups, intentionally out of scope here to keep the stack focused on
the three strategies.

5. **Output schema is computed on the frontend** (like `CREATE_RECORD`),
derived from `input.objectName` — so it is **not** added to
`PERSISTED_OUTPUT_SCHEMA_TYPES` and needs no server-side schema
computation.

6. **Validation**: `PICK_RECORD` is added to object-name metadata
validation (so a deleted/invalid target object is flagged) via a
dedicated `OBJECT_TARGETING_ACTION_TYPES` set — deliberately **not** to
`VARIABLE_CONSUMING_ACTION_TYPES`, because a static pool legitimately
references no upstream variable and would otherwise raise a spurious "no
variable reference" warning.

7. **Empty pool → step error** at run time (respecting the step's
error-handling options) rather than a silent no-op, since an empty pool
is a misconfiguration or fully-deleted set.

8. **`Math.random`** is used for selection — no cryptographic guarantee
is needed for assignment fairness.

## Testing

Per our testing convention (integration test over service/`.spec`
tests): added `pick-record-workflow.integration-spec.ts`, which builds a
workflow with a manual trigger + a `PICK_RECORD` step, configures a
known two-record pool, runs it, and asserts the run completes and the
picked record is **always** within the configured pool (verifying the
pool filter) across repeated runs.

Local verification (typecheck + lint for shared/server/front) is green;
running the integration suite and attaching editor screenshots in a
follow-up comment.

## Follow-ups

- PR 2: `ROUND_ROBIN` + persistent atomic cursor (Redis `incrBy` vs. a
Postgres counter table — tradeoff to be documented on that PR) +
strategy selector.
- PR 3: `LOAD_BALANCED`.
- Later (not in this stack): filter-based / variable-list pools, an
inline "Assign automatically" entry point on relation fields, OOO-skip /
weighting.

https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21899?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-06-21 21:51:16 +02:00
Charles Bochet 334e962ab5 fix: cannot create record from table view — empty morph to-many relation returns null (#21846)
## Problem

Creating a record from the table view (reproduced on **People**) crashes
the client even though the `createOne…` mutation succeeds server-side,
so the record never appears:

```
Cannot read properties of null (reading 'map')
  getRecordConnectionFromRecords → getRecordNodeFromRecord → optimistic cache effect → createOneRecord
```

## Root cause

An empty **morph** to-many relation comes back as `null`, while every
other to-many relation comes back as `{ edges: [] }`. The frontend then
runs `null.map` while building the optimistic cache node; the error
escapes the mutation `update`, the rollback evicts the record, and it
never lands in the table.

## Fix

**Server** — plain to-many relations are hydrated to `[]` and formatted
to `{ edges: [] }` by `ObjectRecordsToGraphqlConnectionHelper`; an empty
morph to-many was left undefined and the field was skipped (→ `null`).
Default an unset to-many value to `[]` so it goes through the **same
connection path as plain to-many relations**.

**Frontend** — defensive guard in `getRecordNodeFromRecord`: a to-many
relation whose value isn't an array is skipped instead of crashing,
mirroring the existing guard in `extractTargetRecordsFromRelation`.
Needed regardless, since cached data / SSE / older servers still send
`null`.

## Tests

- Unit: `getRecordNodeFromRecord` skips a null to-many (reproduces the
exact crash without the guard).
- Integration: an empty morph `ONE_TO_MANY` read returns `{ edges: []
}`, not null.
2026-06-21 18:05:04 +02:00
Félix Malfait a0689d1577 feat(workflow): condition filter on database-event triggers (#21868)
## Problem

Connecting a mailbox bulk-creates contacts via the email/calendar sync,
and each `person.upserted` fires the seeded **"Create company when
adding a new person"** workflow. The trigger enqueues one run per record
(no batching) and each run bills several `WORKFLOW_NODE_RUN` events — so
a single mailbox connect can rack up tens of thousands of runs and
exhaust credits on a brand-new workspace. The workflow is also redundant
on that path: the sync already creates the company from the email domain
and links the person to it.

## What this does

Adds an optional, user-defined **filter** to database-event (listener)
triggers, evaluated in the listener **before a run is enqueued**.
Non-matching events never create a run, so they consume zero execution
credits. This is the Filter node's capability, lifted to the trigger
level, and available for all event types (created / updated / upserted /
deleted).

The seeded "Create company when adding a new person" workflow now
carries a visible trigger filter — `Created by → Source is not Email`
**and** `is not Calendar` — so it no longer runs for sync-created
contacts, while still running for manually / API / CSV-added people.

## How (reuse)

- **Backend:** extracted `evaluateStepFilters()`, shared by the Filter
action and the trigger listener's new `eventMatchesRecordFilter` gate.
The record is exposed under the `trigger` key so filters reference it
exactly like steps do (`{{trigger.properties.after.…}}`).
- **Shared:** one optional `filter` added to the database-event trigger
zod schema; the front-end type derives from it (settings stay JSON — no
codegen).
- **Frontend:** extracted `WorkflowStepFilterBuilder` from the Filter
action's body; both the Filter action and the trigger editor render it.
The field picker needed no changes — at the trigger it already resolves
to the record's own fields via `TRIGGER_STEP_ID`.

## Scope / decisions

- **No migration for existing workspaces** (by request) — only newly
created workspaces get the filtered default; already-created workspaces
keep the always-on workflow.
- Deliberately did **not** add relation-enrichment to the upsert path
(it would add a DB lookup to the very bulk-sync path we're relieving).
Trigger filters work on the record's own scalar/composite fields (e.g.
`createdBy.source`); relation-based filters work on created/updated
where enrichment already runs.

## Verification

- Typecheck: `twenty-shared`, `twenty-server`, `twenty-front` all green.
- Lint (diff, autofix): 0 warnings / 0 errors across all three.
- Unit tests: a new `evaluate-step-filters` spec exercising the exact
`createdBy.source IS_NOT` seed mechanism, plus new listener specs
proving non-matching events are not enqueued. All backend
filter/listener suites pass.
- Not run here: integration tests (need a DB) and Storybook.

https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21868?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: Claude <noreply@anthropic.com>
2026-06-21 15:47:06 +00:00
Abdullah. e90fb4b55c fix(security): bump dompurify to 3.4.11 (config/hook pollution) (#21905)
## fix(security): bump dompurify to 3.4.11 (config/hook pollution)

Resolves [Dependabot Alert
#1520](https://github.com/twentyhq/twenty/security/dependabot/1520) and
[#1509](https://github.com/twentyhq/twenty/security/dependabot/1509).

### What

`dompurify` is affected by:
- **Permanent `ALLOWED_ATTR` pollution via `setConfig()`**
([#1520](https://github.com/twentyhq/twenty/security/dependabot/1520),
Moderate, `<= 3.4.10`)
- **Trusted Types policy survives `clearConfig()`**
([#1509](https://github.com/twentyhq/twenty/security/dependabot/1509),
Low, `< 3.4.9`)

Both patched in `3.4.11`. Bumps the direct `twenty-server` dep `^3.4.0
-> ^3.4.11`.

### Compatibility

Both advisories are about config/hook state pollution via
`setConfig`/`clearConfig`/hooks. All four of our call sites use plain
`DOMPurify(window).sanitize(...)` with **default config** — no
`setConfig`, `clearConfig`, `addHook`, `ALLOWED_ATTR`, or
`RETURN_TRUSTED_TYPE` — so we are not on the affected path, and the fix
does not change default-`sanitize` behavior.

Verification: `typecheck twenty-server` passes; the
`prepare-file-for-storage`, `create-html-to-text-converter`, and
`email-composer` suites pass (28 tests).

### Verification

- `dompurify` resolves to `3.4.11` (no `<= 3.4.10` remains).
- Lockfile + single package.json pin change; `yarn install --immutable`
passes.
2026-06-21 15:05:17 +02:00
Abdullah. d74b6aeadf fix(security): bump nodemailer to 9.0.1 (raw-option SSRF / file read) (#21903)
## fix(security): bump nodemailer to 9.0.1 (raw-option SSRF / file read)

Resolves [Dependabot Alert
#1518](https://github.com/twentyhq/twenty/security/dependabot/1518) and
[#1519](https://github.com/twentyhq/twenty/security/dependabot/1519).

### What

`nodemailer` `<= 9.0.0` lets the message-level `raw` option bypass
`disableFileAccess`/`disableUrlAccess`, enabling **arbitrary file read**
and **full-response SSRF** in the delivered message ([GHSA
advisory](https://github.com/twentyhq/twenty/security/dependabot/1518),
High). Patched in `9.0.1`.

### How — direct bump, no resolution

- **twenty-server:** `nodemailer ^8.0.5 -> ^9.0.1` (major bump).
- **seed-dependencies:** the application-package template `nodemailer
^8.0.5 -> ^9.0.1`; both `DEFAULT_PACKAGE_JSON_CHECKSUM` and
`DEFAULT_YARN_LOCK_CHECKSUM` regenerated to match the recomputed seed
files (the deps-layer cache key).

### Compatibility — verified nothing breaks

It is a major upgrade, so the 9.0 breaking change was checked against
the current tree. The only behavior change is **stricter TLS validation
when nodemailer fetches remote content** (attachment `href`/`path` URLs,
built-in OAuth2 token endpoints, HTTP/HTTPS proxy `CONNECT`). None of
those paths are reachable here:
- Attachments are passed as **content buffers**, never `path`/`href`.
- Gmail OAuth uses **googleapis**, not nodemailer's built-in OAuth2.
- No proxy on any transport.
- The SMTP socket TLS is governed separately (unchanged).

Verification: `typecheck twenty-server` passes (with `@types/nodemailer
^7.0.3`), and the `email-sender`, `gmail-message-outbound`, and
`imap-smtp-caldav-connection` suites pass (10 tests).

### Not covered (follow-up)

Root alert **#1521** will stay open: `imapflow@1.3.6` exact-pins
`nodemailer@8.0.10`. The clean fix is `imapflow 1.4.2` (which pins
nodemailer `9.0.1`), but it published 2026-06-19 and is **age-gated
until ~2026-06-22** — it will land then as a parent-bump (no
resolution).

### Verification

- `nodemailer` resolves to `9.0.1` for twenty-server; seed lockfile has
`9.0.1`; both seed checksums match the canonical recompute.
- `yarn install --immutable` passes.
2026-06-21 15:05:00 +02:00
Félix Malfait 7eafbd91c6 test(server): make timeline integration test self-seed its data (#21896)
## Problem

`timeline-from-object-record.integration-spec.ts` is flaky depending on
Jest shard composition. Its `beforeAll` scans the dev-seeded people for
one with message threads and one with calendar events, and throws when
none is found:

```
Expected the seeded workspace to contain a person with message threads and calendar events
```

This was observed as a deterministic failure of `server-integration-test
(2)` (failed on re-run too), while the other 15 shards were green.

## Root cause

The suite depends on **mutable shared fixture state** under two fragile
assumptions:

1. **That no sibling suite wiped the seeded people.**
`deleteAllRecords('person')` is a common pattern across the REST/GraphQL
suites — `rest-api-core-find-many`, `rest-api-core-find-one`,
`all-people-resolvers`, `search-resolver`, etc. — each hard-deletes
every person (`DELETE FROM "...".person`) and leaves only its own
handful behind, without restoring the seed. Within a shard, Jest runs
files serially (`maxWorkers: 1`) ordered by file size descending (no
timing cache in CI). `rest-api-core-find-many` (~16 KB, runs 2nd)
executes **before** `timeline-from-object-record` (~12 KB, runs 5th), so
by the time the timeline `beforeAll` runs, only 4 company-linked test
people remain — none with threads or events.
2. **That the seeder's `Math.random` participant assignment** happened
to land a thread and an event on a company-linked person within the
first 100 results — itself non-deterministic across DB resets.

It surfaced now because an unrelated PR added a new integration test
file, which changed the total file set and therefore Jest's shard
distribution, moving `timeline-from-object-record` and
`rest-api-core-find-many` into the **same shard** for the first time. It
is a latent test-isolation issue, not a product regression.

### Reproduced locally

Against a DB where `rest-api-core-find-many` had already run (person
count = 4), the timeline suite fails with the exact CI error; on a
freshly seeded DB it passes. So the failure is purely order/seed
dependent.

## Fix

Make the suite self-contained: in `beforeAll` it now provisions its own
graph via the GraphQL API and tears it down in `afterAll`:

```
company → person → messageThread → message → messageParticipant(personId)
                 ↘ calendarEvent → calendarEventParticipant(personId)
```

The timeline resolvers count threads via `messageThread → messages →
messageParticipants.personId` and events via `calendarEvent →
calendarEventParticipants.personId`, so this graph is sufficient and
minimal. The suite no longer reads any ambient seeded data, making it
independent of execution order and seeding randomness.

## Validation

- Self-seeding suite passes against the **polluted** DB (4 people, no
seeded threads/events) — the exact CI failure condition.
- Idempotent across repeated runs and leaves **no residue** (all
fixtures destroyed in `afterAll`).
- Full `--shard=2/16` run green except a pre-existing environmental
failure (`successful-save-imap-smtp-caldav-account`, fails locally with
no mail server, identical before/after this change).

https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21896?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-06-20 14:29:28 +02:00
martmull 6423c4cd3c Add recall io webhook endpoint (#21879)
## Context

Bot-recording integrations (e.g. the Recall.ai meeting bot) receive
webhooks from a third-party provider that delivers **every
tenant's events to a single URL**. Our existing `route-trigger` (`/s/…`)
resolves the workspace from the request host, which can't
work for one shared multi-tenant webhook URL. We need an instance-scoped
ingress that identifies the target workspace from the payload
  instead.

  ## Strategy

Add a new **`ingress-trigger`** logic-function trigger, mirroring
`route-trigger`:

  - A public endpoint keyed by the app's identifiers: `POST

/webhooks/ingress/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier`.
- The logic function declares an `ingressTriggerSettings` block in its
manifest describing how to find the workspace in the payload
  (`workspaceId: { source: 'body' | 'query' | 'header', path }`).
- Core only **resolves the workspace** (declarative, fail-closed,
prototype-safe path getter), verifies the app is installed in that
workspace, then runs the function **synchronously** so the provider sees
the response (status codes / retries).
- **Signature verification stays in the logic function** (it gets
`rawBody` + forwarded headers), keeping core provider-agnostic.
- Shared execution logic (`build event → execute → map response`)
extracted into `LogicFunctionTriggerService`, now reused by both
  `route-trigger` and `ingress-trigger`.

  ## Major changes

- **twenty-shared**: new `ingressTriggerSettings` on
`LogicFunctionManifest` (`IngressTriggerSettings` type).
- **twenty-server**: new `ingress-trigger` module (controller, service,
exception + filter, workspace-id resolver util).
- **twenty-server**: extracted `LogicFunctionTriggerService` +
`route-trigger-response.util` (response builder + sender); refactored
  `RouteTriggerService` and both controllers to reuse them.
- **twenty-docs**: documented the ingress trigger (endpoint, workspace
resolution, signature responsibility, provider HMAC examples).
  - Unit tests for the resolver and the ingress service.
2026-06-19 23:34:43 +02:00
Paul Rastoin ee5b3a65f4 Workspace migration post transaction commit side effect (#21845)
# Introduction
Avoid side effect in transaction to reduce lock duration
As discussed we're going to introduce in migration runner side effect
later through jobs and metadata boolean state tracker in db

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21845?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-06-19 15:50:13 +00:00
nitin 973b35989e Add standard record page layout for calendar events (#21857)
Moves calendar event details from the bespoke side-panel page to the
standard record page layout system.

- Adds standard calendar event record page metadata, fields view,
widgets, tests, snapshots, and upgrade command for existing workspaces.
- Opens calendar events through the generic ViewRecord side-panel path.
- Adds participants and call recordings as standard field widgets.
- Removes the old custom calendar event side-panel page and related
side-panel enum/config entry.

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




https://github.com/user-attachments/assets/c1f88cac-1615-478c-a3dd-87d0c61ab9a8

<img width="3024" height="1658" alt="CleanShot 2026-06-19 at 19 01
27@2x"
src="https://github.com/user-attachments/assets/c3df5705-ff08-446e-ac3c-6ccb11cf21ec"
/>

<img width="3024" height="1658" alt="CleanShot 2026-06-19 at 19 01
19@2x"
src="https://github.com/user-attachments/assets/633525db-310c-4462-8458-a72068cc1432"
/>
2026-06-19 20:06:33 +05:30
Félix Malfait 23f5ba9ebf feat: add resizable kanban column width (#21828)
## What & why

Lets users resize the columns of a Kanban (record board) view. Requested
by a user; the design avoids the "ragged board" problem by making the
width a **single shared value**.

## Behaviour

- A drag handle appears on the right edge of every column header.
- Because all columns read **one** width value, dragging any handle
resizes **every** column together — they can never end up mismatched.
- Width is clamped between **150px** and **400px** (default **200px**).
- The width is **persisted per view** and restored on reload.

## Approach

**Backend** — a new nullable `View.kanbanColumnWidth` field, threaded
through the existing view-level setting pattern (the same one
`kanbanAggregateOperation` / `shouldHideEmptyGroups` use), so it gets
create/update/manifest/override support for free:
- entity column + `ViewOverrides` + `@WasIntroducedInUpgrade`
- `CreateViewInput` / `UpdateViewInput` (`Int`, `@Min(150)`/`@Max(400)`)
+ `ViewDTO`
- flat-view editable properties, entity-properties config, compare-type,
standard-view + manifest converters
- a fast instance command adding the `core.view` column

**Frontend** — the value hydrates into a view-scoped atom and drives a
single CSS variable set on the board container, which both column
headers and bodies read. Live dragging only writes that CSS variable (no
per-move React re-render); the final width is committed to the atom and
persisted via `updateView` on pointer-up.

## Nullability / defaults

`kanbanColumnWidth` is nullable — `null` means "never resized" and the
UI falls back to the 200px default, so existing rows need no backfill.

## Validation

- `nx typecheck twenty-server`  and `nx typecheck twenty-front` 
- `nx lint:diff-with-main twenty-server` ; frontend lint fixes applied
(split constants to one-per-file, removed `useRef`-for-state in favour
of `useState`).
- Draft pending a final green CI run (the dev container reclaimed
`node_modules` mid-session; re-running locally).

## Test plan

- [ ] Drag a kanban column edge → all columns resize together, clamped
150–400px
- [ ] Reload → width persists for that view; other views unaffected
- [ ] A view that was never resized still renders at 200px

https://claude.ai/code/session_016Qe6oDBkhVbrq2QkXBJ5nE

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21828?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: Claude <noreply@anthropic.com>
2026-06-19 15:58:13 +02:00
Paul Rastoin 98c35ea804 App uninstall lambda, layers cleanup (#21749)
# Introduction
On a logic function deletion also remove the driver entry
On a app uninstall also remove the sdk layer ( keeps the dep one as it
can be shared across several lambda )

| Resource | Scope | Before this PR | After |
|---|---|---|---|
| DB metadata (functions, objects, fields…) | per-app | deleted |
deleted |
| Source folder (`FileFolder.Source`) | per-function | deleted | deleted
|
| Built handler file (`FileFolder.BuiltLogicFunction`) | per-function |
deleted | deleted |
| **Lambda function** | per-function | **leaked** | **deleted** (driver
`delete`) |
| **SDK layer** `sdk-<wsId>-<appId>` (all versions) | per-app |
**leaked** | **deleted** (driver `deleteApplicationResources` →
`deleteSdkLayer`) |
| Deps layer `deps-<checksum>` | shared across apps/workspaces | not
deleted | **intentionally not deleted** (content-addressed, GC'd) |

## What I don't like about all that
Right now there's some non reversible side effect inside the workspace
migration transaction
- If the transaction fails we're facing data loss
- It also slows down everything

I'm about to create a new PR that allow population post transaction
commit side effect / cleanup to be run later

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21749?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-06-19 13:10:39 +00:00
Félix Malfait 705caab2b0 fix(onboarding): refresh stale workspace in currentWorkspace field resolver (#21839)
## What & why

After sign-up, users are redirected to `/sync/emails` and the page loads
forever; a full refresh fixes it. This blocks production deploy.

**Root cause** — a GraphQL field resolver returns an unrefreshed (stale)
workspace:

- `@AuthWorkspace()` (`request.workspace`) is read from the per-instance
core entity cache and can still be `PENDING_CREATION` /
`ONGOING_CREATION` right after `activateWorkspace`.
- The `currentUser` query resolver and the `onboardingStatus` field
already guard against this by calling
`refreshWorkspaceIfPendingOrOngoingCreation(...)`.
- But the `currentWorkspace` `@ResolveField` returned the raw
`@AuthWorkspace()` workspace. Because a field resolver takes precedence
over any value the query resolver attaches to its returned object, the
client receives that stale workspace.

So right after activation the client got an inconsistent payload:
- `onboardingStatus: SYNC_EMAIL` (fresh — computed from a direct DB
read)
- `currentWorkspace.activationStatus: ONGOING/PENDING_CREATION` (stale)

On the frontend, metadata loading is gated on
`isWorkspaceActiveOrSuspended(currentWorkspace)`, and
`MinimalMetadataGater` does **not** exclude `/sync/emails`. So the
workspace looked inactive → metadata never loaded (no metadata GraphQL
request was even issued) → the gater's loader showed indefinitely. A
full refresh worked because the cache had since refreshed to `ACTIVE`.

## Why it surfaces on staging but isn't caught by tests

The stale window only opens on a real fresh sign-up followed by
immediate activation, against a workspace cache that hasn't refreshed
yet (multi-instance / cache TTL). Single-instance local dev and the
existing `successful-user-and-workspace-creation` integration test
exercise `activateWorkspace` + `getCurrentUser` against one consistent
cache, so `currentWorkspace` already looks `ACTIVE` and they pass —
which is why this reproduces on staging/production but not locally, and
why a manual refresh recovers.

## How

Refresh the workspace in the `currentWorkspace` field resolver too, so
it is consistent with `onboardingStatus`. For active workspaces this is
a no-op (no extra DB read).

```ts
async currentWorkspace(@AuthWorkspace({ allowUndefined: true }) workspace) {
  if (!isDefined(workspace)) return workspace;
  return this.userService.refreshWorkspaceIfPendingOrOngoingCreation(workspace);
}
```

This is preferred over the frontend alternative (excluding
`/sync/emails` from `MinimalMetadataGater`), which would only hide the
symptom while every other consumer still received a wrong
`activationStatus`.

## Verification
- `nx typecheck twenty-server` and `nx lint:diff-with-main
twenty-server` (oxlint + oxfmt) are green.

https://claude.ai/code/session_018c1X6CwDgttMXA5tB797yS

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 14:45:12 +02:00
Charles Bochet 0064ff6741 fix(ai): validate AI agent output field names against schema-key constraint (#21834)
## Problem

On a self-hosted instance, an AI Agent workflow action fails at run time
with an opaque model error:

```
The model returned the following errors: tools.0.custom.input_schema.properties:
Property keys should match pattern '^[a-zA-Z0-9_.-]{1,64}$'
```

This is Anthropic's validation on tool `input_schema` **property keys**.
An AI Agent's structured **Output** fields are turned into a JSON schema
and passed to the model as a tool; each output **variable name** becomes
a property key. Anthropic rejects any key that does not match
`^[a-zA-Z0-9_.-]{1,64}$` — most commonly a name containing a **space**
(e.g. `meetings brief`), but also names over 64 characters or with other
symbols.

Until now nothing validated this: `fieldsToSchema` writes
`properties[field.name]` verbatim, so a bad name only failed once the
workflow executed, with an error that gives the user no idea what to
fix. It doesn't reproduce on every instance — it depends purely on how
the workflow's output variables happen to be named.

## Fix

Introduce a single shared check,
`isValidAgentResponseSchemaPropertyKey`, and enforce it in two places:

- **Backend** — `validateAgentResponseFormat` now rejects invalid output
field names at agent **save time** with a clear `userFriendlyMessage`,
instead of letting the broken schema reach the model. This also gates
agents created via the API and re-saves of existing bad data.
- **Frontend** — the output schema builder shows an inline error on the
Variable Name field as soon as an invalid name is entered.

## Tests

- Unit test for the shared validity check (valid + invalid cases:
spaces, leading space, empty, > 64 chars, symbols, unicode).
- Unit test for `validateAgentResponseFormat` covering text/json
formats, valid names, a space in a name, an over-length name, and
reporting multiple invalid names at once.

## Notes for the reporter

The immediate unblock for an affected workflow is to rename the output
variable to remove the space (e.g. `meetings brief` → `meetings_brief`)
and retry the run. With this change the bad name is caught up front with
an explanation rather than failing mid-run.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21834?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-06-19 13:50:44 +02:00
Paul Rastoin 26db3f5735 Deprecate legacy encryption (#21831)
# Introduction
Still preserving the cross-upgrade flow

close https://github.com/twentyhq/core-team-issues/issues/2465


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21831?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-06-19 11:32:39 +00:00
Thomas Trompette 4075018834 fix(workflow): label manual trigger record output as Record/Records (#21832)
## What

The manual trigger output schema exposed the triggering record(s) under
a node labeled **Payload**. Relabels it to match what the node actually
contains:

- **Single-record** availability → **Record**
- **Bulk-records** availability → **Records**

## Why

"Payload" was a misnomer — the node holds the record(s) that triggered
the workflow. This is a display-label-only change.

## Notes for reviewers

- **No migration.** The persisted output schema key stays `payload`, so
existing variable references (`{{trigger.payload.x}}`) are unaffected.
- The front recomputes the output schema on the fly
(`computeStepOutputSchema`), so the variable picker shows the new labels
immediately, including for existing triggers.
- The backend (`workflow-schema.workspace-service`) is updated to match
for newly persisted/re-saved schemas. Previously persisted schemas keep
"Payload" until re-saved.
- Added `WORKFLOW_TRIGGER_RECORD_LABEL` /
`WORKFLOW_TRIGGER_RECORDS_LABEL` and removed the now-unused
`WORKFLOW_TRIGGER_PAYLOAD_LABEL`.
- Unit tests updated for both single and bulk cases (55/55 passing).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21832?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-06-19 12:36:14 +02:00
Abdullah. 8ff494e5e8 fix(security): bump tar to 7.5.16 in seed-dependencies (PAX file smuggling) (#21829)
## fix(security): bump tar to 7.5.16 in seed-dependencies (PAX file
smuggling)

Resolves [Dependabot Alert
#1500](https://github.com/twentyhq/twenty/security/dependabot/1500).

### What

`tar` (`node-tar`) `<= 7.5.15` applies a PAX size override to
intermediary GNU long-name/long-link headers, causing a tar-parser
interpretation differential (file smuggling) —
[GHSA-vmf3-w455-68vh](https://github.com/advisories/GHSA-vmf3-w455-68vh).
Patched in `7.5.16`.

This is the **seed-dependencies holdout** deferred from the main tar PR
(#21813): that lockfile + its checksum constants were also touched by
the form-data PR, so it was carved out to avoid a conflict. The
form-data PR has since merged, unblocking it.

### How

- Refreshed `tar` `7.5.13 -> 7.5.16` in `seed-dependencies/yarn.lock`
(transitive via `^7.5.4`, which already permits it) — an in-range
refresh, no override.
- Regenerated `DEFAULT_YARN_LOCK_CHECKSUM` in
`get-default-application-package-fields.util.ts` so the row-stored
checksum matches the value recomputed from file content in
`application.service.ts` (the deps-layer cache key;
`logicFunctionCreateHash` = SHA-512, first 32 hex). `package.json` is
unchanged, so `DEFAULT_PACKAGE_JSON_CHECKSUM` is unaffected.

### Verification

- No `tar <= 7.5.15` remains in the seed lockfile.
- Both checksum constants verified to match the canonical recompute of
the current seed files.
- Lint + format pass on the changed `.ts` file.
2026-06-19 11:46:28 +02:00
Félix Malfait adf6eb572b feat(billing): embed Stripe Payment Element in onboarding (#21759)
## What & why

Replaces the hosted Stripe Checkout redirect on the onboarding "Choose
your plan" step (credit-card trial) with an inline Stripe **Payment
Element**, so users never leave the app to enter card details.

## How it works

- **Frontend:** a deferred `<Elements mode="setup">` renders the Payment
Element, themed via the Appearance API. On Continue: `elements.submit()`
→ `checkoutSession` mutation creates the trialing subscription
server-side and returns its pending SetupIntent `clientSecret` →
`stripe.confirmSetup()` confirms the card (handling 3DS) → redirect to
the existing `/plan-required/payment-success`.
- **Backend:** new `BILLING_STRIPE_PUBLISHABLE_KEY` config var exposed
via `/client-config`; the card path creates the subscription with
`payment_behavior: default_incomplete` + a free trial (so Stripe
attaches a `pending_setup_intent`) and returns its client secret. The
hosted-Checkout code path is removed.
- The **no-credit-card** trial path is unchanged.
- Billing address collection is **disabled** in the Payment Element to
reduce friction; `automatic_tax` is correspondingly disabled (tax needs
an address — collect it later, e.g. at conversion / via the billing
portal).

## Required before this works
1. Set `BILLING_STRIPE_PUBLISHABLE_KEY` (`pk_…`) on the server (infra
change pending).
2. Run `nx run twenty-front:graphql:generate --configuration=metadata`
against a server exposing the updated schema (see inline note on the
hand-authored document).
3. Verify in Stripe test mode: happy path, 3DS (`4000 0025 0000 3155`),
a decline.

## Verified
typecheck (front + server), oxlint + oxfmt clean,
`client-config.service.spec` passing. Not run here: the app end-to-end /
Stripe test mode and `graphql:generate` (no server/DB in the dev
container).

I've left self-review comments inline flagging cleanup opportunities
plus a couple of architectural/tech-debt items.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21759?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: Claude <noreply@anthropic.com>
2026-06-19 11:40:55 +02:00
Félix Malfait 40c9a11f43 feat(onboarding): always show the Create Profile step (#21823)
The Create Profile step was skipped whenever the user already had a
first or last name (e.g. provided during sign-up or via SSO), because
the create-profile-pending flag was only set when both were empty.
Always set it so the step is presented during onboarding and the user
can review/confirm their profile; submitting it still clears the flag
and advances.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21823?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-06-19 09:50:29 +02:00
Raphaël Bosi 3675f264f1 Infer record pickers for record-typed logic function workflow inputs (#21494)
## Context

Logic functions can declare workflow inputs typed as records or arrays
of records (e.g. the People Data Labs enrichment functions), but the
workflow builder rendered those as a plain text input with a variable
picker, which is not usable.

## What this does

- Adds an `objectUniversalIdentifier` link on input schema properties,
so a record-typed input is tied to a workspace object.
- The SDK build infers it from a
`TwentyRecord<'objectUniversalIdentifier'>` marker type in the handler
signature, reading the object's universal identifier straight from the
source; explicit input schemas can still set the field directly.
- The workflow builder renders these inputs as a single record picker or
a record multi-select with the variable picker on the right. Selected
records are stored as record ids; `TwentyRecord<UID>` is a branded
`string`, so the handler signature reflects that it receives ids (a
bound variable resolves to whatever the referenced step produced).
- The multi-select collapses overflowing chips into a `+N` badge
(reusing `ExpandableList`) and its variable picker offers both record
objects and fields.
- Updates the People Data Labs enrichment inputs as the reference
implementation.

<img width="802" height="824" alt="CleanShot 2026-06-12 at 16 54 10@2x"
src="https://github.com/user-attachments/assets/a0896d74-0aab-49bd-a173-14c578a2e533"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21494?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-06-19 09:10:01 +02:00
Abdullah. 26b4d6caed fix(security): bump form-data to 4.0.6 (CRLF injection) (#21808)
Resolves [Dependabot Alert
#1473](https://github.com/twentyhq/twenty/security/dependabot/1473),
[#1475](https://github.com/twentyhq/twenty/security/dependabot/1475),
[#1477](https://github.com/twentyhq/twenty/security/dependabot/1477),
[#1478](https://github.com/twentyhq/twenty/security/dependabot/1478),
[#1480](https://github.com/twentyhq/twenty/security/dependabot/1480),
[#1482](https://github.com/twentyhq/twenty/security/dependabot/1482),
[#1484](https://github.com/twentyhq/twenty/security/dependabot/1484),
[#1486](https://github.com/twentyhq/twenty/security/dependabot/1486),
[#1488](https://github.com/twentyhq/twenty/security/dependabot/1488),
[#1490](https://github.com/twentyhq/twenty/security/dependabot/1490),
[#1492](https://github.com/twentyhq/twenty/security/dependabot/1492),
[#1494](https://github.com/twentyhq/twenty/security/dependabot/1494),
[#1495](https://github.com/twentyhq/twenty/security/dependabot/1495),
[#1497](https://github.com/twentyhq/twenty/security/dependabot/1497),
[#1499](https://github.com/twentyhq/twenty/security/dependabot/1499),
[#1501](https://github.com/twentyhq/twenty/security/dependabot/1501) and
[#1506](https://github.com/twentyhq/twenty/security/dependabot/1506).
2026-06-19 08:46:32 +02:00
neo773 814b43ca41 feat(server): derive email/calendar timelines from object relations (#21684)
Simplifies our existing implementation that uses three different GraphQL
endpoints to just one `getTimelineEventsFrom{Person, Company,
Opportunity}Id` to `getTimelineCalendarEventsFromObjectRecord`


/closes https://github.com/twentyhq/twenty/issues/19676

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21684?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: Charles Bochet <charles@twenty.com>
2026-06-19 02:22:27 +02:00
neo773 616d58bc7e messaging: gmail folder backfill (#21753)
demo


https://github.com/user-attachments/assets/a157cee1-a8fa-4050-af1b-c31a83fb75da

/closes #17095


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21753?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-06-19 01:59:35 +02:00
neo773 c07dd53a48 Scope empty fixture workspaces to upgrade integration tests (#21778)
The dev seeder activated Empty3/Empty4 workspaces without creating their
DB schema, so every workspace-iterating job (e.g. the workflow cron
trigger) logged 'relation does not exist' for those schemas on each run.


```
[1] query failed: SELECT * FROM workspace_4rdlooovb6mo66rdmgupv06zi."workflowAutomatedTrigger" WHERE type = 'CRON'
[1] error: error: relation "workspace_4rdlooovb6mo66rdmgupv06zi.workflowAutomatedTrigger" does not exist
[1] [Nest] 51868  - 18/06/2026, 5:07:04 pm   ERROR [WorkflowCronTriggerCronJob] Error processing workspace 506915ec-21ca-431b-a04a-257eb216865e: QueryFailedError: relation "workspace_4rdlooovb6mo66rdmgupv06zi.workflowAutomatedTrigger" does not exist
[1] Exception Captured
```

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21778?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-06-19 01:21:50 +02:00
Weiko 9f30915f6f fix(metadata): remove deprecated isCustom from Objects and Fields (#21799)
## Context

Follow-up to #21228, which deprecated `isCustom` on object/field
metadata but kept it exposed because the frontend still relied on it.
This removes it from the GraphQL API and the frontend entirely.

## Implementation

### Server
- Remove `isCustom` `@Field` from the `Object`, `Field`, and
`MinimalObjectMetadata` GraphQL types
- Remove the `isCustom` `@ResolveField` resolvers and the
`isCustomLoader` dataloader (+ payload/interface)
- Remove `isCustom` as an internal `@HideField()` on the Object/Field
DTOs used by the i18n standard-override gate > Use an explicit
isStandard instead (which is the correct gating)

### Frontend
- Add `getIsMetadataItemCustom` helper + `useGetIsMetadataItemCustom`
hook: an item is custom when `applicationId ===
currentWorkspace.workspaceCustomApplication.id`
- Migrate all consumers off `objectMetadataItem.isCustom` /
`fieldMetadataItem.isCustom`; `isRecordFieldReadOnly` now takes a
precomputed `isFieldCustom`
- Drop `isCustom` from the metadata fragment/mutations/minimal query, FE
types, zod schemas, and mock generators; regenerate GraphQL types

## Notes
- Breaking change on the (already-deprecated) `Object.isCustom` /
`Field.isCustom` GraphQL fields and the `isCustom` filter
- FE semantic is "belongs to the workspace custom app" (third-party-app
objects/fields are treated as non-custom)
- `isCustom` on IndexMetadata / View / Skill / Agent is a separate
column and is untouched
- Breaking changes on REST metadata API
2026-06-19 01:19:49 +02:00
Félix Malfait 7b5ee8a7bc feat(server): report enterprise instance metadata on license validation (#21793)
## What

Enriches the **enterprise-only** license-validation channel
(`/validate`, `/seats`) with best-effort instance metadata so the
licensing backend can later reconcile seats and surface signs of license
abuse (e.g. one subscription on many `serverId`s, a `serverId` on many
URLs, dev-mode-in-prod).

Reported alongside the existing `enterpriseKey` (and `seatCount` on
`/seats`), under a new `instanceMetadata` object:

| Field | Purpose |
|---|---|
| `serverId`, `serverUrl` | instance identity — sharing / clone signals
|
| `workspaceCount`, `activeUserWorkspaceCount`, `distinctUserCount` |
seat reconciliation / overage |
| `appVersion`, `nodeEnv`, `telemetryEnabled` | fleet/support;
dev-mode-in-prod signal |
| `adminContactEmail` | **single** administrative contact (oldest active
user) for license administration — explicitly *not* an abuse signal |
| `sentAt` | timestamp |

No CRM data, record contents, or member PII beyond the one admin contact
are sent.

## Why it's safe for existing instances

- **Enterprise-only.** Gathering runs only after the `ENTERPRISE_KEY`
checks, so free/community instances make no extra queries and send
nothing — unchanged behavior.
- **Never blocks a refresh.** Each lookup is isolated (`safeCount` /
try-catch); any failure degrades to `null` and the license refresh /
seat report proceeds.
- **Purely additive.** `enterpriseKey` and `seatCount` are preserved;
the `/validate` and `/seats` handlers ignore unknown fields, so this can
ship ahead of any backend consumer.
- **No schema or token-verification changes** → no migration, existing
validity tokens keep validating.

## Verification

- `nx test twenty-server` — full unit suite green (5829 passed),
including the updated `enterprise-plan.service.spec` with a new
metadata-payload test
- `nx typecheck twenty-server` — pass
- `oxlint` + `oxfmt --check` on changed files — clean

## Deliberately out of scope (follow-ups)

- **Server-side correlation/detection** and **short-TTL + instance-bound
validity tokens** live on the signing/billing side (`twenty-website`)
and need a coordinated rollout (enforcing token binding now would break
already-issued tokens).
- **`adminContactEmail`** is PII on a contractual enterprise channel —
the enterprise terms should disclose it before rollout.
- The dev-key / build-provenance hardening discussed separately is
**not** part of this PR.

Opening as **draft** for review of the field set and the cross-repo
rollout plan before wiring a consumer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_0114DV9tctTjVo8eggBGgtKc

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21793?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: Charles Bochet <charles@twenty.com>
2026-06-18 19:16:57 +00:00
Félix Malfait 6a1b28bc12 feat(auth): collect the workspace logo on the sign-up creation step (#21723)
## What & why

A single, consistent **workspace-creation step** for both
multi-workspace and single-workspace self-host — collecting **name +
logo** (and the **subdomain** in multi-workspace) — which **removes the
duplicate name/logo prompt** that previously reappeared on the workspace
subdomain (reported after #21641).

## Changes

**One creation form for both modes**
- With 0 workspaces, both multi-workspace and single-workspace route to
the shared `SignInUpWorkspaceCreationForm`; `SignInUp` renders it for
the `WorkspaceCreation` step regardless of domain/scope.
- The subdomain field shows only in multi-workspace; single-workspace
keeps its fixed address.

**Logo on the creation step**
- New scoped `uploadNewWorkspaceLogo(workspaceId, file)` mutation: the
creator sets a logo on their just-created `PENDING_CREATION` workspace
via the workspace-agnostic token (membership enforced — only the creator
is a member at that point), reusing `uploadWorkspacePicture`. Upload
size is capped via `settings.storage.maxFileSize` (also applied to the
existing logo / profile-picture uploads).
- The picked file is held locally (object-URL preview, revoked on
unmount) and uploaded right after creation (non-fatal on failure).

**Onboarding step → pure activation loader**
- The old "Create your workspace" form (name + logo) is removed. The
onboarding step now activates the pending workspace on mount and shows
the loader, with a **Retry** action on failure.

## Testing
- typecheck (front + server) ; oxlint + oxfmt clean on changed files 
- Unit tests: `auth.resolver.spec`, `useWorkspaceSubdomainField`,
`SignInUpWorkspaceCreationForm` (multi + single-workspace), `useAuth` 
- Metadata GraphQL + `twenty-client-sdk` schema regenerated.

Follow-up to #21641.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Xw37hR5seiCyWnppG9z4op

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:56:14 +02:00
Thomas Trompette 7a1cfc17cc fix(logic-function): treat invoke timeout as a user-level error, not a platform error (#21779)
Fixes
https://twenty-v7.sentry.io/issues/7527156270?project=4507072499810304

## Problem

Sentry was flooded with high-severity alerts for logic functions that
simply ran too long:

> Lambda timed out for function '…' during invoke (functionState=Active,
phase=invoke …)

A function exceeding its configured `timeoutSeconds` is a **user-level
outcome** (their code is too slow), not a platform failure — but it was
being reported as one.

## Root cause

The two timeout mechanisms were classified inconsistently:

- **Lambda's own timeout** → returns `{ status: ERROR, … }` → handled as
a user error (route returns 500 with `shouldBeCapturedBySentry: false`,
queue job records it without failing). Not in Sentry. ✓
- **Client-side `AbortSignal` timeout** → **threw**
`LOGIC_FUNCTION_EXECUTION_TIMEOUT`, which isn't mapped in
`mapErrorToRouteTriggerCode`, so it fell through to
`ROUTE_TRIGGER_PLATFORM_ERROR` (Sentry) and failed the BullMQ job
(Sentry). ✗

Since the executor Lambda is fixed at 900s, the client abort is the
*sole* timeout enforcement for every function with `timeoutSeconds <
900` — so essentially every slow function paged the team. The `local`
driver already returns an ERROR result here; only the Lambda driver
threw.

## Fix

On an **invoke-phase** `TimeoutError`, return a structured ERROR result
instead of throwing — mirroring the Lambda's own timeout and the local
driver. The timeout now flows through the normal result path: surfaced
to the caller as `status: ERROR`, recorded via `handleExecutionResult`
(which the throw path skipped), and kept out of Sentry.

**Build- and fetch-phase timeouts still throw** and stay in Sentry —
those are platform-side (executor build / code fetch too slow, even for
short user code), which is exactly what the phase instrumentation exists
to catch.

## Tests

- Unit test on the new `buildLogicFunctionTimeoutResult` util
- `npx jest logic-function-drivers/drivers/lambda` green
2026-06-18 16:43:40 +02:00
Etienne c6309fd92b feat(workflow): auto-layout steps on AI workflow creation via shared tidy-up (#21756)
## Context

The workflow builder has a "Tidy up" action that auto-positions steps
using a
Dagre layout. However, this lived entirely in the frontend and depended
on node
dimensions measured by React Flow after rendering in the browser.

As a result, workflows (and steps) created through AI Chat / MCP tools
were never
laid out: `create_complete_workflow` accepted optional `stepPositions`
that the
LLM had to invent, and `create_workflow_version_step` stored an optional
position
verbatim. In practice this produced overlapping / poorly positioned
steps.

## What this does

Extracts the tidy-up layout into a pure, frontend-free util in
`twenty-shared` and
reuses it from both the frontend tidy-up and the server, so
AI/MCP-created
workflows are auto-laid out at creation time.

### twenty-shared
- New `computeWorkflowLayout({ nodes, edges, options? })` — a pure Dagre
layout over
a minimal `{ id, width, height }` / `{ source, target }` graph,
returning
top-left-anchored positions (matching React Flow). Ignores edges
pointing to
  unknown nodes.
- New constants: `WORKFLOW_LAYOUT_DEFAULT_OPTIONS`
(ranksep/nodesep/rankdir) and
`WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS` (estimated node size for
server-side
  layout, where measured sizes are unavailable).
- Added `@dagrejs/dagre` dependency.

### twenty-front
- `getOrganizedDiagram` now delegates to `computeWorkflowLayout`,
passing real
  measured node sizes. No behavior change for users.

### twenty-server
- New `WorkflowVersionWorkspaceService.autoLayoutWorkflowVersion(...)`
builds the
graph topology via the existing `buildWorkflowGraph` (covers if-else
branches and
iterator loops), feeds estimated node sizes into
`computeWorkflowLayout`, and
  persists through the existing `updateWorkflowVersionPositions`.
- `create_complete_workflow`: removed `stepPositions` from the tool
schema; the
  server always auto-lays out after creation/edges.
- `create_workflow_version_step`: re-tidies the whole version after each
added step
(wired at the tool level so the builder UI is unaffected) and dropped
the now
  redundant `position` field.

## Notes
- Server-side layout uses estimated node sizes, so it is "good enough";
opening the
workflow and running the existing FE tidy-up refines it with real
measured sizes.
- Auto-layout is wired in the MCP tools, not in the shared creation
service, so
  manual step creation in the builder UI is unchanged.

## Test plan
- [x] `twenty-shared` unit tests for `computeWorkflowLayout` (linear
chain, if-else
  spread, dangling-edge safety)
- [x] `twenty-shared` builds; `twenty-server` and `twenty-front`
typecheck
- [x] Lint/format clean on changed files
- [ ] Create a workflow via AI Chat / MCP and confirm steps are laid out
without
  overlap
- [x] Add a step via MCP and confirm the version is re-tidied
- [ ] Frontend "Tidy up" still behaves as before

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21756?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-06-18 14:10:04 +00:00
Weiko cc4659ce11 fix(server): type WasRemovedInUpgrade columns with WasRemovedInUpgrade<> (#21785)
## Context

WasRemovedInUpgrade type brand was introduced in
https://github.com/twentyhq/twenty/pull/21228/changes#diff-1b6d688610669a46b3ee8e3a41b1c7eb0ee03e19146d0d249f95df6e56164a92R15
for the `isCustom` property deprecation.
The @WasRemovedInUpgrade decorator and the WasRemovedInUpgrade<T> type
are meant to go together: the type brand makes the property optional in
every derived flat-entity type, so the column only needs to be declared
on the entity itself. RolePermissionFlagEntity.flag had the decorator
but was typed as a plain PermissionFlagType, forcing the property to be
supplied everywhere.

This PR:
- Types flag as WasRemovedInUpgrade<PermissionFlagType> (matching the
isCustom reference impl on object/field metadata).
- Removes the now-redundant flag from the flat-entity construction
sites, the create input, and the service call site — leaving it only on
the entity. The GraphQL RolePermissionFlagDTO.flag is kept (it's an API
field derived from permissionFlag.key, not the removed column).
- Fixes a latent brand-leak in the flat-entity config type: toStringify
is computed via object-detection, and a branded type reads as an object.
This was harmless for boolean but wrongly forced toStringify: true for
enum/string columns. Added UnwrapWasRemovedInUpgrade<T> and applied it
so the brand is transparent making the pattern work for any type, not
just
booleans.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21785?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-06-18 15:23:24 +02:00
Thomas Trompette 22baf2c6c5 fix(server): prevent enum migration failure for long identifier names (#21748)
Fixes https://github.com/twentyhq/twenty/issues/20524

## Problem

Adding (or renaming/removing) an option on a `SELECT` / `MULTI_SELECT`
field failed for fields whose object + field name combination is long,
surfacing to the user only as:

> Migration action 'update' for 'fieldMetadata' failed — Migration
execution failed.

The real underlying Postgres error was:

```
type "_personalInsurancePolicyOrQuote_insuranceCoverageClassification" already exists
ALTER TYPE "..."."_personalInsurancePolicyOrQuote_insuranceCoverageClassifications_enum"
  RENAME TO "..._insuranceCoverageClassifications_enum_old"
```

## Root cause

PostgreSQL truncates identifiers to **63 bytes** (`NAMEDATALEN - 1`).

The enum type name
`_personalInsurancePolicyOrQuote_insuranceCoverageClassifications_enum`
is **69 chars**, so it was already stored truncated to 63
(`..._insuranceCoverageClassification` — the `_enum` suffix chopped
off).

Multi-select / select option changes go through the rename-and-recreate
path in `alterEnumValues`, which renames the enum to `<name>_old`. That
candidate is 73 chars → Postgres truncates it back to the **same 63-byte
string** as the source → `type "..." already exists`. The failure is
deterministic, so every retry on that field failed. The transaction
rolls back cleanly, leaving no `_old` artifacts behind.

The temporary column name (`<column>_old`) had the same latent bug for
very long field names.

## Fix

Add `buildTemporaryIdentifier(base, suffix)` to
`WorkspaceSchemaEnumManagerService`, which trims the base name so the
`_old` suffix survives within 63 bytes and stays distinct from the
original. Applied to both the temporary enum name and the temporary
column name.

The `_old` type/column are transient (dropped within the same
transaction), so the trimmed name only needs to fit and not collide —
which it now does.

## Test

Added `workspace-schema-enum-manager.service.spec.ts` reproducing the
exact failing object/field names. Both assertions (target identifier ≤
63 bytes; truncated source ≠ truncated target) fail on `main` and pass
with the fix.

## Recovery

No manual cleanup needed for affected workspaces — failed migrations
rolled back cleanly. Once deployed, option edits on long-named fields
work; the field remained fully usable in the meantime (only option
changes were blocked).
2026-06-18 15:09:12 +02:00
Etienne d67aa2889b feat(workflow): add update_agent tool and responseFormat-aware AI Agent step schema (#21755)
## Summary

Brings the workflow AI/MCP tooling for **AI Agent steps** to parity with
the existing **CODE / logic-function** flow, and makes AI Agent output
references reliable in validation and the variable picker.

Just like a CODE step needs a logic function, an `AI_AGENT` step needs
an agent. The agent is already created as a side effect of step
creation; this PR adds the missing "configure it" tooling and fixes the
output schema so it reflects the agent's actual response format.

## Changes

### New `update_agent` MCP tool
- `update-agent.tool.ts`: lets the assistant configure the agent backing
an `AI_AGENT` step — `prompt` (system prompt), optional `modelId`,
optional `responseFormat` (text or structured json) — via
`AgentService.updateOneAgent`. Direct analog of
`update_logic_function_source`.
- Wired through: added `agentService` to `WorkflowToolDependencies`,
injected `AgentService` and registered the tool in
`workflow-tool.workspace-service.ts`, imported `AiAgentModule` in
`workflow-tools.module.ts`.

### Guided creation flow (mirrors CODE)
- `create-workflow-version-step.tool.ts`: `enrichResultWithNextStep` now
returns an `AI_AGENT` hint instructing the assistant to call
`update_agent` with the step's `settings.input.agentId` (and to set the
task prompt via `update_workflow_version_step` if needed).
- `create-complete-workflow.tool.ts`: rejects `AI_AGENT` steps (it
inserts steps directly and never runs the side effect that creates the
agent), with a description note pointing to
`create_workflow_version_step` + `update_agent`. Same treatment CODE
already gets.

### Correct output schema for AI Agent steps
- Backend `computeStepOutputSchema`
(`workflow-schema.workspace-service.ts`): the `AI_AGENT` case now
derives the output schema from the agent's `responseFormat` instead of a
hardcoded `{ response }`:
  - text → `{ response: string }`
  - json → one leaf per `responseFormat.schema.properties` field
This makes workflow validation resolve `{{stepId.fieldName}}` references
against the agent's real output (previously json agents validated wrong:
real fields rejected, `{{stepId.response}}` accepted but undefined at
runtime).
- Frontend `useStepsOutputSchema.ts`: when an `AI_AGENT` step has no
persisted `outputSchema`, fall back to generating it from the agent's
`responseFormat` (via `FindManyAgents` + the existing
`agentResponseSchemaToOutputSchema`) instead of the hardcoded `{
response }`. Keeps the variable picker correct for structured agents.

## Why output schema matters

Validation resolves every `{{stepId.path}}` against the referenced
step's `settings.outputSchema` (`validateWorkflowVariableReferences`).
The agent step's output schema is therefore the single source of truth
for "is the right output referenced." Because the runtime output depends
on `responseFormat` (text → `{ response }`, json → schema fields
directly), the schema must be derived from `responseFormat` to be
accurate.

## Not solved issue
We want each AI_AGENT step's settings.outputSchema to always match the
backing agent's responseFormat:
- text → { response }
- json → one field per responseFormat.schema.properties
That output schema is what everything downstream relies on: validation
(validateWorkflowVariableReferences resolves {{stepId.field}} against
it), the frontend variable picker (useStepsOutputSchema), and it's also
persisted inside workflowVersion.steps.

Agent should be unique source of truth but syncing agent -> step is not
possible


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21755?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-06-18 13:00:21 +00:00
Paul Rastoin e7e99247e8 Centralize and standardize impersonation validation rules (#21717)
# Introduction
Followup https://github.com/twentyhq/twenty/pull/21707

## Behavioral change worth calling out
Server-level impersonation now requires verified 2FA outside development
at every checkpoint (generation, exchange, and per-request). In main the
2FA gate only existed in ImpersonationService. This is the right
tightening, but it means existing server-admin impersonation sessions in
production for admins without verified 2FA will now be rejected on the
next request, not just at token creation.

cc @s0yd4RK

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21717?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: s0yd4RK <285671363+s0yd4RK@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 13:13:50 +02:00
Abdul Rahman ccc77932a0 Tool execution metrics (#21587)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21587?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-06-18 10:58:47 +02:00
nitin 640a8e6ca6 Add post-call recording ingestion and billing (#21758)
## Summary

- Add post-call Recall recording ingestion for transcripts, audio, and
video
- Request/retrieve async transcripts and reconcile stale pending
transcript markers
- Complete call recordings atomically once all artifacts and billable
timestamps are available
- Charge `CALL_RECORDING` usage once per completed recording based on
recording duration
- Add Recall recording/media API helpers, transcript marker utilities,
and audio/video field identifiers
- Update generated metadata/SDK files and billing usage operation
support
- Add unit coverage for ingestion, completion, charging, Recall API
behavior, and reconciliation flows



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21758?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-06-18 14:01:18 +05:30
Harshit Raizada 01bb2f4ab2 fix: enforce strict rules for currency value handling by ai chatbot (#21470)
opportunity:
<img width="382" height="75" alt="image"
src="https://github.com/user-attachments/assets/be443c29-0bca-4537-a775-01cdbf704cdb"
/>
fix: 
<img width="382" height="289" alt="image"
src="https://github.com/user-attachments/assets/11aa9552-f3ac-4d25-b5aa-efbacfba3a13"
/>

closes #21419

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-06-18 10:31:13 +02:00
Etienne 39e00d5853 feat(workflow): expected output schema for runtime-output steps + validation (#21744)
## Summary

Extends the workflow validation layer (introduced in #21422) and adds a
new
"expected output schema" capability for steps whose output structure is
only
known at runtime.

Some workflow steps (HTTP Request, Code, Logic Function, AI Agent
(coming soon), Webhook
trigger) don't have a statically known output shape, so downstream steps
can't
resolve `{{step.x.y}}` variable paths or validate them. This PR lets
users
declare a **sample/expected output** for those steps, derives an output
schema
from it, and uses that schema both to power variable resolution and to
surface
validation issues at build time.

## What's included

### Expected output schema (shared schemas + types)
- New `expectedOutputSchemaShape` reused across the HTTP request, code,
logic
function and AI agent action settings schemas, plus the webhook trigger
  schema (`expectedOutputSchema` optional loose object).
- Mirrored on the server-side action/trigger settings types.

### Output schema computation (server)
- `workflow-schema.workspace-service` now computes a step's output
schema from
  the user-declared `expectedOutputSchema` sample (via
`getOutputSchemaFromValue`) when no statically computed schema is
available.

### Validation layer (server)
- `STEP_HAS_NO_VARIABLE_REFERENCE` (warning): flags steps of
`VARIABLE_CONSUMING_ACTION_TYPES` (HTTP_REQUEST, CODE, LOGIC_FUNCTION,
SEND_EMAIL, record CRUD) that reference no upstream variable.
- `LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH` /
`AI_AGENT_OUTPUT_SCHEMA_MISMATCH`
(warnings): compare the declared output schema against the expected
sample
using the new shared `getOutputSchemaMismatchIssues` util (missing keys,
  leaf/object mismatches, type mismatches).
- Trigger is now validated alongside steps (trigger type requirements +
  trigger variable references).
- Validation issues no longer return both `suggestions` and
`availablePaths`
  when they are identical (avoids redundant, costly payloads).

### Shared utilities
- New `getOutputSchemaMismatchIssues` (+ tests) in
`twenty-shared/logic-function`.
- Moved `agentResponseSchemaToOutputSchema` from `twenty-front` into
  `twenty-shared/ai` so it can be reused on both sides.

### Frontend
- New `WorkflowExpectedOutputBodyInput` component (JSON sample editor
with
validation) used by HTTP request, code, logic function and AI agent step
  editors.
- New `resolvePersistedStepOutputSchema` util + `useStepsOutputSchema`
update:
  resolves a step's output schema from `outputSchema`, falling back to
  `expectedOutputSchema`, with an AI_AGENT default.
- HTTP request / code / logic function editors persist
`expectedOutputSchema`
  and derive `outputSchema` from it.
- Webhook trigger default settings include `expectedOutputSchema`.


BONUS : iterator loop validation

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21744?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-06-18 10:31:01 +02:00
Félix Malfait f96e36d3e6 fix(ai): prevent chat thread bricking from tool parts with null input (#21752)
## Problem

Fixes #21695.

An AI chat thread became **permanently unusable** — every subsequent
message failed with `AI_APICallError: Internal server error` from
Anthropic — when the thread history contained a tool part in
`output-error` state with a **null input** (e.g. a tool call that failed
input validation before execution, so neither `toolInput` nor
`toolOutput` was ever captured).

## Validation of the reported findings

I reproduced and confirmed the root cause empirically against the pinned
`ai@6.0.97` SDK before writing the fix.

**Root cause (confirmed from SDK source).** `convertToModelMessages`
serializes every non-`input-streaming` tool part into a provider
`tool_use` block, and for errored parts it uses:

```ts
input: part.state === 'output-error'
  ? (part.input ?? ('rawInput' in part ? part.rawInput : undefined))
  : part.input,
```

When both `input` and `rawInput` are nullish, the block is built with
`input: undefined`, which `JSON.stringify` drops — so the HTTP payload
carries a `tool_use` with **no `input` field**. This matches the
reporter's minimal repro exactly (no `input` → `400 Field required`;
`input: {}` → `200`). Inside a large streamed conversation the same
malformed block surfaces as the generic `500`, and because the bad part
is replayed on every turn the thread stays bricked.

**Why #21276 didn't catch it.** `finalizeDanglingToolParts` only rewrote
`input-available` parts; a part that arrives already in `output-error`
with a null input was passed through untouched.

**Note on current `main`.** A read-path default added recently
(`mapDBPartToUIMessagePart`: `input: part.toolInput ?? {}`) already
masks the live 500 on the standard reload path. However the gap is real
and worth closing: the persist path still writes `toolInput = NULL` (the
exact malformed rows the reporter found in `core."agentMessagePart"`),
`finalizeDanglingToolParts` still doesn't normalize this case, and the
protection rested on a single implicit default with no regression
coverage. A small repro harness confirmed all of this: persisted
`toolInput` was `undefined`, and a raw (non-defaulted) `output-error`
part produced a `tool-call` whose `input` value was `undefined`.

## Fix

Defense-in-depth so the invariant *"a tool part always carries a defined
input"* holds at both the finalize and storage boundaries:

- **`finalizeDanglingToolParts`** now backfills `input: {}` for
`output-error` parts whose input is null, while preserving the original
error message. This is the natural chokepoint (it already runs
immediately before every persist).
- **`mapUIMessagePartsToDBParts`** defaults a nullish tool input to `{}`
so malformed rows are never persisted, independent of the caller.

The existing read-path `?? {}` default is kept as a third safety net.

## Tests

- Unit tests for `finalizeDanglingToolParts`: backfills `{}` for an
`output-error` part missing its input, and preserves the existing
validation error message.
- Persistence test: `mapUIMessagePartsToDBParts` stores `{}` (never
`null`) for a missing input.
- End-to-end round-trip test: after finalize → persist → reload,
`convertToModelMessages` produces a `tool-call` with a defined input and
the errored call stays resolved.

All three new core assertions were verified to **fail without the fix**
and pass with it. Full AI module suite (97 tests) passes; `oxlint
--type-aware`, `oxfmt`, and `tsgo` typecheck are clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01SpuX6Pp2yTevk1zKTRiB9G

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21752?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: Claude <noreply@anthropic.com>
2026-06-17 21:13:51 +02:00
Weiko a1f79c4f40 fix(server): enforce lowercase universalIdentifier in sync (#21754)
## Context

App sync fails when a manifest defines an entity with an uppercase UUID
`universalIdentifier`. Postgres `uuid` columns normalize to lowercase on
write, but the sync diff matches `universalIdentifier` strings
case-sensitively. So an uppercase-defined entity never matches its
lowercased DB row and is seen as delete + create on every sync, which
trips downstream guards like "Parent navigation menu item not found".

## Change

Reject non-lowercase `universalIdentifier`s at validation time in
`WorkspaceEntityMigrationBuilderService.validateUniversalIdentifier`
(right after the existing UUID-v4 check). This lives in the abstract
base builder, so it covers every syncable entity type. App authors now
get a clear "must be lowercase" error on the first sync instead of
confusing downstream failures.

Validation is sufficient here, no normalization needed — because the DB
side is always lowercase, so rejecting uppercase input guarantees both
sides of the diff match.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21754?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-06-17 19:19:46 +02:00
Etienne d99e479be8 feat(billing) - facilitate top up in ai chat (#21645)
Today, when a trialing user hits their AI usage cap inside the Ask AI
chat, ending the trial bounces them to the Stripe billing portal (and,
for card-less users, loses their place in the conversation). This PR
makes activating a paid plan / topping up credits feel seamless from
within the chat:

Trial users with a card on file activate their subscription in place,
without leaving the app.
Trial users without a card are sent to the Stripe payment-method portal
and, on return, the trial is ended automatically and they're dropped
back into the exact Ask AI thread they came from.
Credit-exhaustion and trial banners now reflect whether a payment method
exists (Add Credit Card vs Subscribe Now / End Trial Period) and upgrade
inline via a confirmation modal instead of redirecting to Settings.


Uploading Screen Recording 2026-06-16 at 07.51.12.mov…


https://github.com/user-attachments/assets/4ea77273-da63-4b32-b6f1-5ac9e9560651



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21645?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-06-17 16:20:11 +00:00
nitin 177afde866 [BREAKING CHANGE] fix chart cache collisions with key-based data plumbing (#21743)
closes
https://discord.com/channels/1130383047699738754/1514946035317997709

This fixes Apollo cache collisions for pie slices and line series by
keeping chart bucket identity as key end-to-end, matching how bar chart
already works.


What changed -- 

- Renamed pie/line chart response identity from id to key in the chart
data path.
- Kept key through frontend chart hooks, types, stories, and
tooltip/drilldown logic.
- Only adapt key to id at actual external boundaries like Nivo and
GraphWidgetLegend.
- Added/updated tests covering cache normalization and chart data
behavior.


before - 

<img width="2600" height="844" alt="CleanShot 2026-06-17 at 20 17 14@2x"
src="https://github.com/user-attachments/assets/b9ee83e9-db4b-423e-8668-a7beb4c4c62e"
/>

after - 

<img width="2614" height="800" alt="CleanShot 2026-06-17 at 20 16 17@2x"
src="https://github.com/user-attachments/assets/674a5417-ffc2-441d-9484-e1126438254c"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21743?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-06-17 16:13:27 +00:00
Félix Malfait 02a3a3c47c fix(ai): handle dynamic-tool message parts in chat persistence (#21740)
## Summary

Fixes #20558. AI chat streams crashed with `Unsupported part type:
dynamic-tool` whenever the model emitted a *dynamic* tool call (a tool
that isn't part of the bound schema). The assistant message never
persisted, so the user saw a hard failure mid-stream.

## Root cause

The AI SDK v6 emits two flavors of tool parts:
- **Static** — `type: "tool-<toolName>"` (e.g. `tool-execute_tool`)
- **Dynamic** — `type: "dynamic-tool"`, with the name on `part.toolName`

`mapUIMessagePartsToDBParts` recognised tool parts with a homegrown
check:

```ts
part.type.includes('tool-') && 'toolCallId' in part
```

That returns `false` for `'dynamic-tool'` (it contains `-tool`, not
`tool-`), so dynamic parts fell through to `throw new
Error(\`Unsupported part type: ${part.type}\`)` during the
`handleStreamFinish` persistence step. Stack trace from the issue
matches exactly.

The same broken heuristic was duplicated in:
- `packages/twenty-server/.../mapDBPartToUIMessagePart.ts` (reverse
mapper)
- `packages/twenty-front/.../utils/mapDBPartToUIMessagePart.ts`
(frontend mirror — would also throw on a `dynamic-tool` row reloaded
from history)

Meanwhile, two other call sites in the codebase
(`finalize-dangling-tool-parts.util.ts`, `isThinkingStepPart.ts`)
already correctly use the SDK's `isToolUIPart`, which natively
recognises both flavors.

## What this PR does

1. **Switches all three mappers to the SDK's canonical check**
(`isToolUIPart` on the forward path; explicit `dynamic-tool` + `tool-`
startsWith on the reverse paths, where the input is an entity/DTO, not a
UI part).
2. **Persists `toolName`** — the column already existed on the entity,
DTO and GraphQL fragment but nothing wrote it. For static parts the name
is recoverable from `type`; for dynamic parts it's the only place the
name lives, so without it the round-trip is impossible. The shared
denormalisation also helps existing per-tool analytics
(`count-native-web-search-calls-from-steps.util.ts`).
3. **Reconstructs `dynamic-tool` parts on read** (with `toolName`) so
they survive a DB round-trip both on the server and on the frontend
history view.
4. **Adds a round-trip unit test** covering both `dynamic-tool` and a
static tool part to lock the behavior in.

## Architecture notes (called out for review)

- `mapDBPartToUIMessagePart` is duplicated frontend + backend because
the input shape differs (TypeORM entity vs. GraphQL DTO). Out of scope
to consolidate here, but they're drifting — this PR is what that drift
looked like in production. Worth a follow-up to express the shared logic
once over a unified row type.
- I left the existing renderer guard `part.type !== 'dynamic-tool'` in
`AiChatAssistantMessageRenderer.tsx` alone — it's a reasonable UI-side
decision to not attempt to render an unknown dynamic tool generically.
Persistence and history reload now work; rendering of dynamic tool calls
is a separate UX decision.
- No DB migration needed — the `toolName` column already exists. Old
static rows have `toolName: null`; the reverse mapper recovers their
name from the `type` column as before. Old dynamic-tool rows don't exist
(they all threw on write).

## Test plan
- [x] `yarn workspace twenty-server jest map-message-parts.dynamic-tool`
— 5 passed
- [x] `yarn workspace twenty-server jest
finalize-dangling-tool-parts.roundtrip` — still 4 passed (no regression)
- [x] `yarn nx typecheck twenty-server` — clean
- [x] `yarn nx typecheck twenty-front` — clean
- [x] `yarn nx lint:diff-with-main twenty-server` — clean
- [x] `yarn nx lint:diff-with-main twenty-front` — clean
- [ ] Manual: trigger an AI chat that exercises a dynamic tool (e.g. via
an MCP server returning a tool not in the bound schema) and confirm the
stream finishes and the message persists.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013EE11eVWtyxmdcbEHVJKoc

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21740?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: Claude <noreply@anthropic.com>
2026-06-17 18:12:21 +02:00
Thomas Trompette 105f9565a5 feat(workflow): surface manual-trigger payload + metadata in variable picker (#21692)
## Summary

Step 2 of the manual-trigger output schema restructuring (expand →
display → migrate → contract).

Builds on the now-merged #21676 (which expanded the runtime payload to
serve `payload` and `metadata` siblings at the trigger root). This PR
**surfaces** those in the variable picker as nested, expandable nodes:

- `trigger.payload.{record fields}` — the record(s) that triggered the
run
- `trigger.metadata.workspaceMemberId` — who triggered it

The flat root fields (`trigger.id`, etc.) remain available, so existing
saved variable references keep working until a later migration phase
moves them.

### Changes
- **twenty-shared**: metadata/payload label constants +
`build-manual-trigger-metadata-node` util + barrel exports.
- **twenty-front**: `computeStepOutputSchema` MANUAL branch now nests
`payload` (RecordNode for SINGLE_RECORD, array Node for BULK_RECORDS,
omitted for GLOBAL) and `metadata`; `ManualTriggerOutputSchema` type
updated to `{ payload?; metadata }`.
- **twenty-server**: `computeTriggerOutputSchemaFromAvailability`
mirrors the same nested shape for server-side validation.

The key is `metadata` (not `_metadata`) — custom fields can't start with
`_`, so collision risk was deemed acceptable.

## Test plan
- [x] `npx nx build twenty-shared`
- [x] `computeStepOutputSchema` unit tests pass (55)
- [x] Manual: create a manual-trigger workflow (GLOBAL / single-record /
bulk), confirm the picker shows `payload` and `metadata` as expandable
folders and that selecting a field yields `{{trigger.payload.<field>}}`
/ `{{trigger.metadata.workspaceMemberId}}`

> Note: server typecheck has pre-existing unrelated failures on main
(Stripe billing mocks, gmail mocks); none touch workflow files.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21692?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-06-17 15:54:35 +00:00
Weiko 607d9ee6e5 fix(server): allow app-defined permission flags to be referenced by a role in the same sync (#21742)
## Context

When an application defined custom permission flags and a role
referencing them in the same sync, installation failed, first at
validation (Permission flag not found) and then at execution (Migration
action 'create' for 'rolePermissionFlag' failed).

Root cause: both the migration builder order and the runner execution
order processed rolePermissionFlag before permissionFlag, so the role's
flag assignments were validated/inserted before the flags they reference
existed.

## Changes

- Builder order: run the permissionFlag builder before
rolePermissionFlag so newly created flags are visible in the optimistic
maps when assignments are validated.
- Execution order: order the permission-flag actions so definitions are
created before assignments, and assignments deleted before definitions,
keeping the FK satisfied in both directions.
- In-use check: move the "flag still assigned to a role" guard out of
the per-entity deletion validator (order-dependent, false-positived when
a flag and its assignments were deleted together) into a new
order-independent validatePermissionFlagNotInUseCrossEntity (aligned
with existing validateObjectMetadataCrossEntity,
validateViewFieldLabelIdentifierCrossEntity, ...), run after all
builders against the migration's final state.

This fixes both the create path (define flag + reference it in one sync)
and the teardown path (delete flag + its assignments in one sync).

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