Commit Graph

11036 Commits

Author SHA1 Message Date
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 ab4e979352 perf(ai): render streaming markdown as memoized blocks (#22489)
## Rationale

`LazyMarkdownRenderer` re-parses and re-renders the **entire accumulated
message** through react-markdown on every throttled stream flush (10/s).
Render cost grows linearly with message length while streaming, so long
answers degrade progressively — this is the dominant jank vector in the
chat (verified in the perf audit: no memoization anywhere in the
message-render path).

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

The waste is structural: 99% of a streaming message is settled text that
cannot change, yet it re-renders because the whole string is one
react-markdown call. Splitting at real markdown block boundaries via
`marked.lexer` (already a dependency, used in the advanced text editor)
and memoizing per block means settled blocks keep their rendered
subtree; only the growing tail block re-parses per flush — cost becomes
O(tail) instead of O(message). Index keys are stable because streaming
is append-only. This is the standard memoized-markdown pattern from the
AI SDK ecosystem.

Deliberately **not** included: list virtualization for very long
threads. The audit's verdict was memoize first, virtualize only if
profiling still shows mount cost matters — virtualization changes scroll
behavior and deserves its own evaluation.

One known tradeoff: markdown reference-style links whose definition
lives in a *different* block won't resolve across blocks. Model output
uses inline links; the tradeoff is shared by every implementation of
this pattern.

## User impact

Long streaming answers stop stuttering — keystroke-to-paint stays flat
instead of degrading as the answer grows. Most noticeable on tool-heavy
turns that produce big final summaries.

## Test plan

- [ ] CI green (existing markdown rendering covered by storybook visual
tests)
- [ ] Manual: stream a long answer with code fences and tables —
identical rendering, no per-flush jank in the profiler

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/22489?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:09 +02:00
Félix Malfait 4a8679327b fix(ai): bound silent stream recovery and surface a terminal CONNECTION_LOST state (#22486)
## Rationale

Two gaps in the keep-alive recovery (`AgentChatStreamKeepAliveEffect`):

1. It only engages when `isStreaming` is already true — a socket that
dies **before the first chunk** leaves the user waiting forever with no
recovery path (CONFIRMED-high in the chat-stack audit; the window where
Sentry shows failures concentrate).
2. When it does engage, it retries **silently forever** — a genuinely
dead connection means an infinite spinner with the user none the wiser.

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

Recovery must be gated on "a response is owed" — which since #22485 is
`isStreaming || isAwaitingFirstChunk`, closing gap 1 with the state that
actually models the window rather than a timer heuristic. For gap 2,
unbounded retry hides a terminal condition; the fix is an honest state
machine: 3 silent recoveries (resubscribe + refetch), then a client-only
`CONNECTION_LOST` error. Two deliberate choices from the audit:

- **No Retry button** on `CONNECTION_LOST` — it's semantically forced,
not cosmetic: Retry calls `retryLastFailedTurn`, which requires a
persisted `lastStreamError`; after a mere connection loss the server has
no failed turn (the stream is likely still running or completed
server-side), so Retry would deterministically throw
`NO_FAILED_TURN_TO_RETRY`.
- **Auto-clear instead of dead-end**: the moment events flow again (SSE
reconnect, refetch delivering data), the `CONNECTION_LOST` error clears
itself — the state is "connection lost", not "turn failed", and it
self-heals when the connection returns.

## User impact

A dead connection pre-first-token currently means waiting forever;
mid-stream it means silent infinite recovery. Now: three quiet recovery
attempts (which fix the transient cases invisibly), then a truthful
message, which disappears on its own when connectivity returns — and the
server-side answer is intact all along, delivered by the next successful
refetch.

## Stack

Based on #22485 (pending indicator) — reads the awaiting-first-chunk
state. Chain: #22484#22485 → this.

## Test plan

- [ ] CI green
- [ ] Manual: kill the network pre-first-token → 3 recoveries →
CONNECTION_LOST; restore network → error clears, transcript catches up

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/22486?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:20:45 +02:00
Félix Malfait 77529191f8 fix(ai): apply stream chunks in exact server seq order via a client-side sequencer (#22484)
## Rationale

Stream chunks reach the client on two unsynchronized paths: live SSE
events and the catchup replay (fired on reload, refetch, SSE reconnect,
and keep-alive recovery). The server already stamps every chunk with an
authoritative `seq` (Redis `RPUSH` length), but the client applies
chunks in **arrival order**. Reload mid-stream and the two paths
interleave: duplicated text deltas, or lower-seq catchup chunks applied
after higher-seq live ones — the streaming answer visibly garbles until
the persist-refetch repaints it.

Main's existing guard (`seq < firstLiveSeq` bound on catchup) only
prevents duplication in one direction (live-before-catchup); it does
nothing for catchup-during-live overlap, and it *creates* a
dropped-chunk window when chunks land between the catchup snapshot and
the first live event.

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

The defect is a joining problem between two ordered sources, and the
join point is the client — the server can't fix it without a protocol
change (per-subscriber cursor resume), because Redis pub/sub fan-out has
no per-subscriber replay. Given the transport, the correct fix is to
make the reducer's input **seq-exact**: apply strictly in server order,
dedup anything already applied, buffer early arrivals until the gap
fills. Escalation is bounded and degrades gracefully: a stalled gap
triggers one refetch (the full-list catchup replay doubles as gap-fill,
no new endpoint), a second stall flushes the buffer in order — so even
an expired chunk list degrades to slightly-lossy instead of wedging. The
catchup path now replays the full list (the sequencer dedups overlap),
which also closes the dropped-chunk window.

Server-side cursor resume remains the nicer long-term protocol (would
simplify this client), but it's a subscription protocol change; this
fixes the user-facing defect with zero server change and is
forward-compatible with it.

## User impact

Reloading (or losing the connection) mid-answer currently scrambles or
duplicates the streaming text until the turn completes. With this, the
answer renders identically no matter when you reload or how the two
delivery paths race.

## Test plan

- [x] Sequencer unit suite (fake timers): in-order apply, out-of-order
buffering, catchup/live overlap dedup, gap-fill via replay,
stall→refetch escalation, second-stall in-order flush, high-water-mark
continuation, reset
- [ ] CI green
- [ ] Manual: reload mid-stream repeatedly; text never reorders

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/22484?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:17:11 +02:00
Félix Malfait 310742519b fix(ai): tell members without billing permission why AI stopped at the usage cap (#22487)
## Rationale

When a workspace hits its AI usage cap, members **without** the
`BILLING` permission flag get zero explanation:
`AIChatNoMoreBillingCreditsBanner` returns `null` for them, and
`AiChatErrorRenderer` also returns `null` for
`BILLING_CREDITS_EXHAUSTED` (deliberately delegating to that same
banner). Net effect — for most seats in a workspace, AI chat just
silently stops working. Sentry shows the cap is hit constantly: 290
users / 90 days on `Billing Credits Exhausted`.

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

The permission gate exists to hide *billing actions* (upgrade/subscribe
modals) from members who can't act on them — but it was written as "hide
everything", conflating the action with the information. The fix keeps
the gate exactly where it belongs (no upgrade button, no modals for
non-billing members) and renders the information-only banner: "Your
workspace hit its AI usage limit. Ask an admin to upgrade the plan."
Fixing it in the error renderer instead would be the wrong altitude: the
banner mounts *before* a send is attempted (gated on
`hasReachedCurrentBillingPeriodCap`), so members are informed
proactively rather than after a failed send.

## User impact

Non-admin members — the majority of seats — stop experiencing "AI is
broken" and instead see what happened and who can fix it.

## Test plan

- [ ] CI green
- [ ] Manual: member without billing permission at cap → informational
banner, no upgrade button; admin → unchanged upgrade flow

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/22487?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:14:46 +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
Félix Malfait a445974cff fix(ai): offer Retry when the failed turn persisted partial assistant output (#22478)
## Rationale

When a turn fails mid-stream *after* emitting some text, the failed
turn's partial assistant message is persisted — so the last message in
the thread is the assistant's, and `AiChatErrorUnderMessageList` (which
owns the Retry button, gated on the last message being the user's) never
renders. The error surfaces through `AiChatMessage` →
`AiChatErrorRenderer` instead, and that path never passed `onRetry`.
Result: an error banner with no action for the most common failure shape
(mid-stream provider errors), most visibly after a reload.

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

This is a wiring omission, not a designed gate. `AiChatErrorRenderer`
already accepts `onRetry`, and the server's `retryLastFailedTurn`
already deletes the failed turn's assistant messages before re-streaming
— the entire retry path for partial-output turns exists and works; only
the prop was never threaded. Verified there's no hidden protective
reason: retrying with partial output cannot duplicate content, because
regeneration is delete-then-restream by design.

## User impact

A mid-stream failure currently strands the user: their only options are
re-typing the message or reloading. With this, the same Retry affordance
appears whether the turn died before or after the first token (Sentry
shows 126 users/30d hitting zero-output failures alone — the with-output
shape shares the same recovery need).

## Test plan

- [x] `AiChatErrorRenderer` retry behavior already covered by existing
rendering; change is prop threading only (~10 lines)
- [ ] CI green
- [ ] Manual: fail a turn mid-stream (kill provider), observe Retry on
the in-message error banner

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/22478?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:57:40 +02:00
Raphaël Bosi 7c33280465 Fire front component window error handlers (#22462)
## What

Front components run in a Web Worker with the remote-dom polyfill, which
installs a fake `window`. Native `error` and `unhandledrejection` events
only fire on the real worker scope, so a component's `window.onerror`,
`window.addEventListener('error', ...)`, or
`window.onunhandledrejection` handler is a **silent no-op** today —
error-tracking libraries (Sentry-style) never see anything.

This adds `installErrorEventBridge` to the worker bootstrap: it listens
for the native `error`/`unhandledrejection` events and re-dispatches
equivalent events onto the fake `window`, so component-registered
handlers fire as they would on the web. It is guarded to no-op outside
the worker (when the fake window is the global scope) and swallows
errors thrown by a component's own handler.

## Scope

Worker-side only, no host, SDK, or RPC changes. Uncaught synchronous
errors already reach the host error panel via the native worker
`onerror`; surfacing unhandled promise rejections to the host panel is a
separate follow-up.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22462?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 16:15:04 +00:00
nitin bc1a4cb3fb feat(call-recorder): cap media file size during ingestion to avoid OOM (#22463)
Media ingestion buffered whole Recall files in memory; long recordings
OOM'd the logic function.

- Caps the size of ingested media files, configurable via the
`CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB` app variable (default 80 MB).
- Downloads stream chunk by chunk and stop at the cap; a Content-Length
above the cap skips the download without reading the body.
- A skipped file is recorded as `video_file_too_large` /
`audio_file_too_large` in `callRecorderFailureReason`; the completion
gate treats a marked file as resolved, so the recording still completes
and bills with its remaining artifacts.
- A real failure reason always wins over the size markers when the
recording fails.

Deferred: the cap is a stopgap until core supports streaming uploads
(TODO in code).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22463?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:55:47 +05:30
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
martmull 4f44131901 perf(front): query only rendered fields in application settings pages (#22454)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456

Application settings pages pulled far more data than they render:

- **`FindOneApplicationByUniversalIdentifier`** fetched the full
`ApplicationFields` fragment (all nested agents, objects,
logicFunctions, frontComponents, commandMenuItems) just so
`SettingsAvailableApplicationDetails` could check whether an app is
installed. Slimmed to `id, universalIdentifier, name, version` (its only
caller; every field usage audited).
- **`FindManyApplicationRegistrations`** (developer tab list) fetched
the 17-field registration fragment including `isConfigured`, which
triggers a per-row DataLoader resolve. The list renders only `id, name,
universalIdentifier, sourceType` — new lean
`ApplicationRegistrationListItem` fragment. The detail page and admin
list, which actually render `isConfigured`, keep the full fragment.
- **`SidePanelEditOwnerSection`** pulled the full `FindOneApplication`
payload to render a name — new `FindOneApplicationName` (`id, name`)
query.

Regenerated `generated-metadata/graphql.ts` (document-level changes
only, zero schema drift; data/admin outputs byte-identical).
Deliberately untouched: the installed-app detail page query (renders its
nested collections across tabs), the sub-detail pages that share its
cache entry, and the marketplace manifest usage (needs backend fields —
later PR).

Verified: typecheck, oxlint/oxfmt on touched files, jest (applications
19/19, navigation-menu-item 86/86).

🤖 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/22454?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 16:52:28 +02:00
Raphaël Bosi 49095dcfe0 Drop unsafe props from front component elements (#22458)
## What

Front components are third-party React components rendered into the host
page through a restricted element allow-list. `filterProps` (where their
props become real DOM attributes) used to forward unrecognized values
as-is, which left two ways to run script in the host origin:

- an `on*` attribute with a string value, which React renders as an
inline event handler;
- a dangerous-scheme URL (`javascript:`, `data:`, `vbscript:`) on a
link, which executes on navigation.

## Change

`filterProps` now drops both:

- `on*` props are kept only when the value is a real function (still
wrapped as before); any non-function `on*` is dropped.
- `javascript:` / `data:` / `vbscript:` URLs are dropped, but only on
**navigation targets** (`<a>`/`<area>` `href`/`xlink:href`, `<form>`
`action`, `<button>`/`<input>` `formaction`), after normalizing away
control-character obfuscation (e.g. `java\tscript:`). Resource-loading
attributes are left alone, so `<img src="data:image/...">` keeps
working.

Well-behaved components are unaffected: function handlers are still
wrapped and normal URLs pass through. Host-side only, no worker or SDK
changes.

## Scope: the actual behavior change is small

The diff looks large, but most of it is **not** a behavior change.
`createHtmlHostWrapper.ts` (~460 lines) was split into
one-export-per-file utils (`filterProps`, `serializeEvent`,
`parseCssString`, `hasDangerousUrlScheme`, etc.), each with its own unit
test, leaving `createHtmlHostWrapper.ts` as a thin orchestrator. Those
helpers were **moved unchanged** — the only real logic change is the
`filterProps` hardening described above. The pre-existing render-based
integration test passes untouched, which confirms the split is
behavior-neutral; the rest of the new files are extractions plus added
test coverage.

## Why these schemes, and only on navigation targets

Per MDN, `javascript:` (and `data:`) URLs are dangerous specifically
where a URL is a *navigation target*, not where it is a *resource
location* (like an image `src`) — which is exactly how the check is
scoped:

- [`javascript:` URLs
(MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascript)
- [`data:` URLs
(MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data)
- [URI schemes overview
(MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes)

This is a prerequisite for later work that widens the raw-attribute
surface (innerHTML rendering).
2026-07-02 14:40:15 +00: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
Raphaël Bosi ff6a0c6e69 Fix front component crash on unknown elements (#22455)
## What

Front components are third-party React components rendered on the host
via remote-dom against an allow-list of elements. Today the host
renderer throws on any element tag it has no component for (e.g. a raw
tag produced by `innerHTML`), and there is no error boundary, so a
single unknown element crashes the whole widget.

This wraps the component registry with a fallback:
- a raw tag that has an allow-listed `html-*` equivalent is routed to
that safe wrapper (so a raw `iframe` renders through the existing
sandbox-forcing renderer instead of being dropped),
- tags with no safe renderer (`script`, `object`, `embed`, `link`,
`meta`, `base`, `noscript`, `style`) render nothing,
- any other unknown tag renders children only.

`RemoteRootRenderer` is also wrapped in an error boundary that fails
closed to the existing error panel, so a render error can no longer take
down the host.

## Notes

The host allow-list remains the single rendering gate. This is the first
hardening step of a broader effort to widen the DOM/Web API surface
available to front components; it is self-contained and does not change
behavior for components that only use allow-listed elements.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22455?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:13:50 +00:00
martmull 11b2990dd6 fix(twenty-sdk): stop dev-mode OOM by caching compiled manifest modules (#22435)
## Context

Closes twentyhq/core-team-issues#2601

`twenty dev` crashed with a Node.js heap OOM (`FATAL ERROR: Reached heap
limit — JavaScript heap out of memory`) after a while of editing.

## Root cause

`loadModule()` in `manifest-extract-config-from-file.ts` compiled every
manifest-defining file with **`vm.compileFunction`** on every manifest
rebuild. V8 pins every function compiled through the `vm` module and
never releases it
([nodejs/node#35375](https://github.com/nodejs/node/issues/35375)).

In the dev loop this is on the hottest path and heavily amplified:
- `runSyncPipeline` → `buildManifest` re-globs **all** `.ts/.tsx` files
and recompiles every entity file on **every** sync — not just the edited
one.
- A single save triggers 2+ full rebuilds (the manifest watcher change →
`scheduleSync`, then the esbuild watcher's `handleFileBuilt` →
`scheduleSync` again).
- Each compiled unit is the full esbuild bundle — hundreds of KB, up to
MBs for front components (React/JSX inlined).

So over an hour of editing, thousands of `vm.compileFunction` calls ×
large source, all permanently retained → multi-GB heap → crash. This
matches the reported profile exactly.

Investigation ruled out (with evidence): chokidar watchers (disposed on
restart), ts-morph/`createProgram` (dead code, not in the dev loop —
typecheck runs in child `tsc` processes), the event log (hard-capped at
200), Ink timers/subscriptions (all cleaned up), and graphql-sse (only
used by `logs`).

## Change

Keep only the **latest build per file**, keyed by file path. Each cache
entry stores the file's last bundled-output hash and its compiled
wrapper:
- Rebuild with **unchanged** output → reuse the existing wrapper (no
recompile).
- Output **changed** → overwrite the entry, so the file's previous build
is dropped instead of accumulating.

This bounds the cache to one entry per file rather than one per rebuild,
so old builds no longer pile up in the heap. The wrapper is still
executed fresh into a new module shim on every call, so extraction
behavior is unchanged.

One file changed:
`packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts`.

## Test

- Behavior of `extractManifestFromFile` is unchanged (fresh execution
per call); only redundant recompilation is eliminated and stale builds
are dropped.
- Note: `yarn install` could not complete in the authoring sandbox (a
git-based transitive dep of `twenty-desktop` is blocked by the proxy),
so lint/typecheck/tests were not run locally — relying on CI.

## Follow-ups (not in this PR)

- Redundant **double-sync per save**
(`start-watchers-orchestrator-step.ts`) triggers two full rebuilds per
edit.
- Latent **event-log display bug**: new events stop appearing once the
200-event cap is reached.
2026-07-02 15:12:54 +02:00
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
Weiko 632114e5e2 fix(front): hide sub-item tree connector in navigation drag preview (#22442)
## Context

Dragging a navigation menu sub-item cloned the whole row as the floating
drag preview, which included the vertical tree-connector bar on the
left. Hide that connector inside the moving clone (marked by dnd-kit
with [data-dnd-dragging]) so the preview shows only the icon and label.
The static placeholder left in the list and the other items keep their
connectors, so the list layout is unchanged.

## Before



https://github.com/user-attachments/assets/8fc04a28-e1d2-49e0-88c1-ef03f89475c2


## After


https://github.com/user-attachments/assets/dbcd3cc5-8a51-40a3-98de-a8ea3d440774
2026-07-02 14:21:08 +02:00
Weiko 63a0b0ab96 fix(front): render pinned command-menu buttons inline in page header (#22446)
## Context
Pinned command-menu items (isPinned: true) stopped appearing as inline
buttons next to the command-menu/burger control and only showed up in
the side panel's "Pinned" list.

Root cause: PR #21308 replaced the flex-based PageHeader with the
grid-based PageCardHeader. The old header sized the title with `flex: 0
1 auto` (content width) and the action container with `flex: 1 1 0`
(grows to fill), so the pinned-buttons wrapper — itself a `flex: 1 1 0`
element that measures its own available width to decide how many buttons
fit inline — had room to expand. PageCardHeader inverted this: it put
the title in the flexible `minmax(0, 1fr)` track and the action area in
the content-sized `auto` track. With the pinned wrapper empty on first
paint, the `auto` track collapsed to zero, the measured container width
was 0, and the "wait until measured" guard kept the visible inline count
pinned at 0 forever — a deadlock where nothing ever rendered inline and
every pinned item fell through to overflow.

Fix: give the non-centered header the same intent as the old flex layout
— title track content-sized/shrinkable (`minmax(0, auto)`), action track
flexible (`minmax(0, 1fr)`). The centered variant already placed the
action area in a `1fr` track, so it is unchanged. Both tracks keep a 0
minimum, so long titles still clip without causing horizontal overflow.

## Before
<img width="1299" height="140" alt="Screenshot 2026-07-02 at 13 05 20"
src="https://github.com/user-attachments/assets/948f5ded-1a9f-4329-825e-313924a829fe"
/>

## After
<img width="1296" height="238" alt="Screenshot 2026-07-02 at 13 05 11"
src="https://github.com/user-attachments/assets/64edd5f2-b307-4119-9158-813e39f813aa"
/>
2026-07-02 14:20:57 +02: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
Abdullah. 049a98a36a fix(website): restore footer CTA buttons on the light card inside the dark stage (#22447)
## What

The footer's **Talk to us** / **Get started** CTAs regressed: filled
rendered white-on-white (only the label showed) and outlined vanished
entirely. Resolves
[this](https://github.com/twentyhq/core-team-issues/issues/2634) issue.

## Why

#22241 marked the footer root as a dark menu-surface
(`data-scheme="dark"`) so the sticky menu adapts over the dark footer
stage. But the footer's content sits on a **white Card inside that
root**, and the button's dark override is a *descendant* selector
(`[data-scheme='dark'] &`) — so it leaked into the card. Filled → white
fill + black label (invisible fill on white); outlined → white stroke +
white label (fully invisible). The card's text was fine because it uses
the light default semantic vars; only the buttons key off the raw
attribute.

## Fix

- Mark the white `Card` as `data-scheme="light"` — it *is* a light
surface. The root keeps `data-menu-surface`/`data-scheme="dark"`, so
**menu adaptation is unchanged**.
- Add a button override scoped to `[data-scheme='dark']
[data-scheme='light'] &` — a light surface *nested inside* a dark one.
It's higher specificity than the dark rule and matches **only** this
footer case, so a dark card nested in a *light* section (e.g.
`HelpedCard`) is never affected. No other button changes.

Result: filled = black fill + white label, outlined = black stroke +
black label — matching the design.

## Testing

- `nx typecheck twenty-website` ✓ · `nx lint twenty-website` ✓
(check-conventions + oxlint + oxfmt)
- Reviewable on the PR preview (footer CTAs, plus menu/FAQ/hero/signoff
buttons unaffected).
2026-07-02 16:31:59 +05: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
Abdullah. 63d092a31b fix(website): center the User Guide nav preview image (#22445)
## What

The **User Guide** item in the Resources dropdown rendered its preview
image off-center (anchored top-left with a gap below the halftone).

## Fix

Set `imagePosition: 'center'` on the User Guide preview so the halftone
book is centered and fills to the bottom of the frame, matching the
others.

## Testing

- `nx lint twenty-website` ✓ (check-conventions + oxlint + oxfmt)
- `nx typecheck twenty-website` ✓
2026-07-02 14:47:48 +05: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
github-actions[bot] 8b6bd34a17 i18n - website translations (#22436)
Created by Github action

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-02 08:53:53 +00:00
Abdullah. 3474d75b56 feat(website): add Product to menu + footer nav, move Why into Resources dropdown (#22429)
## What

Restores the **Product** link to the site nav (removed in #21794),
reversing that commit's nav-structure change:

- **Menu** — Product is the first top-level item (in place of Why);
**Why** moves back into the **Resources** dropdown with its `why.webp`
preview (`IconBulb`, "Why teams choose Twenty").
- **Footer** — Product added to the **Sitemap** group (after Home).

The `/product` and `/why-twenty` routes already exist and are in the
sitemap; only the nav data changed. The rest of #21794 (dropdown frame
height, preview assets, current-page highlight) is untouched.

## Notes

- New `msg` strings (`Product`, and Why's restored strings) are left to
CI / the i18n bot to extract + translate — no catalog changes here.

## Testing

- `nx lint twenty-website` ✓ (check-conventions + oxlint + oxfmt)
- `nx typecheck twenty-website` ✓
2026-07-02 10:46:36 +02:00
Abdullah. 1b06532cb1 fix(website): tighten product-feature spotlight height and align bento spacing (#22428)
## What

Design polish on the product page's `ProductFeature` bento, from
designer review:

- **Spotlight height** — the first (spotlight) card's visual was
`min-height: 420px` on desktop vs the grid cards' `340px` (80px taller),
so it towered over the rest. Now **340px**, matching the grid cards.
- **Spacing consistency** — the spotlight visual used a uniform `margin`
(bottom margin included), unlike the other cards' `CardVisualFrame` (`…
0` bottom). Removed it so the visual→content gap is consistent across
every card.
- **Gap** — bumped the visual→content gap to `spacing(6)` (**24px**) on
desktop for all cards.

## Testing

- `nx lint twenty-website` ✓ (oxlint + oxfmt + check-conventions)
- `nx typecheck twenty-website` ✓
2026-07-02 10:45:45 +02: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
martmull 3c13dc1ab8 feat(twenty-sdk): include app readme in published package (#22431)
## Context

When running `yarn twenty app:publish`, no README was included in the
npm-published app package.

Closes twentyhq/core-team-issues#2632

## What changed

- Added `copy-readme-to-output.ts`, which finds the app's root readme
file (matched case-insensitively, preferring the markdown variant,
mirroring how npm ranks README candidates) and copies it into the build
output directory (`.twenty/output/`).
- Wired `copyReadmeToOutput` into `buildApplication` — the shared build
path used by `publish`, `build`, and `dev` — so the readme is present
when `npm publish`/`npm pack` runs from the output directory. npm only
ships a README when the file lives in the package root, which for
published apps is `.twenty/output/`.

The readme is not tracked in the manifest checksums; it is a pure npm
packaging artifact, so it is only copied into the output directory and
does not affect app installation/validation.

## Tests

- Added unit tests for `findReadmeFileName` (case-insensitivity,
markdown preference, ignoring unrelated files) and `copyReadmeToOutput`
(copies the readme into the output dir; no-ops when the app has no
readme).

https://claude.ai/code/session_01Qje6VemuMk8nunn6yVJNtL

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22431?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:23:45 +02:00
martmull 7b682bced9 feat(shared): require defaultValue on non-nullable field manifests (#22419)
## Context

Follow-up to #22362, which made `isNullable` manifest changes actually
apply (including a nullable → non-nullable backfill). This models the
`isNullable` / `defaultValue` relationship directly in the
`FieldManifest` type.

## Rule

- A **non-nullable** field (`isNullable: false`) must declare a
`defaultValue`, so the column always has a value to fall back on (e.g.
for the backfill on the nullable → non-nullable transition).
- A **nullable** or **unspecified** field may omit `defaultValue`.

## Changes

- Split `RegularFieldManifest` into a base shape plus a discriminated
nullability union. The union keeps `isNullable` free once a
`defaultValue` is supplied, so helpers that always provide one can still
pass a dynamic `boolean` `isNullable`.
- `defaultValue` keeps its rich per-type `FieldMetadataDefaultValue<T>`
(POSITION → number, ACTOR → composite) rather than a bare `string`.
- `RelationFieldManifest` is rebased on the shared base and keeps
`isNullable` / `defaultValue` optional, since relation join columns are
always nullable by design.
- Narrowed `buildEstimateFieldManifest` in the manifest-update
integration test to satisfy the stricter type.

## Verification

Environment couldn't install the monorepo deps (registry connections
aborting), so `nx typecheck` wasn't run here. Validated the union
structure with standalone `tsc` synthetic tests mirroring every
construction pattern in the codebase:

-  nullable/no-default, no-`isNullable`, non-nullable with
string/number/composite defaults, dynamic-boolean-with-default, and the
`DistributiveOmit` path into `ObjectFieldManifest`
-  non-nullable **without** a default is correctly rejected with a
clear "defaultValue is missing but required" error

Recommend a full `nx typecheck twenty-shared twenty-sdk twenty-server`
in CI to confirm against full project resolution.

https://claude.ai/code/session_01VnbrgBB3kNGP876qaKPYDL

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22419?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:03:52 +00: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] 128abcc433 i18n - docs translations (#22420)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-01 21:13:04 +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
martmull f7f224aa7a Fix lint (#22416)
as title

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22416?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 20:56:54 +02:00
martmull 05ce08ddba Add twenty-app keyword (#22415)
as title, bump version

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