Commit Graph

1317 Commits

Author SHA1 Message Date
Thomas Trompette 9e2c870574 fix(workflow): clear nextStepIds when converting a step to If/Else (#23714)
## Context

Fixes #22947.

A workflow with If/Else branches could fail at runtime with `Step not
found` (and no detail in the Runs panel) because of dangling
`nextStepIds` in the workflow graph — references to steps that no longer
exist.

## Root cause

An If/Else routes only through `settings.input.branches[].nextStepIds`;
its top-level `nextStepIds` is never read by the executor and must stay
empty. But converting an existing step into an If/Else copied the
previous step's `nextStepIds` onto the new If/Else, leaving a stray
top-level reference. That reference is invisible to the executor, and
when steps around it are later deleted it becomes dangling and
propagates into a normal step's `nextStepIds`, which the executor then
tries to follow — `Step not found`.

## Fix

When a step's type is changed to If/Else, don't carry over the previous
step's `nextStepIds`. One change in
`workflow-version-step-update.workspace-service.ts`.

## Verification

Reproduced on a local instance via the editor's GraphQL mutations (build
`trigger → P → X → D`, convert X to If/Else, delete D then X):
- Before: converting X produced a stray `nextStepIds: [D]`, and after
the deletes P was left with a dangling reference.
- After: converting X yields `nextStepIds: []`, and P stays clean — no
dangling reference.
2026-08-03 16:17:02 +00:00
neo773 96ea1e1ffc Track connected account webhook subscription lifecycle metrics (#23710)
Emits created/renewed/deleted counters and their failure counterparts
from the messaging and calendar webhook subscription services.

Each counter carries channel_type and provider attributes so the Grafana
panels can break them down. Infra side: twentyhq/twenty-infra#841

Co-authored-by: neo773 <huzef@twenty.com>
2026-08-03 13:51:17 +00:00
BOHEUS 495bd193a3 Messaging archived account fix (#23553)
Fix case where workspace admin wants to reconnect inherited connected
account

Case:
- workspace admin inherits team accounts from other workspace member who
left the workspace
- admin wants to reconnect inherited channels but it's not possible as
there's no path to make archived connected account active

Expected outcome: admin, who has credentials to archived connected
accounts, can reconnect said accounts

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23553?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: neo773 <huzef@twenty.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
2026-08-03 12:34:31 +00:00
Thomas Trompette e81fdbcc7a feat(workflow): variable pickers for Search Records limit, offset and date filters (#23696)
## Summary

<img width="491" height="390" alt="Capture d’écran 2026-08-03 à 11 30
40"
src="https://github.com/user-attachments/assets/6db59b8a-3e8e-41b8-80b3-c736e56b7f7f"
/>

Adds workflow variable pickers to the **Search Records** action for
fields that previously only accepted static values:

- **Limit** and **Offset** number inputs now expose the
`WorkflowVariablePicker`, so they can be bound to a variable from a
previous step. The stored value can be a standalone variable string; the
backend coerces the resolved value back to a number.
- **Date filters** using the `Is before` (`IS_BEFORE`) and `Is after or
equal` (`IS_AFTER`) operands now expose the variable picker in the
advanced filter side panel (previously disabled for all date filters).

The backend already resolves these inputs via `resolveInput`; the only
backend change is a small numeric coercion of the resolved limit/offset.

## Changes

- `WorkflowEditActionFindRecords.tsx` — pass `WorkflowVariablePicker` to
the Limit/Offset inputs; make `onChange` and form state variable-aware
(`number | string`).
- `AdvancedFilterSidePanelValueFormInput.tsx` — enable the date
`VariablePicker` only for `IS_BEFORE` / `IS_AFTER`.
- `useGetRecordFilterDisplayValue.ts` — return the raw variable for a
standalone `{{variable}}` value so date filters don't crash
`Temporal.*.from`.
- `find-records-action-settings-schema.ts` — allow a string (variable)
for `limit` / `offset`.
- `find-records.workflow-action.ts` — coerce resolved `limit` / `offset`
to numbers before querying.

## Testing

Built a workflow locally (Manual trigger → Code step returning `{ limit:
2, offset: 1, sinceDate }` → Search Records) with all three fields bound
to those variables. The run completed successfully; the Search Records
step returned exactly 2 records (limit applied) filtered by `createdAt
>= sinceDate`, confirming the backend resolves each variable.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23696?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-08-03 12:32:06 +00:00
neo773 00ad1544d8 Classify OAuth refresh errors by reason instead of status code (#23705)
Both provider parsers treated unrecognised failures as permanent, so a
single transient error marked a working account as needing reconnection
and it never recovered on its own. Permanence is now decided by the
provider's OAuth error code, everything else is temporary and retries.

Checked against prod: 15 connected accounts currently flagged
auth-failed still return a valid token when refreshed, and 12 of those
were flagged in bursts across unrelated workspaces (five within 90
seconds on 2026-01-13), which points at a transient blip rather than
users revoking access.

---------

Co-authored-by: neo773 <huzef@twenty.com>
2026-08-03 11:43:00 +00:00
neo773 7c797ff0c9 Filter connected account webhook renewal to exclude accounts with failed authentication errors (#23694)
/closes TWENTY-SERVER-J1F

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23694?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: neo773 <huzef@twenty.com>
2026-08-03 09:43:54 +00:00
Thomas Trompette ad8830ecbf fix(server): stop FIND_RECORDS from silently ignoring its filter (#23640)
## Problem

A FIND_RECORDS workflow step with a filter configured could silently
return every record (and thus the first row of the table) instead of
applying the filter. Ways to hit it:

- `recordFilters` set but `recordFilterGroups` omitted (e.g. an
API/agent caller, or any non-UI config).
- A grouped filter (carrying `recordFilterGroupId`) whose
`recordFilterGroups` is missing.
- A filter referencing an unknown `fieldMetadataId` or an unresolvable
relation (`turnRecordFilterIntoRecordGqlOperationFilter` returns
`undefined`, silently dropped).
- `gqlOperationFilter` set, which passed validation but was never read.

In every case the computed filter collapses to `{}`,
`FindRecordsService` returns all records ordered by `id ASC`, and
`records[0]` is the first row. This is fail-open: the step reports
success and returns wrong records rather than erroring.

## Fix

- Compute the filter whenever `recordFilters` is non-empty, defaulting
`recordFilterGroups` to `[]`. `computeRecordGqlOperationFilter` handles
ungrouped filters independently of groups.
- **Fail closed**: if `recordFilters` is non-empty but the computed
`gqlOperationFilter` is empty, throw `INVALID_STEP_INPUT` instead of
running an unfiltered query. This covers grouped-without-groups and
unknown-field/unresolvable-relation cases raised in review. An absent or
empty `recordFilters` still legitimately means "find all".
- Remove the unused `gqlOperationFilter` field from the find-records
input type and settings schema so it is no longer advertised as a filter
option (it has been dead since #16147, when the action moved to
computing the filter at runtime from `recordFilters`).
2026-08-03 08:24:44 +00:00
Félix Malfait f663cd3c68 Move open-record-in to object metadata and member preference (#23614)
Replaces the per-view "Open in" setting with a two-level model,
following up on #23422 / #23424 and superseding the closed #23446 and
#23457:

- `objectMetadata.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` |
`USER_CHOICE` (default `USER_CHOICE`)
- `workspaceMember.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` (default
`SIDE_PANEL`), editable in Settings > Experience

The rule: records open where the member prefers, unless the object pins
them, and never in a panel there is no room for (mobile always resolves
to the record page).

## Why

Having the setting on views, objects and members at once was heavy, and
view-level resolution was fragile: a chip rendered outside a view
(notes, front components, kanban cards pointing at another object) had
no view to read from, which is the class of bug behind #23422.
Resolution is now context-free: it needs only the object, the current
member and the viewport, so chips behave identically everywhere by
construction.

## Changes

**Object level**
- New `openRecordIn` enum column on `objectMetadata`, editable through
`updateOneObject` and surfaced in Settings > Data model > Object >
Layout ("Open records in": Member preference / Side Panel / Record Page)
- Standard definitions pin `workflow`, `workflowVersion`, `dashboard`
and `messageCampaign` to the record page (matching the previously
hardcoded list) and `calendarEvent` to the side panel (it has no curated
record page); everything else, including `workflowRun`, follows the
member preference
- Apps can set it in `defineObject()` via the object manifest

**Member level**
- New `openRecordIn` standard field on `workspaceMember`, persisted
through the existing settings path (same as `colorScheme`) and exposed
in Settings > Experience

**View level (deprecated)**
- `view.openRecordIn` is no longer read or written by the frontend; the
"Open in" entry is gone from the view options dropdown
- The column, DTO field and inputs are kept for one release for API
compatibility: the output field carries a `deprecationReason`, the
inputs keep accepting the value with a `Deprecated:` description (NestJS
silently drops input fields that have a `deprecationReason`, which would
have been a breaking change)

**Upgrade (2.27)**
- Fast instance command adds the `objectMetadata.openRecordIn` column
defaulting to `USER_CHOICE`
- Workspace command adds the `workspaceMember.openRecordIn` field
- Workspace command seeds the object column from the standard
definitions (any non-`USER_CHOICE` value), then lifts deliberate
per-view record page choices onto objects the definitions don't pin

**Debt removed**
- `canOpenObjectInSidePanel` hardcoded object list and its test
- `ObjectOptionsDropdownLayoutOpenInContent` and the `layoutOpenIn`
dropdown wiring
- `DefaultViewOpenRecordIn`
- Context-store/view-based resolution in `useResolveOpenRecordIn` (now
reads object metadata + member + viewport)
- Front components no longer guess from the current view: an explicit
side-panel call honours a pinned object and the viewport, nothing else

## Verification

- Ran the three upgrade commands against a live database: column
created, the pinned standard objects seeded per workspace (record page
pins plus calendarEvent to side panel), member field backfilled to
`SIDE_PANEL`; seed rerun is a no-op
- Seed command verified on a simulated pre-upgrade workspace (index view
set to record page on company): pins the standard objects plus company,
idempotent on rerun
- Both packages typecheck and lint clean; affected unit suites and the
application sync, view creation and metadata cache integration specs
pass

---------

Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
2026-07-31 18:28:55 +02:00
nitin 079e9b8e56 feat(dashboard): extend number format option to bar, line and pie charts (#23505)
https://discord.com/channels/1130383047699738754/1509604545381142649

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

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

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

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


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



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

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

## How it works

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

## App-owned orchestration

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

## Notes

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

## Follow-ups

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

## Screenshots


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


<img width="426" height="686" alt="Screenshot 2026-07-27 at 3 58 38 PM"
src="https://github.com/user-attachments/assets/b0a62e7c-c5e4-4c96-9389-5e47d7ef8c77"
/>
<img width="1053" height="726" alt="Screenshot 2026-07-29 at 12 54
45 AM"
src="https://github.com/user-attachments/assets/4e14b3fb-fbe5-4f4d-a380-cc45cc60a01a"
/>
2026-07-30 09:36:38 +00:00
Etienne a276f3277f feat(workflow): pin concrete model on AI agent node creation and exclude interactive tools from workflow runs (#23447)
## Context

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

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

## What this PR does

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

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

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

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

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

## Test coverage

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23447?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-29 16:48:24 +00:00
nitin 742f76e318 [BREAKING-CHANGE] Add NOT_RECORDED call recording status (#23478)
Adds NOT_RECORDED to the CallRecording status select, for meetings where
nothing was captured (bot never admitted, meeting not started, nobody
joined). First part of twentyhq/core-team-issues#2706, split out so
existing workspaces are upgraded before the call-recorder app starts
writing the new status.

- NOT_RECORDED enum value + standard select option
- 2-26 workspace upgrade command adding the option to existing
workspaces (idempotent, same option id as the standard definition)

App-side classification from Recall sub codes comes in a follow-up PR.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23478?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-29 14:44:51 +00:00
Thomas Trompette 198e3969df feat(workflow): read workflow version content from core in the engine, behind a flag (#23403)
## Scope: engine only

Behind the existing `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` flag (**off by
default**), `getWorkflowVersionOrFail` sources a version's `trigger` /
`steps` / `status` from `core.workflowVersion` instead of the workspace
row. That covers its 11 call sites: workflow build, validation, schema,
run, and trigger dispatch.

**The UI is not switched here.** `useWorkflowVersion` and the content
fetch in `useWorkflowWithCurrentVersion` still go through generic object
CRUD against the workspace columns. That work needs the client to stop
treating the workspace record as the home for content, and is planned
separately around `flowComponentState` (the jotai state the builder
already reads from). It is the read that must land before the workspace
`trigger` / `steps` columns can be dropped.

## Why an overlay, not a repository swap

`core.workflowVersion` is a content-only projection: its own `id` (not
the workspace version id), `workflowId`, `triggers[]`, `steps[]`,
`status`. No `name`, no `position`. So core cannot fully back the
entity.

The flip is therefore an overlay: identity, name and position stay from
the workspace row, and only content comes from core (`triggers[0] ->
trigger`).

## Reversibility

Falls back to workspace content when the flag is off (default), when the
version has no `coreWorkflowVersionId` soft-ref, or when the core row is
missing. Merging changes nothing until the flag is enabled per
workspace, and it can be flipped back at any time.

## Verification

- `nx typecheck twenty-server` green, `oxfmt` + `oxlint --type-aware`
green
- Unit test covering four branches: flag off, flag on with the core row
present (overlays content **and** preserves `id` / `name` from the
workspace row), flag on with the core row missing (falls back on
`trigger`, `steps` and `status`), flag on with no soft-ref (skips the
core read)

## Enablement gate

Do not enable the flag in any workspace until the drift dashboard
reports zero drift for `core.workflowVersion` and legacy drift is
repaired. Enabling before that turns latent drift into live dispatch
behaviour.
2026-07-29 08:07:08 +00:00
Abdul Rahman 6c66d4c862 fix(ai-agent): use a non-workflow base system prompt for programmatic agent runs (#23394)
`AgentAsyncExecutorService` hardcoded `WORKFLOW_SYSTEM_PROMPTS.BASE`, so
every caller was told "You are executing as part of a workflow
automation" and "your output may be used by downstream workflow nodes".
That is only true for the workflow AI-agent action. The `runAgent` API
(used by apps such as the call recorder) and agent evaluations got the
same framing, which does not describe how they run or where their output
goes.

The executor no longer asserts its own execution context: `executeAgent`
now takes a required `baseSystemPrompt` and each caller supplies its
own.

- Workflow AI-agent action passes `WORKFLOW_SYSTEM_PROMPTS.BASE`
(unchanged behavior)
- `runAgent` and evaluations pass the new `AGENT_RUN_BASE_SYSTEM_PROMPT`

The param is required rather than defaulted so every call site states
its context and no future caller silently inherits the wrong one.

Prompt constants are also split one export per file, with the shared
tool-usage guidance extracted into `TOOL_USAGE_STRATEGY` so both bases
compose it.

No GraphQL schema, SDK, or database changes.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23394?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-28 11:53:31 +00:00
neo773 1e58c3073c Feat/email composer improvements (#23188)
- Move composer to dedicated page
- Add test email option
- Auto saved as draft can be revisited from `objects/messageCampaigns`
later
- Campaign stats component



https://github.com/user-attachments/assets/9e523116-e79b-496d-9c9d-3887e0c9213f



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23188?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: Félix Malfait <felix.malfait@gmail.com>
2026-07-28 13:13:00 +02:00
Etienne 74260a161d fix(ai): stop double-counting cache-creation tokens in reported token totals (#23405)
## Context

Under AI SDK v6 usage normalization, `usage.inputTokens` is the **full
prompt**: fresh (noCache) + cache-read + cache-creation tokens. Our
`totalTokens` formulas still added `cacheCreationTokens` (extracted from
provider metadata) on top of `inputTokens` — a leftover from the pre-v6
SDK generation, where flat `inputTokens` excluded cache tokens. The v6
upgrade changed the semantics under the formula's feet, so every Claude
run using prompt caching reported a `totalTokens` inflated by exactly
`cacheCreationTokens`.

## Evidence, traced through AI SDK source

**1. The Anthropic provider folds cache tokens into `inputTokens`.** The
raw Anthropic API reports `input_tokens` *excluding* cache tokens; the
provider sums all three components — [`convertAnthropicMessagesUsage`,
`@ai-sdk/anthropic@3.0.84`](https://github.com/vercel/ai/blob/%40ai-sdk/anthropic%403.0.84/packages/anthropic/src/convert-anthropic-messages-usage.ts):

```ts
inputTokens: {
  total: inputTokens + cacheCreationTokens + cacheReadTokens,
  noCache: inputTokens,
  cacheRead: cacheReadTokens,
  cacheWrite: cacheCreationTokens,
}
```

**2. ai core surfaces that total as the app-visible
`usage.inputTokens`** — [`asLanguageModelUsage`,
`ai@6.0.97`](https://github.com/vercel/ai/blob/ai%406.0.97/packages/ai/src/types/usage.ts):

```ts
inputTokens: usage.inputTokens.total,
...
totalTokens: addTokenCounts(usage.inputTokens.total, usage.outputTokens.total),
```

So the SDK's own `totalTokens` is already "full prompt (incl. cache read
+ creation) + output".

**3. The value we were adding on top is the same one already inside
`inputTokens`.** The provider also exposes the raw API field in metadata
(`@ai-sdk/anthropic` dist):

```ts
const anthropicMetadata = {
  usage: response.usage,
  cacheCreationInputTokens: response.usage.cache_creation_input_tokens ?? null,
  ...
```

`extract-cache-creation-tokens.util.ts` reads exactly
`providerMetadata.anthropic.cacheCreationInputTokens` — the same
`cache_creation_input_tokens` that step 1 already folded into
`inputTokens.total`. Adding it again counts it twice.

**Worked example** (matches the new pinning test): API returns
`input_tokens: 400, cache_read_input_tokens: 600,
cache_creation_input_tokens: 200, output_tokens: 500` → app sees
`usage.inputTokens = 1200`,
`providerMetadata.anthropic.cacheCreationInputTokens = 200` → old
formula reported `1200 + 500 + 200 = 1900`; actual tokens processed:
`1700`.

All snippets are verbatim from the version tags in `vercel/ai` and match
the installed `node_modules` dists.

## Provider independence

`inputTokens + outputTokens` is correct for every provider Twenty routes
through, not just Anthropic:

- The v3 provider spec (`@ai-sdk/provider`) defines `inputTokens.total`
as "the total number of input (prompt) tokens used", with
`noCache`/`cacheRead`/`cacheWrite` as its components — and all 8
installed provider packages comply (verified in dists): `anthropic` and
`amazon-bedrock` sum the components explicitly ([`convertBedrockUsage`,
`@ai-sdk/amazon-bedrock@4.0.117`](https://github.com/vercel/ai/blob/%40ai-sdk/amazon-bedrock%404.0.117/packages/amazon-bedrock/src/convert-bedrock-usage.ts):
`total: inputTokens + cacheReadTokens + cacheWriteTokens`); `openai`,
`azure`, `google`, `mistral`, and `openai-compatible` pass through wire
values that already include cached tokens; `xai` even detects which wire
convention the API used and normalizes either way.
- The removed `cacheCreationTokens` term was already 0 for every
provider except Anthropic/Bedrock
(`extract-cache-creation-tokens.util.ts` only reads those two metadata
namespaces), so this PR is a strict no-op for OpenAI-style providers and
only removes the double-count where it existed.

Caveat: a custom `AI_PROVIDERS` entry pointing at a legacy V2-spec
provider package bypasses this normalization (ai core's shim passes flat
usage through verbatim); that path could misreport under any formula,
and none of the built-in providers use it.

## What changed

Four sites computed the inflated total:

- `ai-billing.service.ts` — `quantity` on the emitted AI token usage
event
- `chat-execution.service.ts` — chat-turn usage event
- `agent-async-executor.service.ts` — workflow-agent usage event
- `build-ai-agent-step-log.util.ts` — workflow step log (display)

The first three now compute `totalTokens = inputTokens + outputTokens`;
the step-log util uses the SDK's `usage.totalTokens` directly (it
receives the `generateText` usage object, where the field is
guaranteed). The explicit sum is used where usage objects are
hand-assembled or merged — e.g. the streaming path in
`stream-agent-chat.job.ts` builds usage literals with no `totalTokens`
field at all, so `usage.totalTokens ?? 0` would silently emit 0. Both
forms are definitionally identical where the SDK object exists, since ai
core computes `totalTokens` as `input + output` (see evidence above).

**Impact: reported/analytics quantities only.** Billed credits
(`creditsUsedMicro`) come from `computeCostBreakdown`, which already
handles the cache-inclusive convention correctly and is unchanged.

**Ops note:** `usageEvent.quantity` for cache-heavy workspaces steps
down on deploy — dashboards trending this metric may want an annotation.
Historical rows are not backfilled (per-row component fields aren't
stored, so mixed-era rows can't be reliably corrected).

## How tested

- Updated `build-ai-agent-step-log.util.spec.ts` expectation (155 → 150
with `cacheCreationTokens: 5` still present)
- New pinning test in `ai-billing.service.spec.ts`: emitted `quantity`
is 1700 (not 1900) for inclusive Anthropic usage with
`cacheCreationTokens: 200`
- New pinning test in `agent-async-executor.service.spec.ts`: emitted
total is 150 (not 180) when steps carry
`providerMetadata.anthropic.cacheCreationInputTokens`
- 3 suites / 20 tests pass; oxlint, oxfmt, and `nx typecheck
twenty-server` clean

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23405?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-28 09:59:57 +00:00
Thomas Trompette 2c49c4169c feat(workflow): remove async workflowVersion core dual-write listener (#23374)
## What

Removes `WorkflowVersionCoreDualWriteListener` (and its now-empty
module), replacing the last async best-effort core writes for workflow
versions with synchronous mirrors. Adds one missing synchronous funnel
so nothing is left uncovered.

## Why

After #23356, the listener's `handleRestored` / `handleDeleted` /
`handleDestroyed` handlers are redundant with the synchronous lifecycle
mirror, so the async path (which can silently drift on failure) can go.

While removing it I found one path the listener was **not** redundant
on: **direct `deleteOneWorkflowVersion` (discard draft)** is an allowed
operation (discard a DRAFT version that isn't the only version, via the
`DISCARD_DRAFT_WORKFLOW` command) and had **no** synchronous post-hook.
The async listener was the sole thing deleting its core row. Removing
the listener without a replacement would have drifted on every draft
discard.

So this PR also adds a `workflowVersion.deleteOne` post-hook that
mirrors the deletion to core.

## Coverage after this change

| version lifecycle path | synchronous coverage |
| --- | --- |
| `deleteOneWorkflowVersion` (discard draft) | **new**
`workflowVersion.deleteOne` post-hook |
| delete via workflow cascade | `handleWorkflowSubEntities` ->
`deleteCoreVersionsByWorkflowIds` (#23356) |
| restore via workflow cascade | `handleWorkflowSubEntities` ->
`recreateCoreVersionsByWorkflowId` (#23356) |
| destroy via workflow | `workflow.destroy*` post-hooks (#23356) |
| `deleteMany` / `destroyOne|Many` / `restoreOne|Many` version | blocked
by pre-hooks ("Method not allowed") |

## Notes

- The new post-hook re-fetches the version (`withDeleted`) to resolve
its `coreWorkflowVersionId`, because the delete post-hook payload only
carries the columns the client selected (the delete `RETURNING` set is
built from `selectedFieldsResult.select`), so `coreWorkflowVersionId` is
not reliably present.
- `deleteCoreVersionsByWorkspaceVersionIds` deletes precisely by
`coreWorkflowVersionId` (not by `workflowId`), so discarding one draft
does not touch the core rows of the workflow's other versions.
- The workflow-side `WorkflowCoreSyncModule` listener is intentionally
left in place (separate migration track).

## Verification

- `nx typecheck twenty-server` green
- `nx lint:diff-with-main twenty-server` green
- Added integration test `workflow-version-discard-draft-core-mirror`:
activate v1, create a draft, discard it, assert only the draft's core
row is removed and the active version's core row remains.
- Live run on a dev instance still pending.

## Merge gate

Per the migration plan, removing the async backstop should land only
after the drift cron reports zero drift over a soak period.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23374?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-27 15:52:15 +00:00
Thomas Trompette 79bf20515d feat(workflow): mirror version delete/restore/destroy to core transactionally (#23356)
Next step in the workflowVersion -> core soft-ref migration. The
transactional mirror (#23243) made **content** writes (create/update)
drift-free. This does the same for the **lifecycle** events (delete /
restore / destroy), which were still handled only by the async
best-effort listener. It's the prerequisite for dropping that listener.

## What changed
`delete` and `restore` already soft-delete / restore the workflow's
versions inside `handleWorkflowSubEntities` (twenty doesn't cascade
soft-deletes, so it does each sub-entity explicitly). So the core
delete/recreate just sits next to the existing version write:

- **delete** — after `workflowVersionRepository.softDelete({ workflowId
})`, `deleteCoreVersionsByWorkflowIds` removes the
`core.workflowVersion` rows (`workflowId IN (...)`).
(`deactivateVersionOnDelete` no longer re-mirrors the deactivated
version — that was recreating the core row it just deleted; it only
flips the workspace status to `DEACTIVATED` so a restore comes back
deactivated.)
- **restore** — after `workflowVersionRepository.restore({ workflowId
})`, `recreateCoreVersionsByWorkflowId` re-reads the restored versions
and reuses the existing `upsertToCore` (which reuses the stored
`coreWorkflowVersionId` soft-ref, so rows come back with their original
ids and current status).
- **destroy** — version destroy isn't done in
`handleWorkflowSubEntities` (it happens via the generic cascade), so
there's no existing place to hang the core delete. New
`workflow.destroyOne`/`destroyMany` **post**-hooks call
`deleteCoreVersionsByWorkflowIds` (batched `IN`) only after the destroy
commits, so a rejected destroy can't remove core rows while the
workspace versions survive.

No new transactional wrappers or raw SQL — the delete/recreate reuse the
existing `WorkflowVersionCoreSyncService` methods
(`deleteFromCore`-style delete, `upsertToCore`). The async listener
stays as an idempotent backstop until the cron soaks zero drift.

## Async listener kept as backstop
`handleRestored` / `handleDeleted` / `handleDestroyed` stay for now.
Both paths are idempotent (delete-of-deleted is a no-op; upsert
converges), so they don't conflict. Those handlers come out in a
follow-up once the consistency cron soaks zero drift - which this PR
unblocks.

## Verification
Lifecycle integration test — creates a workflow, **activates** the
version (the active path is where the delete re-mirror bug bit), then
asserts the core row: present -> gone after delete -> back after restore
(as `DEACTIVATED`, same id) -> gone after destroy. Plus the existing
`workflow-resolver` delete/restore suite (regression, since
`handleWorkflowSubEntities` is shared).

Also verified **live** on a running instance against the real DB: the
full active-version lifecycle above, plus a batched `destroyWorkflows`
on two workflows removing both core rows in one `IN` delete. `nx
typecheck` + oxlint + oxfmt clean.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23356?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-27 14:43:59 +00:00
Weiko 0c57d7c108 perf: reuse Google webhook OAuth client (#23361)
## Context
A new client currently downloads signing certificates for every webhook

## Fix
reuse same OAuth client instance

## Impact
Probably small but not really risky to merge imho
2026-07-27 13:42:59 +00:00
Thomas Trompette 46a3a83866 feat(workflow): mirror all workflowVersion writes to core in-transaction, drop async create/update dual-write (#23243)
Switches the workflowVersion -> core dual-write from the async,
best-effort listener to a **transactional mirror**, wires every
content-write funnel through it, and drops the async create/update
handlers. Core can no longer drift from the workspace on any covered
path: the core copy commits or rolls back atomically with the workspace
write.

## Helper (`WorkflowVersionCoreSyncService`)
Two entry points, both writing `core.workflowVersion` and stamping the
`coreWorkflowVersionId` soft-ref on the **caller's transaction
manager**:

- `writeWorkflowVersionAndMirror(workspaceId, write)` - for funnels that
don't own a transaction. Opens a workspace queryRunner, runs the
caller's workspace write on that manager, re-reads the row, mirrors to
core in the same tx, commits, then invalidates the trigger-map cache
post-commit.
- `mirrorWorkflowVersionWrite({ workspaceId, entityManager,
workflowVersion })` - for funnels that already own a queryRunner tx
(activation / deactivation / delete-cascade); they just gain this one
call on their existing manager before commit.

`invalidateAutomatedTriggerMaps` is public so tx-owning callers run it
post-commit.

## Funnels wired (all content writes now mirror in-transaction)
- `updateWorkflowVersionStepsAndTrigger` (central builder step/trigger
edit)
- edge create/delete (4 trigger/step writes)
- `createDraftFromWorkflowVersion` (update + insert),
`duplicateWorkflow` (content update), `updateWorkflowVersionPositions`
- iterator / if-else empty-node step writes
- `workflow.createOne` / `createMany` post-hooks (v1 draft insert)
- AI `create_complete_workflow` tool (v1 insert)
- activation / deactivation status writes (ACTIVE / ARCHIVED /
DEACTIVATED) on their existing tx
- `deactivateVersionOnDelete` cascade (ACTIVE -> DEACTIVATED) on its
existing tx

Direct `workflowVersion.createOne/createMany` is forbidden by a
pre-hook, so there is no un-funneled create path.

## Async listener trimmed
`handleCreated` and `handleUpdated` are removed: every create/update now
mirrors in-transaction, so the post-commit handlers were redundant and
were the source of the rollback-drift (an edit that rolls back still
emitted an `UPDATED` event carrying the uncommitted payload, which the
async listener wrote to core).

`handleRestored`, `handleDeleted`, `handleDestroyed` are **kept**.
Soft-delete/restore go through the generic ORM (not a funnel):
`handleDeleted` drops the core row on soft-delete, and `handleRestored`
recreates it on restore (the restore path just calls
`workflowVersionRepository.restore()`). Removing the restore handler
would leave restored versions with no core row, so the delete/restore
pair stays async.

## Why the core write is raw SQL
`core.workflowVersion` is on the core DataSource, not the workspace
DataSource, so a repository can't be pointed at it from the workspace
queryRunner's manager. But both schemas are one Postgres DB and a
queryRunner is a single connection, so a schema-qualified `INSERT INTO
core."workflowVersion" ... ON CONFLICT` on that manager participates in
the workspace transaction (the prefill util's pattern). The workspace
write and soft-ref write-back go through the ORM's
`repository.update(criteria, data, undefined, queryRunner.manager)`, so
the source-of-truth workspace write keeps its ORM machinery (actor
stamping, search vector, events); only the dumb core mirror is raw,
confined to the helper.

**jsonb:** the raw insert has no entity transformer, so
`triggers`/`steps` are passed as JSON strings (Postgres parses them) -
the inverse of the prefill core insert (the #23204 double-encode trap),
covered by the test. `universalIdentifier` is minted only for a new
link; on conflict only `triggers`/`steps`/`status` are updated.

## Test
In-process integration test: opens a workspace queryRunner, calls the
helper, asserts the core row is visible inside the tx with correct
native jsonb, rolls back, asserts the core row is gone - empirically
confirming one workspace queryRunner writes `core.*` in the same tx.

## Review feedback addressed
- **Duplicate atomicity:** `duplicateWorkflow` now inserts the draft
version with its final steps/trigger inside
`writeWorkflowVersionAndMirror`, so a mirror failure rolls back the
whole version instead of leaving an unmirrored empty draft.
- **Delete cascade:** `deactivateVersionOnDelete` moved its command-menu
cleanup after commit, so a rolled-back deactivation can no longer strip
the menu item from a still-active version.
- **Rollback drift / unlinked rows:** resolved structurally by the
in-transaction mirror + removal of the async CREATED/UPDATED handlers,
and by the soft-ref-field guard that skips mirroring (returns null) on
workspaces without `coreWorkflowVersionId`.
- **Unit suites:** the step-helpers, step-operations and edge suites now
provide a `WorkflowVersionCoreSyncService` mock whose
`writeWorkflowVersionAndMirror` runs the write callback against the test
repo; all three pass (35 tests).

## Verification
Rebased onto `origin/main`. `nx typecheck twenty-server` is green, the
three affected unit suites pass (35 tests), and every changed file
passes type-aware oxlint + oxfmt. Integration suites were failing only
on behind-main DB-init drift (`core.keyValuePair` / data-migration),
which the rebase resolves. Live end-to-end test on a running instance
still pending.
2026-07-27 09:17:17 +00:00
Weiko 42c598a33a Filter stale sync workspaces by active channels (#23321)
## Context

The hourly calendar and messaging stale-sync crons currently load every
active workspace and enqueue one recovery job per workspace. Each
recovery job then enters the workspace context and queries its channels,
even when that workspace has no stale sync.

As the number of workspaces grows, the cost of this check grows with
every workspace rather than with the number of syncs that actually need
recovery. Because both crons run on the hour, this also creates a
synchronized burst of mostly unnecessary queue, database, and
workspace-context work.

## What changes

The crons now use TypeORM repository `find` operations on
`calendarChannel` and `messageChannel` before enqueueing recovery jobs:

- Select only `workspaceId` from matching channels.
- Keep only active, non-deleted workspaces.
- Keep only the scheduled and ongoing stages handled by the existing
recovery jobs.
- Treat a sync as stale when `syncStageStartedAt` is null or older than
the existing 30-minute timeout.
- Deduplicate workspace IDs in memory before enqueueing.
- Enqueue the existing recovery job only for matching workspaces.
- Share the stage definitions between candidate selection and recovery
so they cannot drift independently.

## Before and after

Before:

1. Load every active workspace.
2. Enqueue a calendar job and a messaging job for each workspace.
3. Enter every workspace context.
4. Query its channels.
5. Usually find nothing to recover.

After:

1. Run one candidate lookup for calendar channels and one for message
channels.
2. Return only the workspace ID for each potentially stale channel.
3. Deduplicate those IDs and enqueue one job per affected workspace.
4. Let the existing recovery jobs recheck and recover those channels.

Database reads now scale with stale channel candidates, while queue and
workspace-context work scale with affected workspaces rather than the
total workspace count.

## Why deduplicate in memory

TypeORM repository find options do not provide `DISTINCT`, so these
lookups can return the same workspace ID once per stale channel. A `Set`
removes those duplicates before jobs are created.

This is a deliberate tradeoff:

- The database returns only UUIDs, not full channel records.
- The scans run hourly against channel tables, and stale candidates
should remain sparse.
- It keeps the query expressed through the typed repository API.
- It avoids adding a database sort or hash aggregation for `DISTINCT`.

If candidate volume becomes large enough for result transfer or
in-process deduplication to matter, database-side deduplication can be
moved into a dedicated repository query. A practical signal would be
tens of thousands of candidates per run, a high duplicate ratio, or
measurable lookup and event-loop latency.

## Safety

- The recovery jobs and sync-state transitions are unchanged.
- The candidate lookup uses the same stage lists and timeout constants
as the recovery jobs.
- Recovery jobs still recheck staleness after dequeueing, which protects
against a channel recovering between candidate selection and execution.
- Jobs are still enqueued sequentially with per-workspace exception
handling.
- Disabled and group channels are not newly excluded, preserving the
previous recovery behavior.

## Scope

This only changes hourly stale-sync detection. It does not change normal
calendar or messaging scheduling, imports, retry policies, or recovery
state transitions.

## Testing

Added unit coverage for:

- active and non-deleted workspace filtering,
- selecting only workspace IDs,
- scheduled and ongoing stage filtering,
- null or expired `syncStageStartedAt`,
- the configured stale timeout,
- application-side workspace deduplication,
- enqueueing one recovery job per affected workspace.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23321?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-27 08:56:28 +00:00
neo773 3fb29db28a Feat/email settings v2 (#23180)
Settings pages changes

- Add `displayName`
- Unsubscribers Page

<img width="1496" height="844" alt="Screenshot 2026-07-22 at 8 52 15 PM"
src="https://github.com/user-attachments/assets/69bc1993-4547-4a64-83a6-b47fef1a4e40"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23180?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: Félix Malfait <felix.malfait@gmail.com>
2026-07-26 22:48:30 +02:00
Thomas Trompette cbcfba0de2 feat(workflow): dedicated updateWorkflowVersionTrigger mutation + close version CRUD holes (#23207)
Prerequisite for the workflow-core soft-ref migration: every
`workflowVersion` content write must go through a dedicated,
draft-guarded server mutation so it can later be wrapped in a
transactional core mirror. This closes the generic-CRUD holes that let
writes bypass that path.

## Part A - dedicated `updateWorkflowVersionTrigger` mutation
The builder saved a version's trigger through generic
`updateOneWorkflowVersion` - the only content write not going through a
dedicated mutation. Added:
- Server: `updateWorkflowVersionTrigger(input: { workflowVersionId,
trigger })` resolver +
`WorkflowVersionStepWorkspaceService.updateWorkflowVersionTrigger`,
draft-guarded via `getValidatedDraftWorkflowVersion` then
`updateWorkflowVersionStepsAndTrigger` (reuses existing write logic).
- Front: `useUpdateWorkflowVersionTrigger` now calls the dedicated
mutation instead of `useUpdateOneRecord`.

## Part B - restrict generic `updateOneWorkflowVersion`
`validateWorkflowVersionForUpdateOne` previously allowed writing
`trigger`, `position`, `workflowId` (re-parenting),
`coreWorkflowVersionId`, and let `steps: null` slip through on a draft.
It now rejects any update that sets `steps`, `trigger`, `status`,
`workflowId`, or `coreWorkflowVersionId`, or that clears the `name`,
while still allowing a plain rename. (A name-only allowlist was tried
first but blocked legitimate renames - at the pre-hook the generic
update payload is not single-key - so it was replaced by this denylist,
verified live.)

## Part C - close the destroy/restore hole
`workflowVersion` had no `destroyOne/destroyMany/restoreOne/restoreMany`
query hooks, so a caller with object permission could hard-destroy any
version (including active) or resurrect one with no validation. Added
pre-hooks that forbid all four via the API ("Method not allowed"),
matching the existing forbidden generic mutations (`createOne`,
`deleteMany`, ...). Rationale: there is no legitimate API use for
standalone version destroy/restore - retention purging happens through
the trash-cleanup cron (internal, not hook-gated) and restore happens
through the workflow-restore cascade or create-draft-from-version.

## Tests
Integration specs that set a trigger through the generic mutation were
migrated to the new `updateWorkflowVersionTrigger` mutation (new
`update-workflow-version-trigger.util.ts`). Unit test for the front hook
updated.

## Verification
- `twenty-server` + `twenty-front` typecheck: clean.
- oxlint + oxfmt on all changed files: clean.
- `graphql.ts` regenerated for the new mutation; its types match the
server DTOs exactly. Local `graphql:generate` introspects a running
server, so it only succeeds against a server built from this branch - CI
regenerates against the PR server and verifies.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23207?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: prastoin <paul@twenty.com>
2026-07-24 14:32:09 +00:00
Raphaël Bosi d1c6b8ee72 Show relation record labels instead of UUIDs in dashboard charts (#23163)
https://github.com/user-attachments/assets/d012a013-2c90-49a1-a27e-b8e4b684a84f



Charts grouped by a relation without a sub-field rendered raw FK UUIDs
on axis ticks, legends and tooltips. The server now batch-resolves the
grouped record ids to their label identifier through a permission-scoped
query and formats every bucket with the record's display name.
Unresolvable records (deleted or not readable) render as Unknown and
their ids are stripped from the response payload. Same-named records get
an ordinal suffix so their buckets don't merge. Covers bar, line and
pie, plain and morph relations.

```mermaid
flowchart TD
    A["Dashboard widget load"] --> B["Chart data service<br/>(bar / line / pie)"]
    B --> C["executeGroupByQuery:<br/>group by relation FK id,<br/>ORDER BY target label identifier,<br/>scoped to source object permissions"]
    C --> D["filterOutEmptyChartBuckets"]
    D --> E{"Bare relation axis?<br/>(no sub-field)"}

    subgraph RL["ChartRelationLabelService.resolveRelationLabels"]
        direction TB
        G1["Collect distinct record ids<br/>per target object"] --> G2["Batch SELECT label identifier columns,<br/>scoped to TARGET object permissions"]
        G2 --> G3["buildRawLabelByRecordId:<br/>display name per record"]
        G3 --> G4["buildUniqueRelationLabels:<br/>suffix duplicates, Unknown for unresolved"]
    end

    E -- No --> H["formatDimensionValue per bucket"]
    E -- Yes --> G1
    G4 --> H
    H --> I["Strip unresolved ids from<br/>formattedToRawLookup"]
    I --> J["Chart DTO to frontend"]
```

The chart settings sub-field dropdown gains a Record option to group by
the related record itself, and now only offers sub-fields the backend
accepts (system fields like a workspace member's updatedBy were
selectable but rejected at query time). Chart-data errors are now logged
server-side.

Also fixes two latent bugs on this path: sorting a bare-relation chart
by field threw `Cannot orderBy unknown field: agentId`, and the pie
chart truncated slices before sorting. The AI dashboard tool guidance
and the seeded dashboards no longer force the sub-field workaround.

The group-by query orders buckets by the related record's label
identifier at the database level (the engine now accepts ordering by a
target field when grouping by its id), so with more than 100 distinct
related records the surviving buckets match the label order.
2026-07-24 14:12:22 +00:00
Thomas Trompette 96a2456367 feat(workflow): periodic core-consistency check for workflows, versions and triggers (#23103)
Monitoring for the soft-ref migration. The `workflow` /
`workflowVersion` dual-write into core is **best-effort** (async, not
transactional; failures only go to Sentry), so `core.workflow` /
`core.workflowVersion` can silently drift from the workspace source of
truth. This adds a periodic job that detects that drift across **all
three workflow entities** and emits it as metrics.

Supersedes the earlier inline shadow-parity approach that lived on this
branch — that only covered trigger dispatch and added a cache read +
diff to every cron tick and every DB-event batch (too much hot-path
overhead). This is broader and fully off the dispatch path.

## What
A cron (`cron:workflow:core-consistency-check`, every 3 hours, wired
into `cron:register:all`). Per run:
- **Bounded**: `SELECT DISTINCT "workspaceId" FROM core."workflow"` —
only workspaces that actually use workflows (skips the large majority).
- Per such workspace, emit a drift metric per `(entity, driftType)` —
detect-only, plus a triage log:
- **workflow** and **workflowVersion** — `unlinked` / `missingCore` /
`orphanCore` / `fieldMismatch`, via cross-schema `COUNT` aggregates
(core + workspace are the same DB, so indexed joins — no rows pulled
into JS).
- **automated triggers** — the `workflowAutomatedTrigger` table vs the
`workflowAutomatedTriggerMaps` cache: `inTableNotCache` /
`inCacheNotTable` / settings `mismatch`.
- Per-workspace failures are isolated (caught → Sentry) so one bad
workspace does not stop the sweep.

## Why it is efficient
Two central `core.*` queries + a few `COUNT` queries per
*workflow-using* workspace, on a relaxed cadence, off the dispatch path.
Shardable across ticks later if needed.

## Metrics
`workflow-core-consistency/{workflow,version,automated-trigger}/drift`
counters, attribute `driftType`. Dashboards: twentyhq/twenty-infra#800.

## Not in scope
Detect-only — no auto-heal (the existing backfill/rebuild command can
heal). No dispatch or flag changes.

## Test
- The consistency SQL (workflow/version sync counts, orphan counts,
trigger read) validated against a live workspace (it surfaced real drift
there — unlinked versions + an orphan core version). The whole
cron→service→SQL→metric pipeline is proven live: the cron is already
emitting real drift counters on a running server.
- Unit specs for the service (clean → no metric; per-entity drift per
dimension; per-workspace error isolation).
- Command boots and registers via `cron:register:all` (verified).
Typecheck + lint clean.
2026-07-23 12:30:12 +00:00
neo773 0489d39f2b fix(messaging): stop group and bulk emails from creating contacts (#23137)
Group addresses were only checked against the message sender, so
anything arriving as reply-to or cc slipped through and became a
contact. Now checked per participant at contact creation.

The group word list can't keep up with real senders (posts-recap@,
showinfo@, follow-suggestions@), so bulk-mail headers back it up:
`List-Unsubscribe, List-Id, Precedence, Auto-Submitted` (Industry
standard headers)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23137?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-22 17:17:45 +05:30
Raphaël Bosi 578d69e4fb Fix update events reporting untouched columns as changed (#22797)
## Before


https://github.com/user-attachments/assets/b02dbd50-87c9-4699-a6a9-254a4c8b9182

The image uploaded during the onboarding is deleted and doesn't appear
in the animation

## After


https://github.com/user-attachments/assets/9443b837-044e-47c6-b2df-c392c238e935

The Image appears in the animation

## Description

TwentyORM's `.save()` emits UPDATE events whose `after` record carries
default (empty) values for columns that weren't written: TypeORM
null-injects untouched nullable columns on the entity in place, and
`formatResult` turns those into empty strings. So a partial update (e.g.
renaming a workspace member) reports untouched fields like `avatarUrl`
and `userEmail` as changed, which is what made the avatar-file-deletion
listener delete the picture uploaded during onboarding. The same stale
data also reaches webhooks, workflow/logic-function triggers and record
subscriptions.

Fix: `save()` was the only write path building its event `after` from
the in-memory payload instead of re-reading the row. `update()`,
`upsert()` and `softDelete()` all re-SELECT after the write, so `save()`
now does the same.

`withDeleted` is on both the before and the after find, since `save({
id, deletedAt })` and `save({ id, deletedAt: null })` are valid
soft-delete and restore, and an asymmetric find would leave a restored
row with no matching before-record.

Worth noting: a genuinely no-op save now emits no update event, where it
previously emitted one with a bogus diff.
2026-07-21 17:44:18 +02:00
Félix Malfait 3ad3e8bd1a feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context

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

## Server / API

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

## Frontend

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

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

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

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

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

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

## Scope (v1)

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

## Tests

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

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-21 15:41:08 +02:00
Thomas Trompette 98c71d7b3d fix(server): recover workflow runs whose queue job was lost - monitoring only (#22995)
## Context

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

## What this PR does

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

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

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

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

### Make queue jobs traceable to their run

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

### Observability

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

## Out of scope (follow-ups)

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

## Testing

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

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

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

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23006?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-20 17:26:47 +05:30
Félix Malfait 7e133a4930 Converge email recipient fields on existing patterns: shared parser/formatter, search-index members, one display-name rule (#22997)
# Why

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

# What changed, per comment

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

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

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

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

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

# Verification

- New twenty-shared suites for the parser and formatter (16 tests),
including parse/format round-trips, the encoded-word case, and the
backslash-escaping containment case.
- Server messaging util specs all pass (70 tests), including new
group-flattening regression tests; From-header spec output unchanged.
- Front email module suites all pass (59 tests) with the slimmed
wrappers.
- Typecheck and lint green on twenty-shared, twenty-front,
twenty-server; oxfmt clean on all three.
- Playwright smoke against the seeded dev stack passes end to end:
context suggestions on the Google company, typed search showing people
and the workspace member row (now served by the search index), Enter
picking the top suggestion, duplicate merge, keyboard delete, chip menu
with clipboard copy, Ctrl+Enter committing the buffer then triggering
send.
2026-07-17 21:20:14 +02:00
neo773 f6612e5a85 fix(server): stop treating Microsoft Graph 401 as a permanent failure (#22989)
In production we are seeing some accounts being occasionally marked as
permanent failure even though when you check with their refresh token it
never actually failed this PR stops treating 401 as permanent failure
and treats them as Transient error.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22989?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-17 19:13:50 +05:30
neo773 52b7aebddf fix(server): key connected-account lookup on handle and provider (#22964)
Connect flows (Google, Microsoft, IMAP/SMTP/CalDAV) looked up an
existing connectedAccount by `handle` alone, so connecting a second
account with the same handle but a different provider overwrote the
first instead of inserting a new row (e.g. IMAP inbox clobbering a
calendar-only Google account).

Fix: add the `provider` discriminator to the lookup. Same
provider+handle still updates; a different provider gets its own row.
Integration test covers the Google-then-IMAP case.
2026-07-17 00:28:23 +05:30
Thomas Trompette f4b2968a74 fix(server): finalize workflow runs stuck in STOPPING (#22900)
## Context

A workflow run only reaches STOPPED via the in-flight worker execution:
`stopWorkflowRun` just flips a RUNNING run to `STOPPING` (records
intent), and the `STOPPING -> STOPPED` transition is done later inside
`computeWorkflowRunStatus`, which only runs while a worker is executing
the run's steps.

If no worker is executing the run at that point, nothing ever finalizes
it:
- the worker that owned the run crashed / was killed mid-step (e.g.
under heavy load), or
- a step legitimately sits in RUNNING awaiting an external event that
never arrives (the user stopped it).

Only `ENQUEUED` runs had a staleness sweep, so `STOPPING` (and
`RUNNING`) had no recovery path and would stay stuck indefinitely. This
has been observed in production (~150 runs stuck in `STOPPING` after
manual stops during a migration).

## Change

Extend the existing staled-runs machinery to also finalize runs left in
`STOPPING`:
- New `stuck-stopping-runs-threshold` (1h) +
`getStuckStoppingRunsFindOptions` matching `status = STOPPING AND
updatedAt < now - 1h`. `updatedAt` is a TypeORM update-date column, so
it reliably marks when the run entered `STOPPING`, and 1h stays above
any legitimate in-flight step.
- `handleStuckStoppingRunsForWorkspace` finalizes each match to
`STOPPED` via `endWorkflowRun`, so `endedAt`, step infos and the
`WorkflowRunStopped` metric stay consistent. It pages the backlog with
keyset pagination on `(createdAt, id)`, so a page whose finalizations
all fail can't stay at the front of the query and starve later runs
(failed ones are retried on the next sweep).
- Wired into the same cron (`WorkflowHandleStaledRunsCronJob`, every 10
min), per-workspace job, and the manual `workflow:handle-staled-runs`
command — so ops can also clear an existing backlog immediately. The
staled-ENQUEUED and stuck-STOPPING handlers run independently
(`Promise.allSettled` in the job, separate try/catch in the command), so
a failure in one doesn't block the other.

Stop remains manual and unchanged; this only guarantees a stopped run
eventually reaches `STOPPED`.

## Notes / scope

- No schema change (reuses `updatedAt`), so no migration.
- `RUNNING` runs orphaned by a worker crash have the same missing-net
problem; left out of scope here (this covers the user-triggered STOPPING
case).
- The new detection query scans `status`/`updatedAt` like the existing
ENQUEUED sweep; at very high `workflowRun` volumes an index on `(status,
updatedAt)` would help — same pre-existing consideration as the ENQUEUED
path.

## Tests

Unit tests for `handleStuckStoppingRunsForWorkspace`: no-op when none,
finalizes each match to STOPPED, pages through a multi-page backlog, and
advances past a fully-failed page instead of starving later runs. Plus a
unit test for the `(createdAt, id)` keyset condition in
`getStuckStoppingRunsFindOptions`. Full suite green, lint + typecheck
clean on changed files.

Manually verified on a real instance (Postgres): seeded a `STOPPING` run
aged 2h and ran `workflow:handle-staled-runs` -> transitioned to
`STOPPED` with `endedAt` set; a freshly-`STOPPING` run (updatedAt now)
was correctly left untouched by the 1h threshold.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22900?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-15 16:13:26 +00:00
Thomas Trompette f67eb60c57 feat(workflow): soft-ref core workflow/version (backfill + dual-write) (#22821)
Replaces the shared-UUID model (core row reuses the workspace record id)
with a **soft-ref**: the workspace `workflow`/`workflowVersion` records
carry a nullable `coreWorkflowId`/`coreWorkflowVersionId` pointing to
their **own-id** core rows. This removes the assumption that workspace
record ids are globally unique - which is false, since prefilled/seeded
workflows share ids across workspaces. Supersedes #22776.

## In this PR
**Soft-ref columns (foundation):**
- **twenty-shared** `STANDARD_OBJECTS`:
`workflowVersion.coreWorkflowVersionId` + `workflow.coreWorkflowId` (+
snapshot test).
- **compute utils**: both as system, nullable UUID fields.
- **entity classes**: the bare fields.

**Version soft-ref sync:**
- Core `workflowVersion` rows get their own id, derived
deterministically from `workspaceId + record id` (uuidv5). Deterministic
so the upsert is idempotent: a failed write-back re-derives the same id
and self-heals instead of orphaning rows or colliding on the
one-active-per-workflow index.
- Sync = find-or-create keyed on the workspace record's
`coreWorkflowVersionId`, then write the core id back onto the workspace
record.
- Migrating over pre-soft-ref data: purges any core row whose id equals
the workspace record id before recreating, so old shared-UUID rows
aren't orphaned.
- Version dual-write listener reworked: delete is keyed by the core id
read off `before.coreWorkflowVersionId`.

Verified on a fresh `database:reset` (columns materialize, backfill
produces deterministic own-id rows linked back, idempotent re-run), a
simulated old shared-UUID state (stale rows purged, records re-linked),
and a simulated write-back failure (retry re-links to the same id, no
orphan, active-version index intact).

## Next steps (follow-up work, not in this PR)
1. Workflow-side soft-ref sync mirroring the version side (service,
module, dual-write listener, backfill command).
2. Workspace command to add the two columns to existing workspaces.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22821?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-15 17:50:00 +02:00
Charles Bochet 8e03921372 Add CREATED workspace activation status (read path + enum migration) (#22904)
## Context

Since v2 onboarding (#22303), workspaces are activated **before** the
billing plan step (now the last onboarding step). Users abandoning at
the plan step leave ACTIVE workspaces with a Stripe customer but no
subscription (~60–110/day on cloud, 935+ so far), and no cleanup
mechanism ever touches them: billing webhooks never fire (no
subscription), the suspended-workspaces cron only handles SUSPENDED, the
onboarding cron only handles PENDING_CREATION/ONGOING_CREATION.

Target lifecycle (across two PRs): `PENDING_CREATION → ONGOING_CREATION
→ CREATED → ACTIVE → SUSPENDED → deleted`.

**`CREATED`** = the workspace schema is provisioned but onboarding is
not complete — no billing subscription yet. It is **not** considered
active:

| Concern | CREATED behavior |
|---|---|
| Sign-in / invited teammates joining | allowed (invite-team step
precedes the plan step) |
| Member + metadata loading (app shell) | allowed (user must finish
onboarding) |
| Permissions | real permission checks (no PENDING-style bypass) |
| Version upgrades / workspace migrations | **included** (schema must
not drift) |
| Messaging/calendar/workflow/etc. crons | **excluded** — no background
processing until a plan is chosen |
| PLAN_REQUIRED onboarding lock | unchanged (still derived from
subscription existence) |

## What this PR does (read path only)

The enum addition ships as a **slow** instance command, which can run
after deploy — so nothing in this PR ever **writes** `CREATED`. The
write path (setting it at activation, the cleanup sweep, the backfill of
the existing zombie cohort) is a follow-up PR that ships once this
migration has run everywhere.

- **twenty-shared**: `CREATED` enum value;
`PROVISIONED_WORKSPACE_ACTIVATION_STATUSES` + `isWorkspaceProvisioned`
("schema exists": CREATED | ACTIVE | SUSPENDED), replacing
`isWorkspaceActiveOrSuspended` — all call sites (server member loading,
access-token workspace-member lookup, front metadata-store gates) meant
"has schema/members".
- **Slow instance command** (2.22.0): swaps
`core.workspace_activationStatus_enum` using the
rename→recreate→alter-column idiom. The CHECK constraints on
`core.workspace` embed casts to the enum type and would break the swap —
the command captures them from `pg_constraint`, drops them, swaps the
type, and restores them.
- **Pre-migration-safe queries**: Postgres rejects `IN ('CREATED', ...)`
when the enum value does not exist yet — even for reads, and the
instance-command runner itself queries provisioned workspaces before
migrating (a fresh database could never initialize). All
provisioned-status filters go through a new `activationStatusIn` util
comparing on `"activationStatus"::text`, valid before and after the
migration.
- **Upgrade path**: workspace iterator, command runner, upgrade-status
and workspace-version services iterate CREATED workspaces. Since they
now cover more than ACTIVE/SUSPENDED, the stale names were renamed to
`ProvisionedWorkspaceCommandRunner`, `hasProvisionedWorkspaces`,
`getProvisionedWorkspaceIds`, `loadProvisionedWorkspaces` (the
mechanical import rename in old version-command dirs is why this PR
carries the `ci:allow-previous-version-upgrade-mutation` label).
- **Sign-in**: `throwIfWorkspaceIsNotReadyForSignInUp` accepts CREATED
so invited members can join during onboarding (join authorization itself
is unchanged — enforced upstream in `checkAccessForSignIn`);
`activateWorkspace` idempotent-retry accepts CREATED as a terminal
state.
- **Transitions out of CREATED** (only write ACTIVE — safe to ship now,
dead until the write path lands): the Stripe webhook reactivation branch
also promotes CREATED, and `syncSubscriptionToDatabase` promotes
synchronously; both gated on
`WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES` (Active/Trialing —
extracted from `shouldReactivateWorkspace`, behavior-preserving) so an
`incomplete` subscription created by the payment-intent flow before
payment never promotes the workspace.
- Deliberately untouched: all background crons, permission guards, JWT
strategy, PLAN_REQUIRED logic, admin panel (renders the raw status
string).

## Follow-up PR (after this migration has run)
1. `activateWorkspace` sets `hasWorkspaceAnySubscription ? ACTIVE :
CREATED` (billing disabled → always ACTIVE, self-hosted unchanged).
2. Cleanup: suspend CREATED workspaces older than N days (config var),
handing them to the existing suspended pipeline (warn → soft-delete →
destroy).
3. Backfill: cloud-only slow command moving ACTIVE workspaces with no
billingSubscription row (created since Jul 1) to CREATED.

## Verification
- Migration exercised against a real database via the command class: up
→ down → up; `enum_range` and `pg_get_constraintdef` checked after each
step (constraints restored against the new type, `DEFAULT 'INACTIVE'`
preserved).
- Pre-migration safety exercised for real: with the migration rolled
back (enum without CREATED), `run-instance-commands` — the exact
fresh-database CI path that failed before the `::text` fix — completes
cleanly.
- End-to-end with a workspace manually set to CREATED and the branch
server+front running: sign-in issues tokens, `currentUser` loads
workspaceMember(s), the full app loads with no console errors; GraphQL
returns `activationStatus: CREATED`.
- Workspace creation ran end-to-end locally in **both billing modes** on
this branch:
- billing disabled: signup → workspace creation → ACTIVE immediately →
onboarding completes with no plan step → app loads (unchanged behavior);
- billing enabled (Stripe test mode): signup creates the Stripe customer
eagerly → activation ends ACTIVE → subscription-less workspace is pinned
to the plan-required page → no-card trial checkout creates a `trialing`
subscription via `createDirectSubscription`/`syncSubscriptionToDatabase`
→ app loads.
- `twenty-shared` unit tests, server specs on touched services,
`lint:diff-with-main` and `typecheck` for shared/server/front all green;
full CI green.
2026-07-15 17:03:17 +02:00
Joshua Freedman 9f75506896 feat(workflow-tools): add get_logic_function_source tool (#22835)
## What / why

The workflow agent tools let an AI **write** a CODE step's logic
function (`update_logic_function_source`) and **list** logic functions
(`list_logic_function_tools`), but there is no tool to **read** an
existing function's source.

That's fine for greenfield generation — the agent already has in context
whatever code it just wrote. But it's a real gap when editing a function
the agent did **not** author: to safely modify an existing CODE step it
has to see the current source first, and today the only ways to get it
are the frontend (`getLogicFunctionSourceCode` query) or the DB. So the
agent is forced to either guess or ask a human to paste the code.

This adds a small read tool that closes the loop, mirroring the existing
`update_logic_function_source` tool.

## How

- New `get_logic_function_source` tool that calls the existing
`LogicFunctionFromSourceService.getSourceCode({ id, workspaceId })` —
the same service method backing the `getLogicFunctionSourceCode` GraphQL
resolver the frontend already uses. No new service logic.
- Registered in `workflow-tool.workspace-service.ts` alongside
`update_logic_function_source` (the dependency
`logicFunctionFromSourceService` is already injected).
- Unit test covering the success and error paths, matching the `get-*`
tool test convention.

## Notes

- Read-only, additive; no schema or API changes.
- Naturally pairs with `update_logic_function_source`: read → edit →
write.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22835?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-13 17:41:00 +02:00
neo773 9f5d17d1f0 Add receipt metrics and logs to connected account sync webhooks (#22853)
Webhook deliveries from Google and Microsoft were invisible at the app
level: successful notifications produced no logs and no metrics, so
webhook-triggered syncs could not be told apart from cron polling.

Add two counters, connected-account-sync-webhook/received/messaging and
/received/calendar, mirroring the sync-job metric umbrellas, and log a
line whenever a notification triggers a sync. Unmatched subscriptions
keep their existing warn logs.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22853?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-13 14:28:21 +02:00
neo773 f547da4ee9 Gate connected-account webhook subscriptions behind config (#22761)
Now gated behind IS_CONNECTED_ACCOUNT_WEBHOOK_SUBSCRIPTION_ENABLED
(default false).

Merge + deploy twentyhq/twenty-infra#780 first.

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

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-07-10 15:03:53 +05:30
Abdul Rahman a360f9bdec migrate 10 modules off NestjsQueryTypeOrmModule wiring (#22763)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
ten modules from `NestjsQueryTypeOrmModule.forFeature` to the standard
`TypeOrmModule.forFeature`. These modules only used `nestjs-query` for
repository registration — none register `NestjsQueryGraphQLModule` /
auto-generated
resolvers — so this is a pure module-wiring swap with no behavior or
schema change.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22763?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: Weiko <corentin@twenty.com>
2026-07-10 09:21:52 +00:00
neo773 3a5545c753 chore: remove IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED flag (#22680)
Messaging/calendar webhook subscriptions are now always on; drop the
feature flag gate and its enum/public-flag registration.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22680?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-08 23:35:35 +02:00
Thomas Trompette ca90a9358f feat(workflow): backfill workspace workflowVersion into core (phase A) (#22663)
## workflowVersion -> core, Phase A

Follows #21674 (Phase 0, merged). Base: `main`.

Populates core `workflowVersion` and keeps it in sync with the workspace
object, so a later phase can switch reads to core. Reads stay on the
workspace object in this PR.

### 1. Backfill (upgrade command)
`BackfillWorkflowVersionToCoreCommand`, a
`@RegisteredWorkspaceCommand('2.20.0', ...)`. Per workspace, reads all
workspace `workflowVersion` records and upserts them into core,
preserving ids (idempotent), dry-run aware.

### 2. Dual-write (always on, not flag-gated)
`WorkflowVersionCoreDualWriteListener` hooks
`@OnDatabaseBatchEvent('workflowVersion', CREATED/UPDATED/DELETED)`
(same mechanism as the existing workflow-version status listener) and
mirrors every mutation into core. Sync failures are logged, never break
the user's write; drift is repaired by re-running the backfill command.

Dual-write is deliberately not behind a flag: reading from core (next
phase) is only safe if core has been continuously in sync since the
backfill. An always-on mirror makes "core is fresh" an invariant, so the
read switch becomes a plain flag flip. The cost is one extra upsert on
infrequent workflowVersion writes.

`IS_WORKFLOW_VERSION_IN_CORE_ENABLED` is reserved for the read switch
(Phase B): dispatch from the `workflowAutomatedTriggerMaps` cache,
runner and builder reading trigger/steps from core. Until then it gates
nothing.

Both the backfill and the listener go through a single
`WorkflowVersionCoreSyncService` (`upsertToCore`/`deleteFromCore`): the
workspace-to-core mapping (`trigger` -> `triggers[]`, plus `steps`,
`status`, `workflowId`) and `workflowAutomatedTriggerMaps` invalidation
live in one place.

### Rollout plan (following phases)
- **B, read switch (flag per workspace):** reads move to core; writes
keep flowing workspace -> listener -> core. Rollback = flip the flag
back, workspace never stopped being source of truth.
- **C, contract (code change):** write paths write trigger/steps to core
directly; workspace `workflowVersion` stays as a thin shell
(nav/relations/search) but drops the trigger/steps columns; listener and
flag removed.

### Not in this PR
- Reconciliation tooling beyond re-running the backfill.
- The read switch (Phase B).
2026-07-08 18:45:57 +02:00
neo773 b5a73ad86a Chore/remove messaging mock specs (#22665)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22665?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-08 18:37:06 +02:00
neo773 674de0056b feat(messaging): message campaign delivery stats + views (#22661)
Re-land of #22452 (reverted in #22627). Rebuilt on fresh main with
upgrade commands isolated to 2-20 only; no other version's commands
touched.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22661?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-08 15:55:20 +00:00
Pratik Mahajan 72d728ea8e fix(messaging): add sender name to IMAP/SMTP From headers (#22603)
Manual IMAP/SMTP outbound emails were being composed with a
bare email address in the From header, so recipients did not
see the sender's display name.
Gmail already built a proper sender header from Google profile
data, but manually configured IMAP/SMTP accounts had no
equivalent path and fell back to the raw address only.

Fix this by storing the optional sender display name in the
IMAP/SMTP/CALDAV connection parameters, exposing it through
the settings flow and metadata API, and reusing a shared
From-header formatter when composing outbound messages.
The formatter now builds a properly encoded sender header when
a name is available and falls back to the bare email address
when it is not, keeping the behavior safe for blank or missing names.
Gmail keeps using its existing Google-derived display name source;
this change only brings manual accounts up to the same header
formatting standard and removes duplicated formatting logic between
outbound drivers.

After this change, manual SMTP sends and IMAP draft creation include
the configured sender name in the From header, while blank names are
normalized away instead of producing malformed headers.
Existing manual account names are preserved when updates omit the
field, and edited accounts can still explicitly clear it through the
settings flow.

Add focused utility coverage for the shared From-header formatter.

Fixes: #22608

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22603?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: neo773 <neo773@protonmail.com>
2026-07-08 20:29:25 +05:30
Thomas Trompette 6bc4d8efac fix(workflow): batch staled run reset to avoid Postgres param limit (#22654)
## Problem

Self-hosters with a large backlog of workflow runs stuck in `ENQUEUED`
see the recovery job (`WorkflowHandleStaledRunsJob`) fail with:

```
Error: Data validation error.
    at computeTwentyORMException ...
    at WorkspaceSelectQueryBuilder.getMany ...
    at WorkspaceUpdateQueryBuilder.execute ...
    at WorkflowHandleStaledRunsWorkspaceService.handleStaledRunsForWorkspace ...
```

So the very job meant to unblock enqueued runs can never complete, and
runs stay stuck.

## Root cause

`handleStaledRunsForWorkspace` fetched **every** staled run unbounded,
then called `repository.update(allIds, ...)`. That builds a `WHERE id IN
($1, $2, ... $N)`. Inside `WorkspaceUpdateQueryBuilder.execute`, a
"before" `SELECT` runs with that same huge `IN` list; with a big enough
backlog the bind-parameter count exceeds Postgres' limit, the `getMany`
throws a `QueryFailedError`, and `computeTwentyORMException` maps the
resulting PG error code to the generic `PostgresException('Data
validation error.')`.

There's also a secondary `before.length > QUERY_MAX_RECORDS` (200) guard
in the update path that would reject anything over 200 rows even if the
param limit weren't hit.

## Fix

Process staled runs in batches of `QUERY_MAX_RECORDS` (200), looping
until a pass finds none left — the same batching pattern the sibling
clean-runs job already uses. Each update flips the batch from `ENQUEUED`
to `NOT_STARTED`, so the find criteria stops matching them and the loop
terminates. The throttling recompute now runs once at the end, and only
if at least one batch was reset.

## Tests

New unit spec covering:
- no staled runs -> no update, no recompute
- single batch -> correct ids/payload, recompute once
- exactly 200 -> `take: 200`, 200 ids per update
- 450 backlog -> 3 update calls (200/200/50), loops until empty,
recompute exactly once

All 4 pass locally.

## Note

This fixes the recovery job. If runs keep re-accumulating as `ENQUEUED`,
there may be a separate producer-side issue worth investigating.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22654?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-08 13:53:26 +02:00
Thomas Trompette bfeaaa56a3 fix(workflow): handle IS/IS_NOT operand in text and array filters (#22640)
## Problem

Sentry `TWENTY-SERVER-G4F` — `Error: Operand IS not supported for this
filter type` (30k+ occurrences, 15 workspaces, ongoing).

A workflow **Filter** step throws when a step filter carries an
`IS`/`IS_NOT` operand on a text/array field type (`TEXT`,
`MULTI_SELECT`, `EMAILS`, `PHONES`, `ADDRESS`, `LINKS`, `FULL_NAME`,
`ARRAY`, `RAW_JSON`). `evaluateTextAndArrayFilter` only handled
`CONTAINS`/`DOES_NOT_CONTAIN`/`IS_EMPTY`/`IS_NOT_EMPTY` and hit
`default:` → `throw`. The throw propagates out of `FilterWorkflowAction`
and **fails the entire workflow run**.

The current frontend no longer offers `IS`/`IS_NOT` for these types, so
these are **legacy persisted step filters** in older (immutable)
workflow versions that keep executing.

## Fix

Handle `IS`/`IS_NOT` in `evaluateTextAndArrayFilter` as
`contains`/`!contains`, consistent with `evaluateSelectFilter` (chosen
over strict equality because the routed types include arrays/composites
where `==` would silently never match). No existing operand behavior
changes.

## Tests

Added coverage for legacy `IS`/`IS_NOT` on `TEXT` and `MULTI_SELECT`.
Note: the pre-existing `date operands` test failures are
timezone-dependent and unrelated to this change (they fail on `main`
too).

Fixes TWENTY-SERVER-G4F

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22640?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-08 13:12:08 +02:00
Paul Rastoin bd8bf89653 Revert "feat(messaging): message campaign delivery stats + views" (#22452) (#22627)
Revert "feat(messaging): message campaign delivery stats + views
(#22452)"

This reverts commit 2e1117d442.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22627?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-07 14:17:13 +02:00
neo773 2e1117d442 feat(messaging): message campaign delivery stats + views (#22452)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22452?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-07 04:47:52 +02:00