Commit Graph

3952 Commits

Author SHA1 Message Date
Félix Malfait 9f4efa57ff Expose sent message identifiers in workflow send-email step output (#22520)
## Context

First step toward thread-continuity / follow-up email steps in workflows
(email sequences). The outbound send pipeline already knows the sent
email's RFC-822 Message-ID, the provider thread id, and the persisted
message/thread records — but none of it was surfaced in the send-email
step output, so a later step had no way to reference the email that was
sent.

## What changed

- `saveMessagesWithinTransaction` also returns a `messageExternalId →
messageThreadId` map, and `saveMessagesAndEnqueueContactCreation`
returns the message/thread id maps (both other call sites ignore the
return value)
- `SentMessagePersistenceService.persistSentMessage` and
`SendEmailService.persistSentMessage` return the persisted `{ messageId,
messageThreadId }` (`undefined` when persistence is skipped or fails —
sending still succeeds)
- `SendEmailTool` result now includes `headerMessageId`,
`threadExternalId`, `messageId` and `messageThreadId`
- SEND_EMAIL step output schema (server + frontend) declares
`headerMessageId`/`messageId`/`messageThreadId` so they show up in the
variable picker; DRAFT_EMAIL keeps its success-only schema since draft
creation returns no identifiers yet

This already enables manual thread continuity today: wire
`{{sendEmailStep.headerMessageId}}` into a later email step's
In-Reply-To advanced field — the composer resolves the References chain
and provider thread from it.

## Tests

- New `send-email-tool.spec.ts` covering identifiers in the result,
persistence disabled, and persistence failure
- Extended save-messages spec with the new map, updated frontend
`computeStepOutputSchema` tests

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22520?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 18:01:40 +02:00
Félix Malfait 13f380b80d perf(front-component): fingerprint built-JS URLs by path for CDN caching (#22530)
**Stacked on #22523** — base is that branch, so the diff shows only this
commit. GitHub will retarget it to `main` automatically once #22523
merges.

Follows up on @FelixMalfait's question on #22523: move the
BuiltFrontComponent cache key from a query string into the path so it
plays well with Cloudflare cache rules.

## What

- URL: `/rest/front-components/:id?checksum=<c>` →
`/rest/front-components/:id/<c>.js` (`getFrontComponentUrl`).
- Route: the controller now accepts `[':frontComponentId',
':frontComponentId/:cacheKey']`. `:cacheKey` is a pure cache-buster the
server **ignores** — it still resolves by `:frontComponentId`, exactly
as the query param did.

## Why a path segment (not `:id-<checksum>.js`)

A path-based, extension-bearing URL is matched by Cloudflare's
**default** static-asset caching and by trivial `*.js` path cache rules,
and it's immune to any "ignore query string" cache setting that would
otherwise collapse `?checksum=` to one entry and serve stale JS.

I used a path **segment** (`/:id/:checksum.js`) rather than the literal
`:id-<checksum>.js` you sketched because the id is a **UUID — which
itself contains hyphens** — so a `-` separator is ambiguous to parse. A
segment is unambiguous and equally CDN-friendly (still ends in `.js`).

## Backward compatibility

The bare `:frontComponentId` route is kept, so URLs minted before this
deploys (query-string form, or in-flight pages) still resolve. It can be
dropped in a later release once no client mints the old form. No data
migration — the URL is computed at render time from `frontComponentId` +
`builtComponentChecksum`.

## ⚠️ Decision for you: this alone does not edge-cache — `private` vs
`public`

BFC is served behind `WorkspaceAuthGuard` and #22523 set its header to
**`private`**, max-age, immutable. `private` means shared caches
(Cloudflare) **won't** store it — so today this is browser-cache only,
and the path change just makes it *ready* for edge caching + clean cache
rules.

To actually get **edge** caching you'd additionally either flip BFC to
`public` or add a Cloudflare rule that overrides cache-control — which
means **accepting that the `id`+`checksum` URL becomes the access
capability** (a cache hit is served without re-checking origin auth).
The cache key is unique per component+build so there's no
cross-workspace mixup, but the built JS effectively becomes
public-by-URL (same posture PublicAsset already has). I've **left it
`private`** here; flipping to `public` is your call and can be a
one-line follow-up.

## Tests

- `getFrontComponentUrl` unit test: fingerprinted path when a checksum
is present, bare fallback otherwise.
- Integration test: the `/front-components/:id/:checksum.js` path serves
the built JS with `Content-Type: application/javascript` and
`Cache-Control: private, max-age=86400, immutable`. Existing bare-route
tests remain and still pass.

https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22530?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 17:17:14 +02:00
Félix Malfait 41ef601a7a perf(twenty-server): cache BuiltFrontComponent and PublicAsset responses (#22523)
Follow-up to #22510. Closes #22515 — extends `Cache-Control` to the two
remaining app-asset folders that #22510 left `immutable: false` because
they're path-addressed. Each now gets the directive that matches **how
it is addressed**.

## BuiltFrontComponent → immutable

I was wrong in the #22515 write-up to call this "stable URL, mutable
bytes." The browser **already content-addresses it**:
`FrontComponentRenderer` fetches
`/rest/front-components/:id?checksum=${builtComponentChecksum}`
(`getFrontComponentUrl`), so a rebuild changes the checksum → changes
the URL → busts the cache. That makes `immutable` safe — no stale-code
window — and needs no new versioning machinery. Wired the header into
`FrontComponentController.getBuiltJs` (which passed no folder) and the
front-component presign path.

## PublicAsset → bounded public cache

Genuinely path-addressed and overwritten in place on every app
(re)install/redeploy (upsert on
`['path','workspaceId','applicationId']`), so it **cannot** be
`immutable`. Instead:
- **`public`** — the `/public-assets/...` endpoint is unauthenticated
(`PublicEndpointGuard`), so the bytes are already world-readable;
marking the response `public` lets a CDN (e.g. Cloudflare in front of
the server) serve app/marketplace logos from the edge instead of hitting
the origin on every render. Today these responses carry no
`Cache-Control` at all.
- **`max-age=3600`, not `immutable`** — a bounded window so an asset
overwrite recovers within an hour. This one hour is the single judgement
call here; tune it (or add `stale-while-revalidate`) to taste.

## Mechanism

Generalized `FileFolderConfig.immutable` (boolean) into `cacheControl`
(`string | null`) so a folder can carry its own directive instead of
only opting into one hardcoded string. `setFileResponseHeaders` and the
presign paths now read `cacheControl` directly. The immutable-folder set
is unchanged; only BuiltFrontComponent (→ immutable) and PublicAsset (→
bounded public) move.

## Tests

`setFileResponseHeaders` spec updated: BuiltFrontComponent now asserts
immutable, PublicAsset asserts `public, max-age=3600`, and the remaining
path-addressed folders (`AppTarball`, `Source`, `BuiltLogicFunction`,
`Dependencies`) assert no `Cache-Control`.

_Note: I bundled both folders into one PR since they share the config
generalization — happy to split BuiltFrontComponent (safe/immutable)
from PublicAsset (the `max-age` judgement call) if you'd rather review
them separately._

https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22523?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 17:14:12 +02:00
Paul Rastoin cdd667b106 fix(server): restrict /webhooks/server dispatch to server-route-exposed functions (#22469)
## What

`ServerRouteTriggerService.findResolver` resolved a logic function
purely by `universalIdentifier` and app-registration ownership, then
executed it before its resolver result shape was validated. As a result
the public `/webhooks/server/:universalIdentifier` route could dispatch
any owner-workspace app function — including ones exposed only as
authenticated HTTP routes, tools, or workflow actions — instead of only
functions declared as server-route resolvers.

## Change

`findResolver` now requires `serverRouteTriggerSettings`:
- DB predicate `serverRouteTriggerSettings: Not(IsNull())`, so
non-exposed functions are never fetched
- in-memory `isDefined(...)` guard alongside the existing
owner-workspace check

A function that did not opt into server-route exposure is now rejected
at `findResolver`, before any execution. A legitimately exposed resolver
is unaffected.

## Tests

- Unit (`server-route-trigger.service.spec.ts`): asserts the resolver
query carries the exposure predicate, and that an owner-workspace
function without `serverRouteTriggerSettings` is rejected and never
handed to the executor.
- Integration
(`server-route-trigger-authorization.integration-spec.ts`): exercises
the public endpoint end to end — a non-exposed owner-workspace function
is rejected before execution, while a server-route-exposed resolver
still passes the boundary.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22469?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 16:52:14 +02:00
Etienne 1270054d35 feat(ai): dashboard & view building (#22411)
## Why

Building a dashboard through AI chat used to cost ~9 sequential LLM
round-trips
(~160K input tokens for a single request): the agent had to resolve
object/field UUIDs and assemble views through many granular tool calls,
each
step replaying the full cached context.

## What changed

### 1. Reference objects & fields by name (fewer round-trips)
The agent no longer needs to resolve UUIDs before acting.
- `get_object_metadata`: filter by `objectName` (singular/plural) and a
new
`includeFields` flag returning each object's fields (`{id, name, type,
label}`)
  inline — object + field IDs in one call.
- `get_field_metadata`: accepts `objectName` as an alternative to
  `objectMetadataId`.
- All three dashboard write tools (`create_complete_dashboard`,
`add_dashboard_widget`, `update_dashboard_widget`): accept `objectName`
and
`*FieldName` variants (`aggregateFieldName`,
`primaryAxisGroupByFieldName`,
`secondaryAxisGroupByFieldName`, `groupByFieldName`, ratio `fieldName`),
resolved to UUIDs server-side by `resolveWidgetFieldNamesToIds`. UUID
variants
  still win when both are given.

### 2. `upsert_complete_view` — one atomic call to build/reconfigure a
view
- New `upsert_complete_view` tool + `ViewService.upsertCompleteView`:
create or
  update a view together with its fields, filters, and sorts.
- Children are **declarative**: a provided array replaces all existing
entries of
that kind, `[]` clears them, omitting leaves them untouched. Fields are
  referenced by name or UUID; no child-row IDs needed.
- Runs as a **single workspace migration** (`view` + `viewField` +
`viewFilter` +
`viewSort` in one `validateBuildAndRunWorkspaceMigration` matrice)
instead of
chained per-entity service calls. New
`buildCompleteViewChildrenFlatOperations`
  util assembles the child create/delete operations.
- Granular tools (`create_view_filter`, `update_view_sort`, …) are
retained for
  surgical single-entry edits.

### 3. Chart filters on dashboard widgets (end-to-end)
- Added `chartFilterSchema` (`recordFilters` + optional
`recordFilterGroups` for
AND/OR logic) to the four chart configs, with field-by-name or -UUID
references
  and documented operands/value formats.
- **Relative dates supported** — e.g. `PAST_7_DAY`, `THIS_1_MONTH`,
`NEXT_3_WEEK`,
plus open-ended `IS_IN_PAST` / `IS_IN_FUTURE` / `IS_TODAY`. Filters
route
through the same read pipeline (`computeRecordGqlOperationFilter`) as
view
  filters, so they resolve and apply correctly.
- `resolveChartFilterFieldNamesToIds` resolves filter `fieldName` → id
against the
  widget object.

### 4. Re-enable AI-assisted dashboards
- Removed the "coming soon" gating (`isActive: false` on the dashboard
skill and
the "not available yet" copy in the MCP server + chat prompts) and
registered
  `DashboardToolProvider`.
- Rewrote the dashboard skill prompt: confirmation gate (present a plan,
wait for
confirmation), completion guard (once confirmed, emit the create tool
in-turn —
no "now let me…" preambles), default-and-proceed (pick sensible defaults
for
missing fields instead of stalling), and an intent gate so informational
  dashboard questions are answered directly without loading skills.

### 5. Frontend: clearer advanced-filter labels
- `useRecordFilterField` now derives the filter label from field
metadata and
appends the relation target field (e.g. `Company → Name`), so
relation/target
filters — including those set by the AI — display correctly instead of
showing
  a stale/blank stored label.

## Fixes
- **`get_object_metadata({ objectName })` crash.**
`ObjectMetadataService.findManyWithinWorkspace`
  spread an array-form (`OR`) `where` into a plain object, producing
`{ "0": {...}, "1": {...}, workspaceId }` → `Property "0" was not found
in
"ObjectMetadataEntity"`. Now injects `workspaceId` into each OR clause,
so name
  lookups work.
- **Invalid SELECT/MULTI_SELECT filter options silently produced broken
charts/views.**
Chart-configuration validation and the migration-layer
`FlatViewFilterValidator`
now reject filters that reference options that don't exist, with a clear
  `Allowed values: …` message at creation time (shared
  `getInvalidSelectFilterOptionValues` util + tests).
- **Non-atomic view assembly.** The previous multi-call view build could
leave a
half-built view on failure; `upsert_complete_view` now runs as a single
transaction (one validation pass, one cache recompute, rollback on
error).
- **Blank RECORD_TABLE widgets from UNLISTED views.** Guidance + the
upsert
ownership check steer widget-backing views to `WORKSPACE` visibility; an
  UNLISTED view created without an owner renders a blank widget.
- **Extra discovery round-trip removed.** Deleted the skill→tool bundle
mechanism
(`SKILL_TOOL_BUNDLES`, `getBundledToolNamesForSkills`, and the
`load_skills`
  schema-loading path) that forced a second `learn_tools` call.
- **Type-safety of widget resolution.** Reworked the widget resolver to
build a
properly typed `WidgetWithMetadataIds` (dedicated input/output types)
instead of
  returning an untyped, cast-heavy object.

## Notes
- Backend changes are in `twenty-server`; one small `twenty-front`
change to the
  advanced-filter label hook. No entity/schema changes, so no migration.
- Tests added: `getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`
(incl. filter/relative-date resolution), `update_dashboard_widget`, and
expanded
  view-tools factory specs.
- Design decisions: dedicated composite tool over code-interpreter
orchestration
(atomicity + validation + consistency with `create_complete_dashboard` /
  `create_complete_workflow`); name-or-UUID but no child-row IDs on
`upsert_complete_view`; name→id resolution kept as stateless utils, not
services.

## Test plan
- [ ] `npx nx run twenty-server:typecheck`
- [ ] `npx nx lint:diff-with-main twenty-server` and `twenty-front`
- [ ] `npx nx test twenty-server` (view tools factory,
`getInvalidSelectFilterOptionValues`,
      `resolveWidgetFieldNamesToIds`, `update_dashboard_widget`)
- [ ] AI chat: "Create a dashboard with a chart of deal value by
pipeline stage
      and a table of the top 10 open opportunities" → plans, waits for
      confirmation, then builds with fewer round-trips
- [ ] AI chat: add a chart widget filtered by a relative date (e.g.
deals created
      in `PAST_7_DAY`) and confirm the chart is actually filtered
- [ ] Filter on a non-existent SELECT option is rejected with a clear
error

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22411?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 14:15:27 +00:00
martmull 1a85b88d38 feat(files): direct-to-storage upload endpoints with pending file lifecycle (#22449)
<img width="1484" height="404" alt="image"
src="https://github.com/user-attachments/assets/b2d363bf-d9e1-49fb-9811-8cc98041aa79"
/>


## Context

Uploading large files currently OOMs the server: every upload resolver
buffers the whole file in memory (`streamToBuffer`) before writing it to
storage. This PR is the first of a series introducing direct
client-to-storage uploads. It adds the server-side endpoints and driver
support only — it is non-breaking and nothing consumes the new flow yet.
Follow-up PRs will migrate the frontend upload paths, add a
stale-pending-file cleanup cron, and cap the legacy buffered resolvers.

## What it does

**New upload flow (initiate → PUT → confirm):**

- `createFileUpload(filename, size, fileFolder, fieldMetadataId?)`
validates the request (folder allowlist: `FilesField`/`Workflow`, max
size, extension-derived mime type), creates the file record in a new
`PENDING` status, and returns an upload target:
- **S3 with presign enabled** → a presigned PUT URL with
`Content-Type`/`Content-Length` pinned in the signature, so the client
uploads straight to the bucket;
- **local storage, or S3 without presign** → a token-authenticated
streaming endpoint on the server (`PUT /file-upload/:id?token=…`, new
`FILE_UPLOAD` JWT type) that pipes the request body to the storage
driver with constant memory usage and a declared-size cap.
- `completeFileUpload(fileId)` verifies the bytes actually landed in
storage (HEAD + size match against the declared size) and flips the
record to `UPLOADED`. Idempotent.

**Pending lifecycle safety:**

- New `status` column on `core.file` (`PENDING`/`UPLOADED`, default
`UPLOADED` so all existing rows and the legacy upload path are
unaffected) + fast instance command.
- Files are refused by the serving endpoints and by FILES-field sync
while `PENDING`.

**Driver support (both drivers):**

- `getPresignedUploadUrl` (S3: presigned PUT; local: `null` →
server-endpoint fallback)
- `writeFileStream` (local: `fs` pipeline with the existing
symlink/containment hardening, partial-file cleanup on error; S3:
`@aws-sdk/lib-storage` `Upload` for bounded-memory streaming)
- `getFileMetadata` (HEAD/stat for confirm-time verification)

## Tests

- `file-upload.service.spec.ts`: initiate validation (folder allowlist,
size), presigned vs fallback target, confirm verification (missing
object, size mismatch, happy path, idempotency)
- `local.driver.spec.ts`: `writeFileStream` (content, symlink rejection,
partial-file cleanup on stream error), `getFileMetadata`
- `s3.driver.spec.ts`: `getPresignedUploadUrl` (disabled → null, PUT
command with signed content-type/content-length)
- `direct-file-upload.integration-spec.ts`: full end-to-end flow against
the local driver (initiate → PUT → complete → download), plus error
paths (complete without upload, oversized PUT → 413, invalid token →
403, unsupported folder, size above max)

## Notes for reviewers

- The upload-size ceiling for direct uploads is
`settings.storage.maxDirectUploadFileSize` (1GB), separate from the 10MB
`maxFileSize` used for pictures.
- Since content can't be sniffed before it reaches storage, the mime
type is derived from the file extension (with the existing
`TWENTY_MIME_POLICY` override) and unknown extensions fall back to
`application/octet-stream`; the serving path already forces
`Content-Disposition: attachment` for anything not on the inline-safe
allowlist.
- Self-hosters using S3 presign will need a bucket CORS policy allowing
`PUT` from the frontend origin (config variable description updated).

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22449?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 15:11:53 +02:00
martmull 0992d0b748 feat(server): promote registration display fields to first-class columns (#22513)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456 — follow-up to
#22453, delivering the promised removal of the temporary manifest load.

Display data (description, author, category, websiteUrl,
aboutDescription, termsUrl, emailSupport, issueReportUrl, screenshots)
only existed inside the `manifest` jsonb, forcing hot paths to load it.
This PR:

- Promotes those 9 fields to first-class columns on
`applicationRegistration`, populated at every ingestion point
(`updateFromManifest`, both `upsertFromCatalog` branches) — fast command
creates the columns at deploy, slow command backfills them from the
manifest.
- `findManyListedCatalogCards()` (marketplace list) now selects only
scalar columns — the manifest jsonb is no longer loaded there.
- `findPublicByClientId()` (OAuth consent page) now selects `id, name,
logo, websiteUrl, oAuthScopes` — no manifest.
- The narrow select used by
`findMany`/`findAll`/`findOneById`/`findOneByIdGlobal` includes the new
columns.
- GraphQL surface unchanged (no new fields); the marketplace detail
endpoint still reads the manifest and is slimmed in the next PR.

Verified: migration applied via the real runner (both commands recorded
completed), backfill SQL exercised against live rows (full + minimal
manifests), migration generator reports no pending schema changes,
typecheck, lint, unit suites (application-registration + marketplace
15/15).

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

https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22513?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 14:47:24 +02:00
Félix Malfait 5d3b6d05b3 feat(ai): add a stream heartbeat and reap dead claims so a worker crash cannot brick a thread (#22482)
## Rationale

If the worker process dies mid-stream (OOM, deploy, crash), nothing ever
clears `activeStreamId`: `aiStreamQueue` runs with `attempts: 1`, the
job's `finally` never executes, and the SSE keepalive comes from the API
server — so it actively masks worker death. The thread is bricked: every
send queues behind a dead claim until someone intervenes manually. This
is a CONFIRMED-high from the chat-stack audit, and worker death is not
hypothetical: Sentry shows an unhandled promise rejection inside the AI
SDK in the worker
([TWENTY-SERVER-H7Y](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-H7Y))
— unhandled rejections terminate Node by default.

## Design

- **Claim-time mark**: every enqueue site marks
`agent-chat-stream-alive:<streamId>` with a TTL matching the job lock
horizon (600s) — covering the enqueue→pickup window where a waiting job
holds no lock.
- **Running refresh**: the job tightens it to **30s, refreshed every
5s**; if the process dies, the interval dies with it and the key
expires. The expiry *is* the death signal. (Was 60s/15s — tightened
after review: detection latency is bounded by the TTL, robustness by
TTL−interval and the missed-beat tolerance; 30s/5s halves detection
while tolerating *more* missed beats, 5 vs 3.)
- **Read-path reap**: the send gate and the catchup query convert a
heartbeat-less claim into a normal retryable `STREAM_INTERRUPTED`
failed-turn state (conditional UPDATE guarded on the observed streamId,
so a newer stream's claim is never touched), reset the Redis chunk
state, and publish the terminal error. `isAlive` fails open on Redis
errors — a liveness probe must not turn a Redis blip into a broken send
path.

## Why this is the root cause, not a symptom patch

The strongest alternative — BullMQ's own stalled-job detection — fails
on four concrete grounds: detection latency is bounded by the deliberate
10-minute `AI_STREAM_LOCK_DURATION_MS` (long silent tool runs must not
spuriously stall); the stalled checker needs a *surviving* worker in the
pool; the signal fires in the worker process while the thing needing
repair is a DB claim read by API-server resolvers; and a `waiting` job
holds no lock at all. Reaping at the read path means recovery happens
exactly when a user is looking — the moment it matters — with zero
background machinery.

**Relationship to the graceful-shutdown work (planned follow-ups)**:
shutdown hooks + drain-then-abort will make *deploys* (cooperative
SIGTERM) end streams cleanly, and disabling stalled re-runs will stop
hard-killed jobs from zombie re-executing tools. This PR remains the
only recovery layer for non-cooperative deaths — OOMKill is a straight
SIGKILL, crashes and unhandled rejections never run shutdown hooks — and
the backstop when the drain path itself fails. The two are complements,
not alternatives.

## User impact

Today a worker crash mid-answer bricks the thread until manual
intervention; users see sends silently queue forever. With this, the
next interaction (send, reload) converts it into a visible "response was
interrupted" error with a working Retry, within ~30s of actual death.

## Test plan

- [x] Claim spec: live stream untouched; heartbeat-less claim reaped
into retryable `STREAM_INTERRUPTED` + chunk-state reset + published
terminal event; no-op when the claim moved to a newer stream mid-check
- [x] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38
2026-07-03 13:29:02 +02:00
Charles Bochet 203da78b05 feat(logic-function): namespace shared Lambda resources per instance (#22509)
## Problem

When several Twenty instances run in the **same AWS account + region**
(our customer's setup), their logic-function Lambda resources collide.

Four shared resources are named purely from a content hash:

| Resource | Name | Derived from |
|---|---|---|
| Builder fn | `twenty-builder-<sha256(handler)>` | handler code (⇒
version) |
| Yarn-install fn | `twenty-yarn-install-<sha256(handler)>` | handler
code |
| Common layer | `twenty-common-layer-<sha256(pkg+lock)>` | dependencies
|
| Deps layer | `deps-<yarnLockChecksum>` | app yarn.lock |

Because the hash is identical across instances of the same version,
every instance computes the **same name**. `ensureBuilderLambdaExists` /
`ensureYarnInstallLambdaExists` do `GetFunction → if it exists, return`
with **no role check**, so the first instance to create the function
binds it to *its* `LOGIC_FUNCTION_LAMBDA_ROLE` and every other instance
silently reuses it. When that role is later deleted or belongs to a
different account, invokes fail with:

> The role defined for the function cannot be assumed by Lambda.

Nothing tears these shared resources down, so a poisoned function
persists indefinitely. (Executors are UUID-named and SDK layers are
workspace-scoped, so they don't collide.)

## Fix

Namespace the four shared resources by a per-instance segment.

- New optional config var **`LOGIC_FUNCTION_LAMBDA_RESOURCE_NAMESPACE`**
(LOGIC_FUNCTION_CONFIG group).
- When unset it defaults to `sha256(LOGIC_FUNCTION_LAMBDA_ROLE).slice(0,
10)`.

Keying the namespace on the execution role makes the sharing boundary
correct:
- **same role → same names →** instances still dedupe (original intent
preserved),
- **different role → different names →** full isolation, invoke can
never hit a role it can't assume,
- **role change →** resources are recreated fresh under a new name
(self-healing).

Names become `twenty-builder-<namespace>-<checksum>`,
`deps-<namespace>-<checksum>`, etc. — the owning instance stays legible
in the AWS console.

### Defensive role-heal
As a safety net (and to heal already-poisoned functions), after
`GetFunction` succeeds we compare `Configuration.Role` to the configured
role; on mismatch we delete and recreate the tool function.
(`waitFunctionDeleted` polls `GetFunction` until
`ResourceNotFoundException` — this SDK version has no
`waitUntilFunctionNotExists` waiter.)

## Scope / compatibility

- Executor functions (`<logicFunctionId>`) and SDK layers
(`sdk-<workspaceId>-<appUUID>`) are unchanged — already unique.
- On upgrade, shared-resource names change once (role-hash namespace),
so each instance recreates its builder/yarn-install/common-layer/deps on
first use; old ones are orphaned (harmless, unreferenced).
- Operators who want explicit control can set
`LOGIC_FUNCTION_LAMBDA_RESOURCE_NAMESPACE`.

## Ops note (immediate unblock, independent of this PR)

Delete the poisoned `twenty-builder-<hash>` (and sibling
`twenty-yarn-install-*`) in the affected region; it is recreated with
the correct role on next use.

## Tests

- `compute-hashed-lambda-resource-name.util.spec.ts` — namespace segment
behavior
- `get-lambda-deps-layer-name.util.spec.ts` — namespaced deps layer name
- `get-lambda-resource-namespace.util.spec.ts` — stable, role-distinct
namespace

All pass; `lint:diff-with-main` clean; typecheck clean for changed
files.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22509?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 13:18:26 +02:00
Félix Malfait 8b191d6fcc chore(server): remove the five dead FileFolder values and their legacy serving pipeline (#22516)
Follow-up cleanup after #22510: shrink `FileFolder` and
`fileFolderConfigs` to only folders that actually exist, so per-folder
policy entries are real decisions.

## What

**Remove the five dead enum values** — `ProfilePicture`,
`WorkspaceLogo`, `Attachment`, `PersonPicture`, `File`. They were
already marked replaced/removed in the enum, have no production write
path, and `FileByIdGuard`'s `SUPPORTED_FILE_FOLDERS` allowlist already
rejects them at the serving endpoint.

**Delete the legacy path-based serving pipeline that existed only for
them** — verified wired to no route:
- `FilePathGuard` — registered as a provider in `FileModule` but applied
to no controller
- `extractFileInfoFromRequest` (parsed the old
`/files/profile-picture/original/TOKEN/file.jpg` format) — only consumer
was `FilePathGuard`
- `checkFileFolder` — only consumer was `extractFileInfoFromRequest`
- `settings.storage.imageCropSizes` — keyed exclusively by the three
dead picture folders, zero consumers
- the crop-size helpers in `utils/image.ts` (`getCropSize`,
`ShortCropSize`, `CropSize`) — zero consumers outside the file;
`getImageBufferFromUrl` is kept
- `AllowedFolders` type — last consumer was `checkFileFolder`

**Test fixtures** referencing dead folders were moved to living ones;
the specs of deleted utils are deleted with them.

**Generated files** (`twenty-front/src/generated-metadata/graphql.ts`,
`twenty-client-sdk` schema) hand-updated to match the shrunk GraphQL
enum.

## Legacy data safety

Workspaces may still hold `File` rows whose `path` starts with a dead
prefix (e.g. `attachment/…`). These stay inert, exactly as today:

- Serving: `FileByIdGuard` rejects non-supported folders before any
config lookup, and file lookups filter by `path LIKE
'<current-folder>/%'`, so dead-prefix rows are unreachable.
- Every consumer that feeds stored paths into
`removeFileFolderFromFileEntityPath` (which throws on unknown prefixes)
is upstream-guarded by a current-folder filter or allowlist — audited
all seven call sites.
- Stored legacy member `avatarUrl` strings are parsed with
`extractFileIdFromUrl(url, FileFolder.CorePicture)` and already fall
back to `''` for old formats; unchanged.

## GraphQL note

`FileFolder` is exposed as a GraphQL enum (input of the dev-only
`uploadApplicationFile` mutation, which only accepts application-code
folders). Clients sending a removed value were already rejected at the
resolver allowlist; they now fail GraphQL enum validation instead. No
supported client sends them — the frontend only uses `CorePicture`.

Net: **+10 / −301** across 17 files.

https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22516?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 13:13:14 +02:00
Félix Malfait 8156bf2b89 perf(twenty-server): drive immutable file caching from fileFolderConfigs on both serving paths (#22510)
Follow-up to #22166 (now merged). Single commit, rebased onto main.

## Why

#22166 introduced a `Cache-Control` header for avatar responses, gated
on a hardcoded `CACHEABLE_PICTURE_FILE_FOLDERS = [CorePicture]` list.
Two limitations:

- The list is an ad-hoc second classification of `FileFolder`,
maintained separately from the central `fileFolderConfigs`.
- The header is only set on the stream branch of `getFileById`. On S3
deployments with presigned URLs enabled, the controller 302-redirects
before `setFileResponseHeaders` runs and the presigned S3 response
carries no `Cache-Control` at all — so the header never fires where it
matters most.

Whether a folder's bytes are cacheable-forever is a property of how the
folder is written, and the codebase already has a per-folder source of
truth: `fileFolderConfigs`.

## What

- Add `immutable: boolean` to `FileFolderConfig`. `true` for folders
whose write paths mint a fresh `v4()` file id embedded in the resource
path on every upload — so the bytes behind a given URL can never change:
`CorePicture`, `FilesField`, `Workflow`, `AgentChat`, `EmailAttachment`,
`Dpa`. `false` everywhere else, notably:
- `PublicAsset` — path-addressed, overwritten in place on app
(re)install (including the new manifest logo import)
- `AppTarball` — reuses `tarballFileId` and a stable
`${registrationId}/app.tar.gz` path across version bumps
- `setFileResponseHeaders` reads the flag instead of the ad-hoc list
(list deleted).
- Thread `responseCacheControl` through
`FileStorageService.getPresignedUrl` → `StorageDriver` → `S3Driver`,
which passes it as `ResponseCacheControl` on the `GetObjectCommand`, so
presigned S3 responses return the same `Cache-Control: private,
max-age=86400, immutable` on the redirect path.

`private` is kept because responses are gated by a per-workspace file
token; `immutable` is safe because a changed file always gets a new id
and URL.

## Tests

- `setFileResponseHeaders` spec: header set for each immutable folder,
not set for mutable folders (`PublicAsset`, `AppTarball`, deprecated
picture folders) or when no folder is provided.
- `S3Driver.getPresignedUrl` spec: asserts `ResponseCacheControl` is
forwarded onto the `GetObjectCommand`.

https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W
2026-07-03 12:32:15 +02:00
Abdul Rahman 9a7c0f25a5 fix(server): resolve foreign key violation blocking application uninstall (#22502)
Fixes [sonarly issue #54192](https://sonarly.com/issue/54192)

## Problem

Uninstalling an application fails with a DB error when its
`packageJsonFileId` / `yarnLockFileId` columns are populated:

    update or delete on table "file" violates foreign key constraint
    "FK_3818380258798f9ffa9963b6dc4" on table "application"

Storage was also wiped before the failing DB delete, leaving the app
half-uninstalled.

## Root cause

`application` and `file` reference each other through `ON DELETE
RESTRICT` FKs (`application.packageJsonFileId/yarnLockFileId → file.id`
and `file.applicationId → application.id`), so no deletion order works
on its own. The deferrable-FK migration doesn't help: in Postgres,
`RESTRICT` fires immediately even on `DEFERRABLE INITIALLY DEFERRED`
constraints (only `NO ACTION` honors deferral). Uninstall deleted file
rows first, in autocommit statements.

## Fix

`ApplicationService.delete()` now runs in a single transaction:

1. Clear `packageJsonFileId` / `yarnLockFileId` (breaks the FK cycle)
2. Delete the app's `file` rows
3. Delete the `application` row

Storage cleanup moved after commit and made non-fatal, so a failure can
no longer leave partial state. `deleteApplicationFiles` is split into
`deleteApplicationFileRows` (DB, transactional) and
`deleteApplicationFilesFromStorage` (blobs). The test cleanup util had
the same file-first ordering bug and is fixed the same way.


## Questions / Follow-ups

- **Should the FK cycle be resolved at the schema level?** Both legs
could be switched to `ON DELETE NO ACTION DEFERRABLE INITIALLY
DEFERRED`, which appears to be what the deferrable-FK migration intended
— deferral would then actually apply to deletes, making transactional
deletion order-independent. Happy to open a separate PR if there's
interest.

- **Should the marketplace install path set the package file FKs?** It
stores `package.json` in the `file` table but never populates
`application.packageJsonFileId` / `yarnLockFileId` — today only
workspace creation and `application:rebuild-default-deps` set them.
Marketplace packages also don't ship a `yarn.lock`, so this needs a
product decision.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22502?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 15:45:48 +05:30
martmull af1b89c788 feat(server): import application logo into file storage at install (#22437)
Installed apps stored the logo as the manifest's relative path but never
imported the file, so the public-assets URL 404'd and logos went missing
in the UI for npm/tarball sources. Import the logo (best-effort — a
declared but unshipped logo is skipped, not fatal) and record it as a
first-class logoFileId on the application so it can be served reliably.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22437?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 11:47:58 +02:00
martmull 20f2e33702 feat(server): first-class logo on application registration + narrowed settings queries (#22453)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456

Application-registration list queries loaded the entire `manifest` jsonb
(potentially 100KB+/row) on every settings/marketplace list request
because display data (logo, description, author, category) only exists
inside it. This PR:

- Adds a first-class nullable `logo` column on
`applicationRegistration`, populated at every ingestion point
(`updateFromManifest`, `upsertFromCatalog`) and backfilled from
`manifest->application->>logoUrl` via a slow instance command
(self-sufficient backfill since `runDataMigration` runs before `up`).
- Backs the `logoUrl` GraphQL getter with the column (manifest fallback
for un-backfilled rows) — **GraphQL surface unchanged**.
- Narrows `findMany` / `findAll` / `findOneById` / `findOneByIdGlobal`
to an explicit scalar select that excludes `manifest` and
`oAuthClientSecretHash` (every caller audited — none needs them; OAuth
verification paths are untouched).
- Replaces `findManyListed()` with `findManyListedCatalogCards()`: a
projection query that extracts the four display strings from the
manifest in SQL (with explicit soft-delete filtering) instead of
hydrating full entities, feeding `findManyMarketplaceApps`.

Verified: typecheck, lint:diff-with-main, unit suites
(application-registration 5/5, marketplace 10/10, instance-command
31/31), migration applied via the real runner, and the migration
generator reports no pending schema changes.

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

https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22453?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 09:43:57 +00:00
Clive F f2e3eb5fb7 perf(twenty-server): browser-cache picture file responses (avatars/logos) (#22166)
Fixes #22163.

## Why

Picture file responses (`GET /file/:fileFolder/:id`,
`FileController.getFileById`)
set no `Cache-Control` header, so the browser re-fetches the same
avatar/picture
on every render. When one member's avatar appears many times on a page
(e.g. a
record table or Kanban where that member owns many rows), this fires
dozens of
parallel GETs for the identical image; the browser cancels the redundant
in-flight ones, and the server logs each client-aborted stream as
`Error streaming file from storage`.

Picture files are content-addressed by an immutable file id — changing
an avatar
or logo mints a new file id (and therefore a new URL) — so the bytes at
any given
URL never change and can be cached aggressively.

## What

- `setFileResponseHeaders` now adds
`Cache-Control: private, max-age=86400, immutable` for the picture
folders
  (`CorePicture`, `ProfilePicture`, `WorkspaceLogo`, `PersonPicture`);
  `getFileById` passes the `fileFolder` through.
- Scoped to picture folders so non-image files (attachments, tarballs,
source, …)
  are not cached past a permission/visibility change.
- `private` because files are served behind a per-workspace file token;
`immutable` + the content-addressed id gives automatic cache-busting
when the
  picture changes.

## Tests

- Unit tests for `setFileResponseHeaders`: header is set for each
picture folder,
  and not set for non-picture folders or when no folder is provided.
- Controller test asserts the header on a `CorePicture` stream response.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22166?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>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-07-03 11:43:36 +02:00
martmull 25fe66565c feat(applications): add type and options to application variables (#22157)
## Before
<img width="1452" height="709" alt="image"
src="https://github.com/user-attachments/assets/cd384ffa-cbe6-49d5-a807-ca8d580f55a9"
/>

<img width="1074" height="452" alt="image"
src="https://github.com/user-attachments/assets/720d38db-3495-4032-8831-17d24ec6a7e7"
/>

## After

<img width="1421" height="865" alt="image"
src="https://github.com/user-attachments/assets/2275c996-c895-4800-8324-2aa2ddfddd43"
/>

<img width="1348" height="870" alt="image"
src="https://github.com/user-attachments/assets/3e1a891d-6db0-4cbd-870a-2a5bbde4929d"
/>


## Summary

Adds typed application variables with optional select **options**. This
is the other half of #22059, split out from the custom-settings-tab
removal.

## Changes

- **Shared types**: `ApplicationVariable` / `ServerVariables` gain an
optional `type` (a `FieldMetadataType` subset — `TEXT`, `BOOLEAN`,
`NUMBER`, `DATE`, `SELECT`, `MULTI_SELECT`, `RAW_JSON`, `RICH_TEXT`,
`ARRAY`, …) and select `options`. New
`serializeApplicationVariableValue` /
`deserializeApplicationVariableValue` helpers convert typed values
to/from the encrypted string storage.
- **Server**: `type`/`options` columns on `applicationVariable` and
`applicationRegistrationVariable` (entities + DTOs), a fast `2-17`
instance command, manifest processing via the serialization helpers, and
a `QueryDeepPartialEntity` cast where the manifest JSON column is
persisted.
- **Frontend**: a polymorphic `SettingsApplicationVariableInput` that
renders the native `Form*` field component for each type (boolean,
number, date/date-time, select, multi-select, array, raw JSON, rich
text, text); fragment/query updates to fetch `type`/`options`.
- **SDK**: `defineApplication` validates that `SELECT`/`MULTI_SELECT`
variables declare non-empty `options` at build time (since `options` is
kept structurally optional for TypeORM/SDK compatibility).

Variables default to `TEXT` when no type is given, so existing manifests
are unaffected.

## Notes

The generated GraphQL artifacts (`type`/`options` on the variable types)
are regenerated by codegen; that change accompanies this PR.

https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22157?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 10:52:22 +02:00
Félix Malfait 9ef1af9799 fix(ai): make stream claims atomic via conditional UPDATEs with claim-or-queue send (#22481)
## Rationale

`activeStreamId` is the mutex that guarantees one live stream per thread
— but claiming it is a plain read-then-update. The resolver checks it,
then `streamAgentChat` enqueues the job **before** writing the claim.
Two racing sends both pass the check, both start jobs, and each job's
`resetStreamState` wipes the other's Redis chunk list — tokens from two
answers interleave into the visible message. The same window exists for
retry vs. send, the queue drain vs. send, and `stopAgentChatStream`,
which cleared the claim **unguarded** (`{ id, userWorkspaceId }`) and
could wipe a newer stream's claim entirely.

## Why this is the root cause, not a symptom patch

Ownership must live in the `activeStreamId` column regardless of any
locking mechanism — the queue-behind gate, the thread DTO, and stop all
read it. So the correct primitive is a single-row compare-and-set on
that column: `UPDATE … WHERE "activeStreamId" IS NULL` checked via
affected rows, claim **before** enqueue, release on enqueue failure.
Every mutation of the claim is now guarded on the observed value.

Alternatives evaluated and rejected:
- **BullMQ jobId dedup by threadId**: the driver appends a `-${v4()}`
suffix to custom ids and dedups via a non-atomic `getJobs(['waiting'])`
scan that ignores active jobs — two racing sends still run concurrently,
and it does nothing for stop/retry races.
- **`SELECT FOR UPDATE` / Redis SETNX / advisory locks**: all add a
second mechanism (transaction plumbing or a second source of truth) to
protect a single-row write that Postgres can already do atomically.

Path-specific claim predicates fall out naturally: send/drain claim with
`pendingQuestionMessageId IS NULL`, retry claims with `lastStreamError
IS NOT NULL` (and restores the error if its enqueue fails) — closing the
double-retry race for free.

## User impact

Double-send (impatient double-click, two tabs, retry racing a queued
drain) can currently garble the assistant's answer with interleaved
tokens from two model runs and strand one stream's claim. All of these
become deterministic: exactly one winner streams; the loser queues
politely.

## Test plan

- [x] New claim spec: conditional claim before enqueue, race-loser
queues, halted-backlog send queues at the back and kicks the drain
front-first, enqueue-failure releases the claim
- [x] Retry spec updated: rollback restores the prior `lastStreamError`;
guarded shapes asserted
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22481?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 10:40:03 +02:00
martmull 13be2188cc Fix non-idempotent application sync for viewSorts (subFieldName undefined vs null) (#22505)
## Summary
Successive application syncs (`yarn twenty dev --once`) kept reporting
the same viewSorts as updated, even with no manifest changes. The
manifest converter never set `subFieldName`, so the manifest-derived
flat viewSort carried `undefined` where the flat viewSort computed from
the database carried `null`. The comparator (microdiff) treats `null` vs
`undefined` as a change, producing a phantom update action on every sync
that never converges — the resulting update is a no-op on the database.

Fixes twentyhq/core-team-issues#2629

## Changes
- **Converter**: `fromViewSortManifestToUniversalFlatViewSort` now sets
`subFieldName: viewSortManifest.subFieldName ?? null`, matching how the
sibling converters (e.g. view filters) handle optional compared
properties.
- **Type definition**: added optional `subFieldName?: string` to
`ViewSortManifest` in `twenty-shared`, mirroring `ViewFilterManifest` —
this also makes sorts on composite sub-fields (e.g. `amountMicros`)
expressible in app manifests, which the entity already supports.
- **Tests**:
- Asserts `subFieldName` is `null` (not `undefined`) when omitted — the
idempotency regression.
  - Asserts `subFieldName` is passed through when provided.

## Verification
- All 12 application-manifest converter suites pass (47 tests).
- Flat-entity comparison/constants suites pass (36 tests, 21 snapshots).
- `subFieldName` was already part of the viewSort compare properties, so
no comparator/constants changes needed.

https://claude.ai/code/session_018FrD42MMQtu1UvDyiEZbSq

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22505?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
2026-07-03 07:36:18 +00:00
Abdul Rahman 429e8c4b84 fix: align email validation between front and server and roll back optimistic value on failed save (#22490)
## Summary

Inline edits of EMAILS fields could leave the UI in a misleading state:
the frontend validated with Zod's default `z.email()` while the server
used the stricter `z.regexes.unicodeEmail` pattern (which caps the local
part at 64 characters). A very long email passed client validation and
was optimistically written to the UI; the server then rejected the
mutation. An error snackbar was shown, but the field kept displaying the
unsaved value until a page reload.

## Changes

- **Single source of truth for email validation**: added a shared
`emailSchema` (`z.email({ pattern: z.regexes.unicodeEmail })`) in
`twenty-shared/utils`, now used by:
- the server-side EMAILS field validator
(`validate-emails-primary-email-subfield-or-throw.util.ts`)
  - the `EmailsFieldInput` inline editor
  - spreadsheet import validation
- **Rollback on failed save**: `useUpdateOneRecord` now restores the
optimistically updated fields in the record store when the mutation
fails, mirroring the store upsert already done in the success path.
Previously the catch block only rolled back the Apollo cache — which
stopped reverting the UI after table virtualization, since the record
store (the render source of truth) is no longer synced reactively from
the cache. The error is still rethrown, so the existing global
promise-rejection handler keeps showing the error snackbar. This fixes
the stale-value-until-reload behavior for all field types and all
callers, not just EMAILS fields.
- **Regression tests**: added unit tests for the shared schema,
including the >64-character local part case.

Fixes [sonarly issue
#54034](https://sonarly.com/issue/54034?share=eyJ0aWQiOjMzMCwidHlwIjoiYnVnIiwicmlkIjo1NDAzNCwiZXhwIjoxNzgzNTI1OTQzfQ.9e7639034a677301512fceeafab764b1)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22490?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 09:18:09 +02:00
Félix Malfait 5057f14df9 test(ai): pin the queue-updated event that now follows stream-error on the failure path (#22500)
## Rationale

main's `server-test` is red: 4 error-path tests in
`stream-agent-chat.job.spec.ts` fail. #22494 made the stream failure
path publish `queue-updated` **after** the terminal `stream-error` (so
background tabs refetch the persisted partial transcript), but these
specs still asserted `stream-error` is the *last* published event.
Classic squash-merge semantic conflict — #22494's branch predated the
spec assertions, both were green in isolation.

## Why this is the right fix (not patching a symptom)

The job behavior is the intended one from #22494; the specs encode the
old contract. Rather than loosening the assertions to "a stream-error
was published somewhere", this pins the full intended terminal sequence
— `stream-error` followed by `queue-updated` — so the convergence event
itself is now regression-tested on all four failure paths (mid-stream
provider error, setup rejection, persistence failure, missing
workspace).

## Impact

Unblocks `server-test` / `ci-server-status-check` for every open PR
(including #22498, which is needed to fix the dev-cluster migration
incident). Test-only change.

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22500?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 23:32:49 +02:00
Félix Malfait 027a098099 fix(ai): fully roll back a question answer when the resume enqueue fails (#22492)
## Rationale

`answerAgentChatQuestion` is a three-step transaction without the
transaction: resolve the question (flip tool part to `answered`, clear
`pendingQuestionMessageId`, claim the stream), then enqueue the resume
job. If the **enqueue fails**, the catch restores only `activeStreamId`.
What's left behind: tool part says `answered`,
`pendingQuestionMessageId` is `null`, no job will ever run. The client's
own error handler rolls its card back to *pending* — so the user sees an
answerable question whose re-submission deterministically throws
`QUESTION_NOT_PENDING`. The turn is stuck and state is divergent on
three surfaces (DB part, DB thread, client).

## Why this is the root cause, not a symptom patch

The failure path was rolling back one of three writes. This makes the
rollback total and **exact**: `resolvePendingQuestion` now returns the
part's precise previous `toolOutput` (no reconstruction guesswork —
question tools can carry arbitrary output fields), and the failure path
restores the part verbatim plus the thread's pending-question state,
guarded on the observed streamId so a competing claim is never
clobbered. After rollback, server and client agree again: the question
is pending, answering retries cleanly.

The audit's alternative — forward recovery (keep the answers, mark the
turn interrupted, resume via Retry) — has nicer UX in isolation but
contradicts the client's existing rollback-to-pending behavior; matching
the established contract wins until the client changes.

## User impact

A transient Redis/queue hiccup at answer time currently bricks the
question turn permanently. With this, the user sees the question again
and can just re-answer.

## Test plan

- [ ] CI green
- [ ] Manual: fail the enqueue (kill Redis briefly) at answer time →
question card returns to pending, re-answer succeeds

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22492?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 21:33:20 +02:00
Félix Malfait 087bee0036 fix(ai): notify all tabs when a pending question is answered (#22491)
## Rationale

`resolvePendingQuestion` updates the question tool-part to `answered`
and re-claims the thread — but publishes **nothing**. The answering tab
converges via a local browser event; every other tab keeps rendering the
question card as interactive until the resumed stream's first chunk
happens to arrive. A second tab (or teammate view on shared context) can
attempt to answer an already-answered question and hit a confusing
`QUESTION_NOT_PENDING` error.

## Why this is the root cause, not a symptom patch

Answering a question is a state transition every subscriber cares about
— exactly like queue promotion, message persistence, and stream errors,
all of which publish. This transition just never did. The fix publishes
the existing refetch-trigger event (`queue-updated`, which every tab
already handles by refetching messages + thread state) right after
resolution — no new event type, no new client code path, consistent by
construction with how every other transition converges tabs. A dedicated
`question-answered` event carrying the answers would save one refetch
round-trip; the audit's verdict was that's over-engineering for a rare
interaction.

Publishing *before* the resume-enqueue is deliberate: even if the
enqueue fails, the question **is** answered server-side, and tabs should
reflect server truth.

## User impact

Second tabs stop offering an interactive question that will error when
submitted; everyone sees the answered state within a refetch instead of
whenever the stream resumes.

## Test plan

- [ ] CI green
- [ ] Manual: two tabs on one thread, answer the question in tab A → tab
B's card flips to answered without interaction

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22491?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 21:30:34 +02:00
Félix Malfait d1a4e250cf fix(ai): converge every tab's transcript when a stream fails with partial output (#22494)
## Rationale

When a turn fails after emitting output, the partial assistant message
**is persisted** — but only the success path publishes the event
(`message-persisted`) that makes other tabs refetch. On failure, tabs
that weren't watching the live stream keep a stale transcript until
manual reload. (Error *visibility* itself already works — `stream-error`
reaches every subscribed tab — the gap is purely the
persisted-transcript sync. The original gap-analysis framing of this as
an error-visibility problem was wrong; this is the corrected scope.)

## Why this is the root cause, not a symptom patch

Turn settlement should converge subscribers regardless of *how* the turn
settled — success and failure both persist state that tabs need. The
failure path now publishes the same refetch-trigger event the rest of
the lifecycle uses, right after the terminal `stream-error`. No new
event type, no client changes: the existing guarded replay
(`firstLiveSeq === null`) already ensures tabs with a live view keep
their in-place error rendering while background tabs pick up the
persisted partial message and error state.

## User impact

Open the same thread in two tabs, have the turn die mid-answer in one:
the other tab currently shows the conversation frozen pre-turn until
reload. Now both converge to the persisted partial output plus the
failed-turn state within one refetch.

## Test plan

- [ ] CI green
- [ ] Manual: two tabs, kill the provider mid-stream in tab A → tab B
shows the partial message + error without reload

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22494?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 21:29:56 +02:00
Félix Malfait a505ed3245 feat(ai): typed CONTEXT_WINDOW_EXCEEDED error that hides the pointless Retry (#22488)
## Rationale

When message pruning can't fit the conversation into the model's context
window, `chat-execution.service.ts` throws a **raw `Error`**.
`mapErrorToStreamError` classifies it as generic
`STREAM_EXECUTION_FAILED`, so the client renders a standard failure with
a **Retry button that deterministically fails again** — the conversation
doesn't get shorter by retrying. Users loop on Retry against a
permanently-failing thread.

## Why this is the root cause, not a symptom patch

The failure is *terminal for the thread by construction*, and the error
channel already distinguishes terminal-vs-retryable via typed
`AiExceptionCode`s — this failure just never got one. Adding
`CONTEXT_WINDOW_EXCEEDED` (typed exception → `UserInputError` mapping
instead of a 500 → both error surfaces render the start-a-new-thread
message without `onRetry`) puts it on the same rails as
`API_KEY_NOT_CONFIGURED` and the other special-cased codes. Both
frontend error surfaces route through `AiChatErrorRenderer`, so one case
covers the in-message and under-list renderings.

The deeper endgame (auto-summarize/compact older turns so threads never
brick) is a multi-week feature — and this typed error remains necessary
even then, as its terminal fallback.

## User impact

Instead of an opaque error and a Retry that never works, users hitting
the context limit get told exactly what happened and what to do (start a
new thread), and monitoring stops counting a user-condition as a server
error.

## Test plan

- [ ] CI green
- [ ] Manual: fill a thread past the model limit → typed message, no
Retry on either error surface

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22488?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 21:26:41 +02:00
Félix Malfait 56a6f9419b chore(server): fix stream-agent-chat job spec formatting breaking main's lint (#22493)
The stacked merges of #22479 and #22480 left
`stream-agent-chat.job.spec.ts` on main failing the `oxfmt --check` gate
(2 stray blank lines), which currently fails `server-lint-typecheck` on
**every** open PR's merge ref. Two-line whitespace fix, no behavior
change. Merging this first unblocks the rest of the queue.

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22493?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 21:25:37 +02:00
Félix Malfait 1feb41eb64 perf(sse): drop the per-workspace lock around atomic activeStreams set operations (#22476)
## Rationale

Every event-stream create/destroy in a workspace serializes on a single
cache lock (`workspace:<id>:activeStreams`) just to run
`setAdd`/`setRemove`. On Redis those are native `SADD`/`SREM` — already
atomic (`cache-storage.service.ts:79-109`) — so the lock adds zero
correctness. What it does add: 100ms lock-retry polling under
concurrency, and a hard 5s ceiling (50 retries × 100ms,
`cache-lock.service.ts:36`) after which stream creation **fails** with
`Failed to acquire lock`.

**Production evidence (Sentry):**
[TWENTY-SERVER-H3W](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-H3W)
(125 events, 16 users) server-side and
[TWENTY-FRONT-6MM](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-6MM)
(65 users) client-side — spiky, consistent with deploy-driven reconnect
storms in large workspaces where every tab reconnects at once and queues
on one lock.

## Why this is the root cause, not a symptom patch

The failure isn't "the lock timeout is too short" (raising it would just
trade errors for latency) — it's that the critical section doesn't
exist. A set-membership add/remove of a single element has no
read-modify-write window on Redis. The lock that **is** legitimate stays
untouched: `@WithLock` on `addQuery`/`removeQuery`, which genuinely
read-modify-write the JSON queries map.

The non-Redis fallback of `setAdd` is read-modify-write, but that path
only serves single-node dev/test setups where the worst case is a
transiently miscounted metrics gauge
(`twenty_event_streams_live_total`), not a correctness issue — the
authoritative per-stream state lives in its own key.

## User impact

During reconnect storms (deploys, network blips) in busy workspaces,
tabs no longer randomly fail to establish their event stream — which
previously meant no live updates for that tab and, through the strict
client error path, a crash of the listener sync loop (fixed separately
in #22475). Also removes up-to-5s of serialized queueing latency per
workspace on every connect wave.

## Test plan

- [x] Verified `setAdd`/`setRemove` are native `SADD`/`SREM` + `EXPIRE`
on the Redis path
- [x] No callers depend on the lock's ordering (checked
`engine/subscriptions` call sites)
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22476?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 21:11:27 +02:00
Félix Malfait 3b76ec528f fix(ai): route missing-workspace stream failures through the standard error path (#22480)
## Rationale

When `StreamAgentChatJob` can't find the workspace, it publishes a
transient `stream-error` event and **returns before the try/finally
exists** (`stream-agent-chat.job.ts`). Consequences on main:

- `activeStreamId` is never cleared → every subsequent send in that
thread queues behind a dead claim, forever;
- no `lastStreamError` is persisted → nothing renders after a reload,
and Retry has nothing to retry;
- nothing throws → **zero telemetry**. Sentry confirms: the "Workspace
not found" issues that exist are all auth/Stripe paths — this path fails
in complete silence.

## Why this is the root cause, not a symptom patch

The job's catch/finally already implement the correct failure contract
for *every other* error: persist a typed `lastStreamError`, publish the
typed event, release the claim guarded on the observed streamId. The bug
is that one code path bypasses that contract via an early return. The
fix removes the bypass — the lookup moves inside the `try` and throws a
typed `AiException(WORKSPACE_NOT_FOUND)` — rather than duplicating
cleanup in the early-return branch (which would be the symptom patch,
and would drift the next time the contract changes).

The alternative "prevent the job from existing when the workspace is
gone" isn't achievable: workspace deletion between enqueue and pickup is
an inherent race, so the job must handle it regardless.

## User impact

A workspace deleted/deactivated mid-flight currently bricks the thread
silently (the user just sees sends vanish into a queue). With this, the
failure is visible (typed error message), recoverable (standard
failed-turn state), and observable (real exception in monitoring).

## Stack

Based on #22479 (spec harness) — it extends the same spec file with the
regression test. `WORKSPACE_NOT_FOUND` is a TypeScript enum member, not
a GraphQL schema change: no client-sdk regeneration needed.

## Test plan

- [x] Regression test: missing workspace → typed rejection,
`lastStreamError` persisted, terminal event published, claim released
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22480?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 21:11:02 +02:00
Félix Malfait ca8ab32253 fix(ai): delete the Redis chunk list on credits-exhausted terminal events (#22477)
## Rationale

The AI chat stream keeps every published chunk in a Redis list
(`agent-chat-stream-chunks:<threadId>`, 1h TTL) so late subscribers can
catch up. On `message-persisted` the list is deleted. On
`credits-exhausted` — the *other* successful terminal event — it wasn't.
Any reload/refetch within the TTL replayed the orphaned chunks, flipping
the thread into a "streaming" state that no terminal event ever closes:
an endless spinner until the user sends another message.

**Production evidence (Sentry):** `Billing Credits Exhausted` fired for
**290 users / 937 events in 90 days**, ongoing
([TWENTY-SERVER-G42](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-G42))
— every one of those users who reloads the chat within an hour hits
this.

## Why this is the root cause, not a symptom patch

The chunk list's lifecycle contract is "cleared when the turn settles".
`credits-exhausted` resolves the job successfully **without** persisting
a `lastStreamError`, so unlike `stream-error` there is no persisted
terminator for catchup to replay after the chunks — the replay is
unconditionally un-closeable. Deleting on both settle events restores
the contract exactly where it's already enforced for
`message-persisted`. `stream-error` deliberately keeps the list: the
persisted error acts as the replay terminator, letting a reloading
client still see the failed turn's partial output.

## User impact

Users who hit their billing cap mid-answer (~100/month) no longer come
back to a permanently spinning thread after a reload — they see the
settled conversation and the billing state.

## Test plan

- [x] Unit spec: chunk accumulation with 1-based seq, deletion on both
terminal events (`it.each`), retention on `stream-error`
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22477?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 21:05:19 +02:00
Félix Malfait 6ac8ebcd18 test(ai): pin StreamAgentChatJob stream lifecycle with a reusable spec harness (#22479)
## Rationale

`StreamAgentChatJob` is the most failure-sensitive path in the AI chat
stack — it coordinates the model stream, Redis event publishing, message
persistence, the thread's stream claim, and the queued-message flush —
and it had **zero unit coverage**. Both historical hangs lived here:

- a throw before the model stream merges bypassed `onFinish` entirely
and hung the job until the **10-minute BullMQ lock** expired (thread
stuck the whole time);
- a throw inside `onFinish` (persistence failure) left trailing chunks
published with **no terminal event** — the client spinner ran forever.

Production still shows this class is live: `Query read timeout` thrown
from inside `handleStreamFinish`
([TWENTY-SERVER-GV7](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-GV7)).

## Why this shape, not something else

Tests-only PR, zero production risk. The fake chat stream mirrors the
one AI SDK contract the job's coordination depends on — verified against
the installed `ai@6.0.97` dist: `toUIMessageStream` converts mid-stream
errors into error parts and **always** fires `onFinish` when the stream
ends (`handleUIMessageStreamFinish` invokes it from both `flush()` and
`cancel()`). Pinning that contract in the fake means a future SDK
upgrade that breaks it fails these tests instead of production. Six
tests pin current behavior: chunk ordering with `message-persisted`
last, opaque error-chunk suppression, mid-stream failure persisting
`lastStreamError` + releasing the claim, the two hang regressions above,
and cancel skipping the queued flush.

Three sibling PRs extend this exact spec file (missing-workspace
routing, halted queue, and — later — auto-retry), which is why the
harness lands first.

## User impact

None directly; it makes the two worst historical user-facing hangs
(10-minute dead thread, infinite spinner) regression-proof before the
stuck-state fix series touches this code.

## Test plan

- [x] 6 unit tests, no production code changed
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22479?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 21:01:32 +02:00
Félix Malfait a75414a05e fix(sse): treat expected event-stream coordination errors as 403s and never crash the sync loop (#22475)
## Rationale

`NOT_AUTHORIZED` and `EVENT_STREAM_ALREADY_EXISTS` on the event-stream
mutations are **expected coordination outcomes** — the frontend
explicitly recognizes both (`isGracefullyHandledEventStreamError`) and
recovers by recreating its stream. But `EventStreamExceptionFilter`
rethrows them as `InternalServerError`, which (a) Sentry captures on
every single occurrence, and (b) counts as a 500 in operation metrics.

**Production evidence (Sentry):** this turned a March client-regression
into a 119,510-event / 2,607-user flood
([TWENTY-SERVER-FP3](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-FP3),
plus FP0 at ~29k) that buried real errors. The trigger was fixed back
then, but the amplifier — error-level capture of an expected signal — is
still in place, and a residual trickle still fires today.

Second defect, client side: for any *non-graceful* server error (e.g. a
lock-acquisition timeout), `SSEQuerySubscribeEffect.handleError`
**threw** from inside a debounced callback — an unhandled rejection
([TWENTY-FRONT-62M](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-62M),
233 users; [6MM](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-6MM),
65 users) that left the tab's query listeners permanently out of sync
with the server (no more live updates until reload).

## Why this is the root cause, not a symptom patch

The protocol design already says these are recoverable
client-coordination signals — the bug is purely that the server encodes
them with 500 semantics and the client punishes unexpected errors by
giving up instead of resetting. This PR aligns both ends with the
existing design rather than adding new machinery:

- Server: `ForbiddenError` (403) with the same `subCode` — the client's
graceful check already accepts `code === 'FORBIDDEN'`, so this is
compatible by construction; `FORBIDDEN` is already in
`graphQLErrorCodesToFilter`, so monitoring capture stops with no new
filtering logic.
- Client: the non-graceful path now does exactly what the graceful path
does (reset listeners + recreate stream) and *additionally* reports the
unexpected error — visibility without a crash.

## User impact

Tabs that hit any event-stream error now always self-heal back to live
updates instead of silently going stale until reload (~300 users hit the
crash path over 90d). On the ops side: expected coordination noise
leaves error monitoring, and 403/500 metrics become truthful.

## Test plan

- [x] Behavior preserved for graceful codes (same reset path, client
check already includes FORBIDDEN)
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22475?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 20:59:42 +02:00
neo773 6f64be5751 Gate and meter email group: enterprise license (self-host) + credits (cloud) (#22390)
Email group (marketing email) was gated only by the
`IS_EMAIL_GROUP_ENABLED` feature flag with no server-side enforcement.
This adds real gating, split by deployment:

- **Self-hosted** (`IS_BILLING_ENABLED=false`): requires a valid
Enterprise plan.
- **Cloud** (billing enabled): metered by credits, mirroring the
existing AI credit system. Priced on AWS SES cost ($0.10/1,000 outbound)
× 3 margin = $0.30/1,000 (300 micro-credits/email). Pre-flight blocks
sends when out of credits; each email is charged after SES accepts it,
in the async send job — matching how AWS bills us (no refund on bounce).

Enforcement is applied at every email group resolver, and denials
surface as proper client errors through a dedicated GraphQL exception
filter.
2026-07-02 15:14:49 +00:00
Félix Malfait a28887bba6 feat(server): workspace opt-out of root-domain directory listing (#22423)
## What

Lets a workspace opt out of being surfaced in the multi-workspace
root-domain (app.twenty.com) picker via **email-domain discovery**.

Adds `isDirectoryListingEnabled` (default `true`) on the workspace. When
`false`, the workspace is filtered out of the approved-access-domain
branch of `findAvailableWorkspacesByEmail`, so a user whose email domain
matches an approved access domain no longer sees the workspace in the
sign-up picker.

## Scope of the opt-out (deliberately narrow)

The filter is applied **only** to the approved-access-domain discovery
source:

- **Members** (`availableWorkspacesForSignIn`) — never filtered; they
keep access.
- **Explicit invitations** — never filtered; the intent is one-to-one.
- **Approved-access-domain discovery** — the only "listing" source,
gated by the flag.

A hidden workspace stays fully reachable by members and invited users
via the direct workspace subdomain; it just isn't advertised in the
global picker.

> Open question for review: do we also want a stronger mode that hides
the workspace from the root-domain picker even for existing members
(forcing them to use the subdomain directly)? That would additionally
filter the member/invitation sources and is a larger behavior change —
not included here.

## Changes

**Backend**
- `workspace.entity.ts` — new `isDirectoryListingEnabled` column
(`@Field`, default `true`).
- `user-workspace.service.ts` — filter the approved-access-domain branch
on the flag.
- `update-workspace-input.ts` — expose the field on `updateWorkspace`.
- `workspace.service.ts` — `PermissionFlagType.SECURITY` (same as the
other discovery/security toggles).
- Fast instance command adding the column (default `true`, so no
existing workspace is hidden).

**Frontend**
- Settings > Security: a **"List in workspace directory"** toggle (shown
only in multi-workspace mode) that flips the flag via `updateWorkspace`,
mirroring the existing `isInternalMessagesImportEnabled` toggle.
- Threaded the field through the current-user fragment,
`CurrentWorkspace` type, and mock data.
- Regenerated the metadata + client-sdk GraphQL types
(`generated-metadata`, `twenty-client-sdk/.../generated`) — generated
against a server booted from this branch.

## Verification

- `tsgo` typecheck: 0 errors. `oxlint`: 0/0. `oxfmt`: clean.
- Codegen diff verified to contain **only** the new field (no unrelated
drift).
- Tests not run locally; CI covers unit/integration + the
codegen/migration freshness checks.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-07-02 16:55:40 +02:00
Etienne 29e48e16ba [Breaking change] fix: make pageLayout type field required (#22450)
fixes https://github.com/twentyhq/twenty/issues/22251


**Summary**
- Fixes #22251 — NavigationMenuItem with type PAGE_LAYOUT returns 404
"Off track" for custom standalone pages
- Makes type a required field in PageLayoutManifest instead of relying
on a fallback default to RECORD_PAGE
- Adds PageLayoutType enum to twenty-shared and exports it from the SDK
for app developers
- Adds build-time validation in definePageLayout to reject manifests
missing type
- Updates the CLI add command to prompt users to select a page layout
type interactively

**Root cause**
When definePageLayout was called without type, the manifest converter
defaulted to RECORD_PAGE. The frontend route guard at /page/:id then
rejected it (only STANDALONE_PAGE is allowed), producing a 404.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22450?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 15:40:04 +02:00
neo773 7cf8b58f1b feat(emailing): local unsubscribe URL + full-content log driver (#22412)
In LOG mode, emit a working
http://unsubscribe.<subdomain>.localhost/emailing/unsubscribe link (from
SERVER_URL + workspace subdomain) instead of empty content, and log the
full text/html body + List-Unsubscribe header so the flow is inspectable
locally. buildUnsubscribeUrls now takes a full base URL (field renamed
httpsUrl -> webUrl).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22412?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 13:36:57 +00:00
Félix Malfait 4aaf171d63 feat(ai): add ask_questions interactive clarifying-question tool (#22346)
## What & why

Adds an `ask_questions` tool that lets the in-app **Ask AI** assistant
**pause a turn to ask the user one or more multiple-choice questions**
(per the [Figma
design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=105959-117153))
and resume once answered — instead of guessing on
ambiguous/consequential decisions.

The tool is **harness-only**: an interactive question UI is meaningless
without a user to answer it, so it must be absent from MCP and from
head-less workflow agents.

## Design — true tool-result resume (not a synthetic user message)

The user's answer is a **structured tool result bound to the
`toolCallId`**, and the **same agent turn resumes** — exactly how
Anthropic (`tool_result` by `tool_use_id`) and OpenAI
(`function_call_output`) model human-in-the-loop.

The naive form of this (leave the tool call in `input-available` to mean
"pending") is **impossible** here: `finalizeDanglingToolParts` rewrites
`input-available` → `output-error` ("Tool execution was interrupted") on
both the persist path (`addMessage`) and the model-reload path
(`chat-execution.service.ts`). That util is a load-bearing safety net,
so weakening it is the wrong move.

Instead:

- `ask_questions` is an **inline, chat-only tool with an `execute` that
returns a `status: 'pending'` result immediately**, so the tool part is
always `output-available` and **immune to `finalizeDanglingToolParts`**.
`stopWhen(hasToolCall('ask_questions'))` halts the turn right after the
call (the model never sees the placeholder).
- A nullable **`thread.pendingQuestionMessageId`** marker records that a
turn is awaiting an answer.
- The new **`answerAgentChatQuestion`** mutation atomically *claims* the
question (clears the marker, marks the thread streaming), **writes the
answer onto the same tool part** (`status: 'answered'`), and
**re-enqueues the turn via the existing `existingTurnId` plumbing**
(`isResume` bypasses the per-turn dedup guard). On resume
`finalizeDanglingToolParts` leaves the `output-available` part untouched
and `convertToModelMessages` emits `assistant(tool_use)` +
`tool_result(answers)`, so the model continues.

This achieves the platform-aligned semantics **without** weakening the
finalize safety net or inventing a fragile new part state.

### Meets the two requirements

- **Survives refresh, scoped per-thread** — the pending state is a
normal persisted `output-available` part + the thread marker; the
frontend card is derived per-thread from the loaded messages, so it
re-appears on reload and only on its own thread.
- **Takes priority over the queue** — a unified `isBlocked =
activeStreamId || pendingQuestionMessageId` gate is applied in both
`sendChatMessage` (new messages queue) and `flushNextQueuedMessage` (the
drain). The queue cannot unpile until the question is answered and the
resumed turn completes.

### Harness-only by construction

`ask_questions` is added **only** to the chat's inline `activeTools`
(like `learn_tools`/`execute_tool`/`load_skills`). It never enters the
tool registry/catalog, so it is invisible to MCP and to workflow agents
— no `MCP_EXCLUDED_TOOL_NAMES` entry needed.

## UX

While a question is pending, the **composer is replaced by the question
card** (matching the Figma): question title + pager (`1/2`), numbered
option rows (`IconSquareNumber*`) with per-option info-icon descriptions
and a "Recommended" badge, and the normal composer as the free-text
fallback ("Type anything to do differently."). The transcript shows a
compact "Asking questions…" status line that becomes an answered
summary.

## Changes

**twenty-shared**
- `ai/types/AskQuestionsToolTypes.ts` —
`AskQuestionItem/Option/Answer/Result`, `ASK_QUESTIONS_TOOL_NAME`.

**twenty-server**
- `ai-chat/tools/ask-questions.tool.ts` — inline tool factory
(pending-result `execute`, zod schema, 1–4 questions × 2–4 options).
- `chat-execution.service.ts` — add to `activeTools` +
`preloadedToolNames`; `hasToolCall` in `stopWhen`.
- `chat-system-prompts.const.ts` — when-to-use guidance.
- `entities/agent-chat-thread.entity.ts` — `pendingQuestionMessageId`
column.
- `stream-agent-chat.job.ts` — set the marker on a question pause;
bypass the dedup guard on resume; suppress the no-text warning for
question pauses.
- `agent-chat-streaming.service.ts` — gate `flushNextQueuedMessage`;
`enqueueResumeStream`.
- `agent-chat.resolver.ts` — gate `sendChatMessage`;
`answerAgentChatQuestion` mutation.
- `agent-chat.service.ts` — `resolvePendingQuestion` (atomic claim +
write answer).
- `dtos/agent-chat-question-answer.input.ts`, `ai.exception.ts`
(`QUESTION_NOT_PENDING`), `utils/find-pending-question-part.util.ts`.

**twenty-front**
- `components/AiChatQuestionCard.tsx` — the interactive card (matches
Figma tokens) + `__stories__/AiChatQuestionCard.stories.tsx`.
- `components/AiChatEditorSection.tsx` — swap the composer for the card
while pending.
- `components/AiChatQuestionStatusRenderer.tsx` + branch in
`AiChatAssistantMessageRenderer.tsx`.
- `states/selectors/agentChatPendingQuestionComponentSelector.ts`,
`types/AgentChatPendingQuestion.ts`.
- `hooks/useSubmitQuestionAnswer.ts` + `utils/markQuestionAnswered.ts`
(optimistic) + `graphql/mutations/answerAgentChatQuestion.ts`.

A design doc lives at
`packages/twenty-server/docs/ASK_USER_QUESTION_TOOL_PLAN.md`.

## Migration

Adds a nullable `pendingQuestionMessageId` (uuid) column to
`core.agentChatThread`. Needs a generated **fast instance command**
(`database:migrate:generate --name addThreadPendingQuestion --type
fast`) — see "Verification status".

## Tests

- Server: `ask-questions.tool.spec.ts` (pending echo + schema bounds),
`find-pending-question-part.util.spec.ts`.
- Front: `markQuestionAnswered.test.ts`, plus the Storybook story.

## Verification status (please read)

This branch was authored in an environment where the monorepo `yarn
install` repeatedly failed on transient TLS resets from the package
registry, so I could **not** locally run the mechanical gates. The logic
was reviewed by hand and the `ai@6.0.97` exports used (`hasToolCall`,
`stepCountIs`, `generateId`) were confirmed against the package's type
defs. Still **TODO** (will rely on CI / a follow-up once deps install):

- [ ] `nx run twenty-shared:generateBarrels` (the `ai/index.ts` export
was added by hand; regen to reconcile)
- [ ] `nx run twenty-front:graphql:generate` (new mutation + input type)
- [ ] generate the fast instance command (migration) for the new column
- [ ] `typecheck` + `lint:diff-with-main` (front + server) — expect
minor import-ordering autofixes
- [ ] run the unit tests

**Screenshots:** reproducing the live flow needs an AI provider API key
(to get the model to actually call `ask_questions`), which isn't
available here. The card can be screenshotted from its **Storybook
story** (`AiChatQuestionCard.stories.tsx`) with no API key — I'll add
that image once deps install, or a reviewer can run `nx storybook
twenty-front`.

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

https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22346?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 15:32:18 +02:00
neo773 198f1e4916 feat(emailing): forward SES events to Tatami Monitor (#22407)
Add AwsSesObservabilityService, which adds an SNS event destination to
each workspace's SES configuration set (gated on TATAMI_SNS_TOPIC_ARN)
so deliverability events reach Tatami. Tag sends with tenant_id for
per-workspace breakdowns.

Requires to be merged https://github.com/twentyhq/twenty-infra/pull/765

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22407?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 18:55:25 +05:30
Félix Malfait d709467902 feat(ai): surface AI chat stream failures through one typed error channel (#22434)
## Context

Investigating a report where the AI chat showed only a `...` spinner
while the network response clearly contained `No AI models are
available`. Root cause: terminal stream failures reach the client on
**two mismatched channels**.

| Representation | Persisted (survives reload) | Rendered by client |
|---|---|---|
| AI-SDK `error` chunk (inside `stream-chunk`) |  RPUSH'd to Redis | 
dropped by `readUIMessageStream` (no message part, no error state) |
| typed `stream-error` event |  never persisted |  sets the error atom
|

Live, the `stream-error` event renders. But on reload,
`chatStreamCatchupChunks` replays only the persisted **error chunk** —
which the reducer discards — and the streaming indicator never clears.

## Change

Collapse to a single typed error contract:

- **Suppress the opaque `error` chunk** in the stream job; every failure
is surfaced through the typed `stream-error` event. Errors are mapped
via `mapErrorToStreamError` so an `AiException` keeps its
`AiExceptionCode` (e.g. `API_KEY_NOT_CONFIGURED` → the existing "AI not
configured" banner) instead of leaking a raw string.
- **Persist the terminal error** next to the accumulated chunks and
expose it as an explicit `error { code message }` field on
`ChatStreamCatchupChunks`, so a client catching up after a reload
recovers it — no dependency on the AI SDK's internal chunk shape.
- **Reset per-thread stream state at job start**, so a failed turn's
leftover chunks/error never replay on the next stream.
- **Client replays the catchup error** as a terminal `stream-error`
event, which clears the streaming indicator and renders the error (fixes
the infinite spinner on a stream that ended in error).

## Notes

- `ChatStreamError` is a new metadata GraphQL type; generated types
(twenty-front metadata + client-sdk) were hand-updated to keep the tree
consistent and will be reconciled by CI's `graphql:generate` check if
anything differs.
- Server unit test added for the error mapping. No schema/DB migration.

## Test plan

- [ ] With no AI provider configured, send a chat message → error
renders immediately (not a spinner).
- [ ] Reload the thread → the error still renders (recovered from
catchup), indicator not spinning.
- [ ] Configure a provider and send again → normal streaming; no stale
error from the previous failed turn.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22434?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 14:50:57 +02:00
Paul Rastoin 47689e676b feat(server): improve traceability of flat-entity map mutation errors (#22396)
## Context

cc @rashad 

Twenty applies metadata changes optimistically to in-memory *flat entity
maps* before persisting them. The utils that mutate these maps throw
`FlatEntityMapsException` on invariant violations, which surface in
Sentry (e.g. during `InstallApplication`) as a **hardcoded, generic
message with no identifying data**:

```
GraphQLError: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists
```

There was no way to know *which* entity collided — making triage
impossible.

## What this does (two layers)

**Layer 1 — leaf utils emit identifiers**
- `FlatEntityMapsException` gains an optional structured `context`
(`universalIdentifier` / `id` / `applicationId` / `metadataName` /
`relatedMetadataName` / `operation`), read by the Sentry driver's
existing `'context' in exception` → `setExtra` channel.
- All **9 leaf throw sites** append their in-scope identifiers to the
message **and** populate `context`.

**Propagation — context survives the re-wraps**
- On the install path the collision throws in the (unwrapped)
`compute()` step, so the raw exception + context reaches app-sync
intact.
- For the run/build-phase paths, the migration runner and
build-orchestrator re-wraps copy only `.message`; they now also
**forward `context`** so structured data survives there too.

**Layer 2 — human installation error**
- `synchronizeFromManifest` catches flat-entity failures, resolves the
offending `universalIdentifier` to a manifest **object/field label**,
and rethrows `ApplicationException(APPLICATION_INSTALLATION_FAILED)`
with a safe, human `userFriendlyMessage`.
- The leaf `userFriendlyMessage` stays `STANDARD_ERROR_MESSAGE` — the
detailed message never leaks to end users.
- `APPLICATION_INSTALLATION_FAILED` surfaces with the dedicated
`ErrorCode.APPLICATION_INSTALLATION_FAILED` GraphQL code (mirroring the
workspace-migration runner formatter), not `INTERNAL_SERVER_ERROR`.

### Result — client-facing GraphQL error envelope
```json
{
  "extensions": {
    "code": "APPLICATION_INSTALLATION_FAILED",
    "subCode": "APPLICATION_INSTALLATION_FAILED",
    "userFriendlyMessage": "We couldn't install \"Test Application\". Its Invoice could not be applied to your workspace."
  },
  "message": "Installing application 'Test Application' failed [object: Invoice]: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists (universalIdentifier: ...)",
  "name": "GraphQLError"
}
```

## Where the identifier shows up (not just Sentry)

The offending `universalIdentifier` reaches every consumer, not only
Sentry:
- **Sentry (server):** structured `context` extras + the enriched
message (fingerprinted by `code`, so no issue fragmentation).
- **GraphQL response `message`:** un-masked (no `useMaskedErrors`; the
error-handler hook passes `BaseGraphQLError` through as-is), so it
travels over the wire.
- **App-author SDK/CLI terminal:** `twenty-sdk` captures
`errors[0].message`; for this error `formatManifestValidationErrors`
returns `null` (no `extensions.errors`/`summary`), so the orchestrator
falls back to printing the full message, e.g.:
  ```
✗ Sync failed with error: Installing application 'X' failed [object:
Invoice]: … already exists (universalIdentifier: b1b2c3d4-…)
ℹ Hint: a metadata conflict was detected. Preview the plan with `yarn
twenty dev --once --dry-run`; …
  ```
The `already exists` / `universalidentifier` substrings also trigger
`getSyncErrorRecoveryHint`, so the author gets an actionable next step.
- **End-user (CRM UI):** only the safe rendered `userFriendlyMessage`
(no UUIDs).

## Design note
`userFriendlyMessage` behaviour of the leaf exceptions is intentionally
unchanged (guardrail). Layer 2 resolves labels for **objects and
fields** (the bulk of metadata); other manifest entity kinds fall back
to an app-name-only human message to avoid brittle manifest-walking —
easy to extend. A future first-class option would be structured
`extensions` (like `METADATA_VALIDATION_FAILED`) + a dedicated SDK
formatter; deferred since the message path already surfaces the detail
in the terminal.

## Tests
- **Unit:** existing through-mutation + runner-exception specs still
pass (they assert on exception **code**, not message). Added a spec for
the enrichment util.
- **Response-format snapshot (verified, green):**
`application-exception-filter.spec.ts` runs the exception filter and
snapshots the exact client-facing GraphQL error envelope shown above.
- **Integration:**
`failing-sync-application-flat-entity-map-conflict.integration-spec.ts`
syncs a manifest whose two objects share a `universalIdentifier`
(collision during manifest map build, before validation) and snapshots
the GraphQL error response via
`expectOneNotInternalServerErrorSnapshot`.
- ⚠️ The integration `.snap` was authored from the identical
deterministic path (verified by the filter unit snapshot) because the
integration suite couldn't be executed in the authoring sandbox. Please
regenerate/confirm with `nx test:integration:with-db-reset` (or `-u`) in
a seeded env.

## Status
Draft — opening for review.
2026-07-02 12:39:48 +00:00
Félix Malfait 1cba0cdf49 fix(server): apply row-level security predicates to API key and application principals (#22456)
## What

Row-level security predicates were only resolved for **user**
principals. For API key and application principals, object-level and
field-level permissions were resolved (via
`resolveRolePermissionConfig`), but the row-level predicate role was
left `undefined`, so:

- on the read path, `buildRowLevelPermissionRecordFilter` returned
`null` and no `WHERE` clause was added; and
- on the write path, `validateRLSPredicatesForRecords` returned early
and skipped post-write validation.

The result was that a role carrying row-level predicates constrained
users as intended, but the same role applied to an API key or installed
application was subject only to its object/field permissions — not its
row filters.

## Changes

- Add `resolveRoleIdFromAuthContext`, a single helper that resolves the
effective role id for user, API key, and application principals.
- Use it in `applyRowLevelPermissionPredicates` (read) and
`validateRLSPredicatesForRecords` (write) so row-level predicates are
enforced for all principal types.
- Thread `apiKeyRoleMap` through `WorkspaceInternalContext` (it was
already available on the ORM workspace context).
- Refactor `resolveRolePermissionConfig` to reuse the same helper, so
object-, field-, and row-level checks all resolve the role identically.

`workspaceMember`-relative predicate values are still only bound for
user contexts (API keys/applications have no workspace member), matching
existing behaviour.

## Notes

- Enterprise-gated RLS code paths only.
- Could not run `nx typecheck`/lint in this environment (dependencies
not installed); changes reviewed manually. CI will validate.

https://claude.ai/code/session_01N2RkG8aMwgfFU2jBghMgCQ

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22456?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 12:11:04 +00:00
Félix Malfait 38fbff465f chore(server): ship the 2.20 standardOverrides drop as a dormant command (#22448)
Follow-up to #22417, per [this
thread](https://github.com/twentyhq/twenty/pull/22417#discussion_r3512187719):
migrate the `2-20/README.md` placeholder into a real command using the
`TWENTY_NEXT_VERSIONS` mechanism.

### What

- Add `DropMetadataStandardOverridesColumnFastInstanceCommand`,
registered against `2.20.0`. It boots (`2.20.0` is in
`TWENTY_ALL_VERSIONS`) but stays **dormant** — the upgrade sequence only
runs `TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS` (previous + current), so
it never executes during the 2.19 deploy and activates automatically
when `nx version:bump` promotes 2.20 to current.
- Name constant + unit test (SQL parity, registration against `2.20.0`,
name-constant parity).
- Register it in `instance-commands.constant.ts`.
- Update the `standardOverrides` `@deprecated` comments on object/field
metadata to point at the shipped command.
- Delete `2-20/README.md`.
- Document the "ship a command for a future version" flow in
`docs/UPGRADE_COMMANDS.md` and `.cursor/rules/server-migrations.mdc`
(the mechanism was previously undocumented).

### Note / correction to the README's plan

The old README implied both the command **and** `@WasRemovedInUpgrade`
could be added at 2.20 time. Only the command can ship now: the
decorator's validator runs against the active sequence, so referencing a
still-dormant 2.20 step fails boot with `unknown-step-name`. So the
entity keeps its `WasRemovedInUpgrade<T>` type wrapper for now; the
decorator gets wired (one line, via the name constant) once 2.20 is
current — same deferred-drop shape as `isUIReadOnly`.

### Verification

Could not run `jest`/`typecheck`/`lint` in this environment: `yarn
install` is blocked by egress policy on a git-based transitive dep
(`github.com/electron/node-gyp.git`). Verified by review against the
sibling 2-19 add-column and 2-12 drop commands. **Please let CI run
before merge.**

https://claude.ai/code/session_01KMArJvdEmsX3eAmJLbS1b6

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22448?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 13:18:20 +02:00
Etienne 7a5896ff5d feat(ai) - add delete_workflow tool (#22432)
## Summary

- Add a new `delete_workflow` agent tool that soft-deletes a workflow
and cleans up its sub-entities (versions, runs, triggers) via
`WorkflowCommonWorkspaceService.handleWorkflowSubEntities`
- Update the workflow skill system prompt to document the new capability
and instruct the agent to always confirm with the user before deleting
- Wire `WorkflowCommonModule` / `WorkflowCommonWorkspaceService` into
the workflow-tools dependency graph

## Test plan

- [x] Unit tests added (`delete-workflow.tool.spec.ts`) covering
successful deletion and error handling
- [ ] Verify the agent can resolve a workflow by name via
`list_workflows` then delete it with `delete_workflow`
- [ ] Confirm the agent asks for user confirmation before executing the
deletion
- [ ] Confirm sub-entities (versions, runs, triggers) are removed after
deletion

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22432?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 13:05:46 +02:00
Etienne 080014c970 fix(ai) - Fix date stripped from agent tool output (#22443)
https://discord.com/channels/1130383047699738754/1522138489238458569

**Summary**
Fix a bug where Date objects (returned by TypeORM for createdAt,
updatedAt, deletedAt columns) were silently dropped from AI agent tool
responses
stripEmptyValues treated Date instances as empty objects because
Object.entries(new Date()) returns [], causing the function to discard
them
Add instanceof Date guard before the generic object branch so Date
values pass through unchanged
**Root cause**
TypeORM marks createdAt/updatedAt/deletedAt as special columns
(createDate/updateDate/deleteDate) and returns them as JavaScript Date
objects rather than strings. The stripEmptyValues utility checked typeof
value === 'object' (true for Date), then called Object.entries() on it
-- which yields an empty array since Date has no own enumerable
properties -- and concluded the value was "empty".

The existing tests used string dates ('2024-01-01') instead of actual
Date objects, so the bug was never caught.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22443?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 10:19:04 +00:00
Félix Malfait 5a4ebca226 refactor(server): unify the two metadata override mechanisms into one (#22417)
## Unify the two metadata override mechanisms into one

Twenty had **two** override mechanisms:

- **`standardOverrides`** — a bespoke JSONB column on
`objectMetadata`/`fieldMetadata` with typed DTOs and a per-locale
`translations` map, resolved by two i18n-aware resolvers.
- **`OverridableEntity.overrides`** — a flat, registry-driven JSONB blob
on view / view-field / view-field-group / command-menu-item /
page-layout-tab / page-layout-widget, resolved by a plain spread.

This PR collapses them into **one** concept: a single `overrides` blob,
one registry-driven overridable set, one i18n-aware read path, and one
write path (`computeMetadataOverridesBlob`, extracted in #22404).

Object/field **stay on `SyncableEntity`** (not reparented to
`OverridableEntity`) so their `isActive` default stays **FALSE** — this
sidesteps the `isActive` default conflict entirely.

### GraphQL breaking change (accepted)

The `standardOverrides` field is **removed** with no deprecation alias —
`overrides` (a `JSON` scalar) is exposed instead on `Object` and
`Field`. Product confirmed negligible external usage; the front-end has
no hand-written consumer (only generated types), which are regenerated
here.

### Commit structure (reviewable commit-by-commit)

1. **Unified resolver + parity harness** —
`resolveEffectiveEntityProperty` is a strict superset of the three
legacy resolvers; a corpus parity spec compares it against a *frozen
reference* of the old logic across every locale, `isStandardApp` branch
and override shape.
2. **Registry-driven** — object/field presentation props tagged
`isOverridable` + `translatable`; the overridable/translatable sets are
derived from the registry (a test asserts they equal the legacy
hardcoded lists).
3. **Rename + swap + delete** — `standardOverrides` → `overrides` across
entities, DTOs, flat/universal types, producers, the ~12
resolve/write/create/sync call sites, mocks and specs; the reconciler's
two compare entries collapse to one; the three legacy resolvers, both
DTOs and the hardcoded constants/types are deleted.
4. **Migration (zero-downtime, two-phase)** — split across two releases
so a rolling deploy never drops a column a previous-release pod still
`SELECT`s:
   - **2.19 fast** — add the `overrides` column (schema only).
- **2.19 slow** — backfill `overrides` from `standardOverrides` in
`runDataMigration` (kept out of the schema transaction so the bulk write
doesn't hold the ACCESS EXCLUSIVE lock; skipped on fresh installs, which
have no data to copy).
- **2.20 fast** — drop the legacy `standardOverrides` column (gated by
`TWENTY_NEXT_VERSIONS`, so it stays dormant until the instance reaches
2.20).
5. **Front/client-SDK regen** — regenerated metadata GraphQL types.
6. **Integration specs + i18n** — updated the standard object/field
update integration specs + snapshots, and the reworded validator message
catalog entry.

### Rolling-deploy safety

`standardOverrides` is retained through 2.19 and only dropped in 2.20,
mirroring the codebase's deferred-drop convention
(`isUIReadOnly`/`isCustom`). During the 2.19 rollout both columns exist,
so old and new pods coexist without "column does not exist" errors. The
backfill lives in a slow `runDataMigration` (per the
`no-data-mutation-in-fast-instance-command` rule) so it doesn't stall
reads.

### `isActive` guard

The migration never reads or writes `isActive`; the backfill asserts the
active-row count is unchanged and aborts otherwise. Verified on a real
DB: apply + revert preserves the blob **and** the nested `translations`
map, with `isActive` counts identical before/after.

### Verification (local)

- `nx typecheck twenty-server` + `nx typecheck twenty-front` — green
- `nx lint:diff-with-main twenty-server` (oxlint `--type-aware` + oxfmt)
— green
- `nx test twenty-server` — green (unit + parity + registry + migration
tests)
- `nx run twenty-server:test:integration:with-db-reset` — green
- `database:reset` applies the 2.19 phases and leaves **both** columns
present (2.20 drop stays dormant); backfill + revert round-trip verified
on a real DB
- Metadata integration suites (standard object/field update, application
sync) pass end-to-end against the two-column schema
- Metadata GraphQL types regenerated against a booted server; zero
`standardOverrides` references remain in application code (only the
migration commands + the legacy schema baseline)

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-07-02 12:01:15 +02:00
Abdul Rahman 3bbc08d41f refactor(schema): reorganize IndexField and related types (#22439)
## Summary

Querying `indexMetadatas { indexFieldMetadatas { ... } }` on the
`/metadata` GraphQL endpoint fails with a 500:

> Nest could not find IndexFieldMetadataDTOAuthorizer element (this
provider does not
> exist in the current context)

The `@CursorConnection('indexFieldMetadatas', ...)` decorator on
`IndexMetadataDTO` makes nestjs-query auto-generate a relation resolver
that injects an authorizer for `IndexFieldMetadataDTO`. That authorizer
is never provided, because the DTO was never registered as a resolver in
`IndexMetadataModule` — so the field has been broken since it was
introduced in #7162.

Since the working, DataLoader-backed `indexFieldMetadataList` field
already exposes the same data (and is what the frontend uses), this PR
removes the dead connection instead of wiring up the authorizer.

## Changes

- Remove `@CursorConnection('indexFieldMetadatas', ...)` from
`IndexMetadataDTO`
- Regenerate frontend metadata GraphQL types
(`twenty-front/src/generated-metadata`)
- Regenerate client SDK metadata schema/types
(`twenty-client-sdk/src/metadata/generated`)

## Notes

- Not a breaking change in practice: the removed field always threw, so
no consumer can have been relying on it. Callers now get a standard
GraphQL validation error suggesting `indexFieldMetadataList` instead of
an internal server error.
- Verified locally: the failing query now returns `Cannot query field
"indexFieldMetadatas" on type "Index". Did you mean
"indexFieldMetadataList"?` and `indexFieldMetadataList` continues to
work.

Fixes [sonarly issue #54098](https://sonarly.com/issue/54098?type=bug)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22439?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 11:32:39 +02:00
Raphaël Bosi 717b297bd1 Add Last contact app to onboarding v2 installable apps (#22433)
Adds the Last contact app to the list of installable apps shown in the
onboarding v2 install-apps step, alongside Call recorder and Enrichment.

Wired in both the frontend list (label + description) and the backend
reward/install allow-list so it can be selected, installed server-side,
and credited.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22433?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 08:56:08 +00:00
Etienne 8182b2a07d fix(billing) - invalidate activationStatus after billing event (#22414)
Issue with workspaces still blocked after being re-activated

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22414?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 10:24:35 +02:00
Félix Malfait 7b5d313dd1 test(server): fix DPA Annex C test broken by sub-processor sync action (#22422)
## Fix flaky DPA Annex C test broken by the sub-processor sync action

`resolveDpa`'s Annex C test hard-coded Amazon Web Services' processing
locations:

```
Amazon Web Services (https://aws.amazon.com) — Processing location(s): United States, Germany, France.
```

But `subprocessors.json` is overwritten by the **trust-center sync
GitHub action** (#22403). AWS is now listed with `processingLocations:
["DE"]`, so the DPA renders `Processing location(s): Germany.` and the
hard-coded assertion fails on `main` (`twenty-server:test:ci`).

This makes the test derive its expectations from `subprocessors.json` —
asserting that every synced sub-processor renders an Annex C entry
(`<name> (<vendorUrl>) — Processing location(s):`) and that Annex C is
tied to §6.1 — instead of hard-coding vendor locations the sync action
controls. The sibling `expands the sub-processor sentinel into exactly
the synced entries` test already follows this data-derived pattern.

No production code changes — test only.

### Verification
- `resolve-dpa.util.spec.ts` — 18/18 pass (was 1 failing on `main`)
- oxlint + oxfmt clean


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22422?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-01 23:03:59 +02:00
github-actions[bot] 795785653d chore: sync DPA sub-processors from trust center (#22403)
Automated weekly sync of `subprocessors.json` from Twenty's Trust Center
(OneLeet).

This keeps the DPA's Annex C (the SCC Annex III list of Sub-Processors)
in
lockstep with the canonical list at https://trust.twenty.com — the Trust
Center is the single source of truth; this file is generated from it.

**Please review before merging** — confirm the added/removed
Sub-Processors
are expected, and that customers were notified per Section 6.2 where
required.

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-01 21:03:21 +02:00
Thomas des Francs d8cc81cb91 Improve billing settings UI (#22377)
## Summary

- Refresh the settings billing subscription and credits cards with
clearer status, usage, and action states.
- Add the credit-package picker flow and route past-due/cancellation
actions to billing management instead of credit modals.
- Clean up related billing UI helpers and formatting.

## Screens

### regular
<img width="1007" height="781" alt="image"
src="https://github.com/user-attachments/assets/dc9c0c59-8cda-422a-a0d3-56292421a744"
/>

### downgrading
<img width="1049" height="818" alt="image"
src="https://github.com/user-attachments/assets/0dc3dea8-5b55-4cf2-bc92-997cf6e9bebc"
/>
<img width="1048" height="901" alt="image"
src="https://github.com/user-attachments/assets/fba805ad-f523-444b-93a0-2011b6b43443"
/>

### Trialing

without card
<img width="1008" height="903" alt="image"
src="https://github.com/user-attachments/assets/d5dd11b6-4c92-4102-ac22-ad1ad4e9fbfb"
/>

with card
<img width="1008" height="806" alt="image"
src="https://github.com/user-attachments/assets/0dbeb00e-890e-4448-94fe-0cc0ef411e8d"
/>


### Past due & Unpaid

<img width="1052" height="860" alt="Past due"
src="https://github.com/user-attachments/assets/25ba53ef-6e74-4b4c-bfe1-d6c74e165917"
/>
<img width="1138" height="860" alt="Unpaid"
src="https://github.com/user-attachments/assets/bb16fe65-84e4-40a7-8951-90b01030aded"
/>

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-07-01 17:02:54 +00:00
Félix Malfait 2e7441380f refactor(server): unify metadata override-blob computation (step 1 of override unification) (#22404)
## Context

Twenty currently has **two override mechanisms** for metadata:

- `standardOverrides` (a bespoke JSONB column on `objectMetadata` /
`fieldMetadata`) — i18n-aware, typed DTO with a per-locale
`translations` map, resolved via
`resolve-object/field-metadata-standard-override.util.ts`.
- `OverridableEntity.overrides` (base class: `view`, `view-field`,
`view-field-group`, `command-menu-item`, `page-layout-tab`,
`page-layout-widget`) — a flat, i18n-free `{...entity, ...overrides}`
spread, registry-driven via `isOverridable`.

"One concept, two code paths → drift & confusion; reconciliation has to
special-case." This PR is **step 1** of collapsing them.

## What this PR does (small, behavior-preserving)

The add / remove / null-collapse **override-blob write logic was
triplicated** across:
- `sanitizeOverridableEntityInput` (`overrides`)
- `sanitizeRawUpdateObjectInput` (object `standardOverrides`)
- `sanitizeRawUpdateFieldInput` (field `standardOverrides`)

This extracts it into a single `computeMetadataOverridesBlob` helper
that all three now call. This is genuine **cross-mechanism convergence
of the write path** — the first concrete reduction of the "two code
paths".

- Behavior-preserving: same diff semantics. The object/field paths used
strict `===` on their string standard-override props; `isEqual` subsumes
that for strings, and the overridable path already used `isEqual`.
- Type-casts are contained **inside** the one helper; the three call
sites stay clean and type-preserving.
- 4 files: 1 new util + 3 refactors.

## ⚠️ Draft — verification status

I could **not** run `typecheck` / `lint` / tests locally: the sandbox
this was authored in cannot complete `yarn install` (network aborts
mid-install, no `node_modules`). The change is small and reasoned, but
**please let CI validate it** — that's why this is a draft. If CI flags
a type/lint nit in the contained casts, it's isolated to
`compute-metadata-overrides-blob.util.ts`.

Per request: no code comments were added; the design/tradeoff discussion
lives here.

## The full unification plan (this PR is step 1)

The remaining steps are deliberately **not** in this PR because they
need a live DB (migration) and the front-end codegen pipeline to verify
— neither is available in the authoring sandbox. Documented here for
review before we proceed:

| Step | Change | Why staged |
|------|--------|-----------|
| **(this PR)** | Unify the write-path blob logic | Safe,
behavior-preserving, no DB/FE |
| Read path | One i18n-aware `resolveEffectiveEntity` (superset of the
flat spread + the two i18n resolvers) | The i18n resolvers are entangled
with typed translation-key narrowing; merging cleanly needs the
storage/i18n generalization below |
| Registry | Make object/field presentation props registry-driven
(`facet` + `translatable`), like the overridable set already is |
Depends on the facet annotation |
| Storage | Object/field extend `OverridableEntity`; `standardOverrides`
→ `overrides` (translations preserved); **one data migration** | Needs
DB verification; changes schema |
| GraphQL + FE | Remove the `standardOverrides` field, expose
`overrides`; regen `twenty-front` / client-SDK types; update the
Settings → Data-Model rename UI | Needs codegen; see tradeoff below |

## Key tradeoffs / decisions to confirm

1. **GraphQL break on `standardOverrides` — accepted.** Per product
call, external usage is negligible, so the later step will **remove**
the field outright (no deprecated alias). The one real consumer is the
Settings → Data-Model rename-label UI, updated in the same step. This
drops the most complex part of the original plan (a virtual-alias
resolver + deprecation window).
2. **`isActive` default.** `OverridableEntity` defaults `isActive` to
`true`; object/field default it to `false`. The storage step must
**explicitly override the default** and assert in the migration that no
existing row's `isActive` changes.
3. **Overrides stay anonymous single-slot blobs** (no per-app
attribution / multi-contributor 3-way merge). That limitation is
unchanged here and is only worth revisiting if a concrete use case needs
owner-tagged layering (real schema work, sized separately).
4. **Parity harness is the safety net for the storage step.** Because
the read-path/storage merge touches the hot object/field resolve path
and i18n precedence, that PR should land a golden-corpus parity gate
(all locales, `isStandardApp`, empty/partial/full overrides) proving the
unified resolver reproduces today's output byte-for-byte, before any
switch.

## Not included (per request)
- No service tests added.
- No code comments added (rationale/tradeoffs are here, in the PR).

## Test plan
- CI: `typecheck` + `lint` + the existing
`sanitize-overridable-entity-input.util.spec.ts` (which exercises the
shared logic through `sanitizeOverridableEntityInput`).
- The object/field write paths have no dedicated unit spec; they're
covered by the metadata integration suites
(`successful-update-one-standard-object/field-metadata`).

https://claude.ai/code/session_01E1pGBDLC3gEBs1w45G2W5Z

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

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