Commit Graph

765 Commits

Author SHA1 Message Date
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
Félix Malfait fec266a5ae test(server): strengthen MCP catalog and gating coverage (#23630)
The three MCP testing improvements discussed in #23613 (now merged; this
branch has been rebased onto main).

## What

**1. Name-set assertions in `mcp-protocol.service.spec.ts`.** The two
&#34;exactly 6 tools&#34; tests only used `expect.objectContaining`
subset matches, so the size claim in the titles was never enforced and a
new meta-tool would pass silently. They now assert the exact sorted key
set of the ToolSet handed to the executor. This immediately caught real
drift: the toolset has seven tools, and `get_tool_catalog` was missing
from the expected list.

**2. Catalog contract integration test**
(`test/integration/ai/suites/mcp-tool-catalog.integration-spec.ts`).
Calls `get_tool_catalog` over real HTTP with an API-key bearer, then for
every advertised category dispatches one read-only tool
(`find_/list_/get_/search_` prefixed, up to 3 candidates) through
`execute_tool` and asserts a success envelope. Any newly registered
provider is covered the moment it appears in the catalog, with no new
test code. Categories with no read-only tool are compared exactly
against a deliberate exception list, currently empty since every
advertised category ships a read-only tool, so drift in either direction
fails loudly.

**3. Permission gating integration test** (same suite). Creates two API
keys: one bound to Admin, one bound to a freshly created role with
`canUpdateAllSettings: false` and no settings flags. Asserts the ROLE
category (from #23613) is present in the admin catalog and absent from
the restricted one, while the restricted key still sees DATABASE_CRUD
read tools, proving it is gating rather than a broken catalog. This
locks the provider `isAvailable` contract at the real HTTP boundary,
which the unit mocks cannot.

## Testing

- `npx jest src/engine/api/mcp` — 43 tests pass
- `test/integration/ai/suites` — 4 suites, 24 tests pass locally against
a reset DB
- `npx nx typecheck twenty-server` clean; oxlint and oxfmt clean on the
touched files


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


<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23630?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
2026-07-31 14:10:30 +02:00
Paul Rastoin e99452e00d feat(server): attach app attributes to application rate-limited metric (#23500)
## What

`MetricsKeys.CommonApiApplicationQueryRateLimited`
(`common-api-query/application-rate-limited`) is emitted from the
per-application throttler in `common-base-query-runner.service.ts`
**without any attributes**. Downstream that means a single
undifferentiated counter — there is no way to tell *which* application
is hitting `APPLICATION_API_RATE_LIMITING_LIMIT`.

This attaches the app dimension:

```ts
await this.metricsService.incrementCounterForEvent({
  key: MetricsKeys.CommonApiApplicationQueryRateLimited,
  shouldStoreInCache: false,
  attributes: {
    universal_identifier: authContext.application.universalIdentifier,
    app_name: authContext.application.name,
    source_type: authContext.application.sourceType,
  },
});
```

## Why these three attributes

Same trio already emitted by `application-registration.service.ts`,
`application-install.service.ts` and `application-gauge.service.ts`, so
per-app API rate limiting joins cleanly against the existing app
lifecycle metrics rather than introducing a second naming scheme.

## Notes

- **No new data fetching.** `authContext` is already narrowed to
`ApplicationWorkspaceAuthContext` by the `isApplicationAuthContext`
guard at the top of the method, and the throttler key a few lines above
already reads `authContext.application.universalIdentifier`. `name` and
`sourceType` are plain columns on `FlatApplication`, present on the same
in-memory object.
- **Bounded cardinality.** `universalIdentifier` is stable for an
application across workspaces (see the unique index on
`(universalIdentifier, workspaceId)`), so the label set is bounded by
the number of distinct applications, not by installs.
- **`source_type` typing.** `ApplicationRegistrationSourceType` is a
string enum, assignable to OTel's `AttributeValue`.

## Context

The consuming dashboard panels are already merged-pending in
`twentyhq/twenty-infra` ([PR
#835](https://github.com/twentyhq/twenty-infra/pull/835)) and written
with a `coalesce(nullIf(Attributes['app_name'], ''),
nullIf(Attributes['universal_identifier'], ''), 'unknown')` fallback, so
they render today as a single `unknown` series and start splitting per
app automatically once this ships — no dashboard change needed on either
side of the deploy.

## Test plan

- Behaviour is unchanged: the counter still fires once per
`ThrottlerException`, and the error is still rethrown. Only the
attribute bag is new.
- I did not run the twenty-server test suite or typecheck locally — this
was authored against a shallow clone without a monorepo install, so I'm
relying on CI for both.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23500?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 13:55:04 +00:00
Abdul Rahman ea7863dc4e feat(ai-agent): lazy tool loading for the open-ended runAgent path (#23454)
## Context

`AgentAsyncExecutorService.executeAgent` is shared by workflow agent
nodes and the `runAgent` mutation (Slack assistant, apps). Workflow
nodes run a scoped task, so pre-loading the few explicitly-granted
object tools is fast and skips
the `learn_tools` round trip (the behavior settled in #23400 / #23358).
`runAgent` is open-ended: its role grants broad object access, so
pre-loading inlines every CRUD/action schema on every step. That is what
makes the Slack assistant take 3-4 minutes for a prompt Ask AI answers
in seconds. Confirmed still slow with #23400 merged, so this is the
payload, not object scoping.

## What

Add a `toolLoadingStrategy` to `executeAgent` (default `'preload'`, so
workflow nodes and evals are unchanged). `AgentRunService.run` opts into
`'lazy'`, which exposes a compact tool catalog in the system prompt plus
the `learn_tools` / `execute_tool` meta-tools, using composed role
permissions rather than explicit grants only, so the agent keeps broad
access without the full-payload latency.

## How

- Split tool provisioning into two focused methods on the executor; a
3-line dispatch chooses per strategy. The pre-load path is unchanged.
- `buildLazyRegistryToolset`: one reusable definition of lazy registry
provisioning (catalog + meta-tools), so the chat and agent executors can
share it.
- Extract `buildToolCatalogSection` out of `SystemPromptBuilderService`
into a `tool-provider` util so both paths format the catalog identically
(no dup).
- Replace the meta-tools' `excludeTools` denylist with a single
`isToolAllowed` predicate: the agent path passes an allowlist closed
over the shown catalog (enforced at call time), MCP passes its existing
deny predicate.

Workflow node and eval behavior is unchanged.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23454?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 12:15:56 +00:00
Weiko d7b1ccaa21 Cache field metadata while processing common query results (#23353)
## Context

`CommonResultGettersService` post-processes API query results after they
are loaded. It recursively walks records and relations, identifies field
metadata, and runs result handlers such as file URL signing.

Production profiling of tail-latency requests showed local CPU
concentrated in this service when processing large nested result sets.

Before this change:

- Field name maps were rebuilt for each recursive record-array call. A
repeated one-to-many relation rebuilt the same child-object map once per
parent.
- Every record's keys were scanned three times, once for handlers, once
for relations, and once for the metadata passed to handlers.
- Each scan resolved names through an ID lookup. ORM-only keys such as
join columns have no matching field metadata, and the non-throwing
lookup handled those misses by throwing and catching an exception
internally.

This work is small for one record, but multiplies across every nested
record and can block the Node.js event loop for large responses.

## What changed

- Create an invocation-local processing context from the metadata maps
already supplied to the service.
- Build a `field name -> field metadata` map once per distinct object
type.
- Share that context across root records and recursive relation
processing.
- Scan each record once and reuse the resolved metadata for handlers and
relations.
- Resolve record keys with direct `Map.get` calls, so keys without
metadata are skipped without entering an exception path.

For example, when the same child object type is visited under 200 parent
records, field-map preparation drops from 201 builds to 2, one for each
distinct object type. Per-record metadata scans drop from three to one.

## Why this is safe

- The cache exists only for one public `processRecord` or
`processRecordArray` invocation. It is not stored on the singleton
service and cannot retain metadata across requests or workspaces.
- Handler selection, execution order, and existing duplicate-handler
behavior are preserved.
- Relation traversal order and relation-type behavior are unchanged.
- Record keys without field metadata remain in the returned object.
- Query selection, database access, pagination, and response shape are
unchanged.

## Expected impact

This removes repeated metadata preparation, array allocation, and
exception construction from the hot path. The improvement should be most
visible in API tail latency and event-loop delay for wide nested
responses. Small responses should see little change.

This does not reduce database time or the size of large responses.
Response fan-out remains a separate concern if those requests are still
too expensive after this optimization.

## Tests

- Verify field handlers still run and fields without metadata are
preserved.
- Verify nested one-to-many records keep their output and ordering.
- Verify field metadata is resolved once per distinct object type within
a single invocation, and rebuilt on the next invocation.
2026-07-27 16:55:10 +00:00
Weiko 5c23ddb1ac Dedupe common result handlers (#23364)
## Context

`CommonResultGettersService` selects field handlers by mapping every
returned record field to the handler registered for its metadata type.

Handlers are shared instances, and each handler already receives the
complete field metadata list. This means that when multiple fields have
the same handled type, the same handler is added and executed multiple
times.

For example, a record with two `FILES` fields previously produced this
execution list:

```text
[objectHandler, filesHandler, filesHandler]
```

Each `filesHandler` execution processes both `FILES` fields. As a
result, both file URLs were signed twice. With several fields of the
same type, this can make the work grow quadratically.

The same duplication can affect rich-text processing, including JSON
parsing, serialization, and embedded file URL signing.

## What changed

Field handler instances are collected in an insertion-ordered `Set`
before execution:

```text
[objectHandler, filesHandler]
```

Each distinct field handler now runs once per record and continues to
process every matching field.

## Why this is safe

- The object-specific handler still runs first.
- Different field-handler types keep their first-seen order.
- No field is skipped, handlers still receive the complete field
metadata list.
- Requests with zero or one field for a handled type are unchanged.
- No cache or cross-request state is introduced.

## Expected impact

This removes repeated synchronous file-token signing and repeated
rich-text transformations for records with multiple fields of the same
handled type. It also reduces event-loop blocking when large result sets
contain several file or rich-text fields.

## Tests

Added a regression test with two `FILES` fields. It verifies that both
fields are processed while `signFileByIdUrl` is called exactly once per
file, two calls instead of the previous four.

Validated with:

```bash
yarn nx jest twenty-server --runInBand --runTestsByPath src/engine/api/common/common-result-getters/__tests__/common-result-getters.service.spec.ts
```

Touched files also pass type-aware Oxlint and Oxfmt.
2026-07-27 14:02:55 +00:00
Etienne 4851489ebc fix(ai-chat) fix AI chat tool-output spill leaks: spill learn_tools, cap navigation tools, truncate on spill failure (#23286)
## Context

AI chat spills tool outputs larger than `MAX_INLINE_TOOL_OUTPUT_BYTES`
(16 kB) to a file and lets
the model page them back with `search_output` / `extract_json_paths`.
Three paths bypass this and
let unbounded payloads into conversation history:

1. **`learn_tools` is never spilled.** Tool schemas go inline whatever
their size.
2. **Navigation tools have no inline cap.** They are exempt from
spilling by design (they page
   spilled files), but nothing bounds their own output.
3. **Spill failure falls back to full inline.** On any spill error the
service returns the complete
   payload with only a warning appended.

## What changed

- **`learn_tools` now spills.** `createLearnToolsTool` takes `{
excludeTools?, spillLargeOutput? }`
(same shape as `createExecuteToolTool`); chat execution enables it. Only
the bulky `tools`
schemas are spilled; `message` / `notFound` / `suggestions` stay inline
and the response carries
a `spilledTools` envelope (fileId, preview, hint) pageable via
`extract_json_paths`. MCP is
  unchanged.
- **Navigation tools get a hard inline cap.** Still never spilled, but
output above 16 kB is
head+tail truncated with a marker telling the model to narrow the query
or page with `offset`.
- **Spill failure truncates instead of inlining.** The fallback returns
head+tail within the 16 kB
  budget with the original byte size in the marker, keeping the warning.
- New `truncateHeadTail` util: byte-budgeted, marker-aware, UTF-8
codepoint-safe.

## Test plan

- `tool-output-spill.service.spec.ts`: spill envelope unchanged,
under-budget passthrough,
navigation cap for both tools (budget respected, marker mentions
`offset`, no file written),
  truncated fallback on spill failure with warnings preserved.
- `learn-tools.tool.spec.ts`: no spill without the option, inline under
budget,
`message`/`notFound`/`suggestions` intact when spilled, spill-failure
warnings surfaced.
- `truncate-head-tail.util.spec.ts`: budget, head+tail+marker,
multibyte-safe cuts.
- 63 tests across 6 suites; `lint:diff-with-main` and `typecheck` green.

## Post-deploy

Watch the `AiChatToolOutputTokens` histogram (p95 should collapse to ~4k
tokens) and the
`AiChatInputTokens` / `AiChatCacheReadTokens` ratio on GPT-5-class
models.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23286?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-24 16:57:35 +00:00
Abdul Rahman 7067f6ef88 fix: honor agent rolePermissionConfig in record CRUD (#23248)
## Summary
- Agent tools were built with the agent’s `rolePermissionConfig`, but
record CRUD ignored it and re-resolved permissions from `authContext`
(app `defaultRoleId`)
- CRUD services now pass `rolePermissionConfig` through
`CommonApiContextBuilder` and the common query runner, so repository
access matches the agent role
- Workflow/chat paths already use the same role for auth and
`rolePermissionConfig`, so their behavior should be unchanged

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23248?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-24 15:21:27 +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
Weiko abe4d7491c Cap nested relation query concurrency (#23252)
## Context

Common API queries load selected relations after fetching the root
records.

Relation loading is batched: one query pipeline loads a relation for all
parent records, so this is not an N+1 problem. However, every sibling
relation currently starts concurrently through `Promise.all`.

Nested relations repeat the same behavior recursively. A wide selection
can therefore submit many independent relation query pipelines at once.

Existing query complexity and record limits restrict what can be
requested, but they do not limit how much database work starts
concurrently.

## What this changes

This PR adds a request-local FIFO concurrency limiter for nested
relation loading.

- At most four `findRelations` pipelines execute concurrently.
- One limiter is created for the outer relation-loading call.
- The same limiter is shared by every recursive level.
- Queued work starts as permits become available.
- Permits are released in `finally`, including when a query fails.

Conceptually:

```text
Before:
all sibling relations -> database concurrently
nested siblings       -> more database work concurrently

After:
all sibling relations -> FIFO queue -> at most 4 database pipelines
nested siblings       -> same FIFO queue and same limit
```

Note: Also addressing
https://github.com/twentyhq/twenty/pull/23251#discussion_r3644510597
2026-07-24 13:27:26 +00:00
Weiko 0108a34765 Rekey metadata caches when flat map hashes change (#23164)
## Context

The `/metadata` GraphQL response cache (`ObjectMetadataItems`,
`FindAllViews`) and the workspace SDL cache were keyed on
`workspace.metadataVersion`, an integer bumped on every object/field
migration. That mechanism is legacy (the migration runner literally
calls it `getLegacyCacheInvalidationPromises`): the data plane already
moved to `WorkspaceCacheService`, which versions each flat entity map
with its own hash minted on invalidation.

Version keying had two concrete costs: the version bump was the only
proactive invalidation for `ObjectMetadataItems`, and keeping
`FindAllViews` fresh required `flushGraphQLOperation`, a full Redis
keyspace SCAN on every relevant migration. It is also the main blocker
for deprecating `metadataVersion` entirely.

This PR re-keys both caches on the flat-map hashes instead.

## What changed

**Response cache** (`use-cached-metadata.ts`): the key is now
`{operation}:{workspaceId}:{combinedDependencyHash}[:{userWorkspaceId}]:{locale}:{queryHash}`.
Each cached operation declares which flat maps its resolvers read
(`metadata-graphql-operations-to-cache.constant.ts`) plus a scope:
`ObjectMetadataItems` stays workspace-shared, `FindAllViews` is per-user
because unlisted-view visibility depends on the caller. When any
declared map changes, its hash rotates and the key rotates with it; no
flush needed. The key is resolved once per request and reused in
`onResponse`, so a rotation mid-request can never cache a response under
a fresher key than the data it was built from. If hash resolution fails,
the request is served uncached (Sentry-captured).

This also fixes three pre-existing key soundness gaps: `FindAllViews`
ignored locale although view names are translated server-side, the query
hash ignored GraphQL variables (`$viewTypes`), and mid-request version
rotation could re-key between request and response.

**SDL cache** (`workspace-graphql-schema-sdl.service.ts`): keyed on the
combined hash of the four maps the schema is generated from, taken from
the same `getOrRecomputeWithHashes` call that returns the data, so key
and content cannot skew. The `metadataVersion` read/seed block there is
gone; the Redis seed moved to `middleware.service.ts` so the
`X-Schema-Version` "refresh the page" check keeps working after the
Redis key's TTL expires.

**`WorkspaceCacheService`**: the internal pipeline now threads `{data,
hashes}` through every stage (local hit, hash validation, Redis fetch,
recompute) and the memoizer stores the pair, so returned hashes are
always consistent with returned data. New public
`getOrRecomputeWithHashes` and `getOrRecomputeCombinedHash`
(hashes-first: one MGET of the small `:hash` keys, full pipeline only
for missing ones, so cold pods never pull map payloads just to build a
key).

**Atomic pair writes** (`cache-storage.service.ts`): `mset` on the Redis
driver now delegates to the store's own `mset` (a MULTI of `SET ... PX`,
or native `MSET`), grouped by TTL. Previously it was a `Promise.all` of
independent SETs, so two concurrent recomputes could interleave and
leave one recompute's `:data` next to the other's `:hash`; with
hash-keyed caches that torn pair could persist a stale response under a
live key. `CoreEntityCacheService` writes through the same method and is
fixed for free.

**Cleanup**: `flush()` lost its `metadataVersion` parameter (always
pattern-flush per key on workspace deletion),
`METADATA_VERSIONED_WORKSPACE_CACHE_KEY` became
`HASH_KEYED_WORKSPACE_CACHE_KEYS` with the `MetadataVersion` key
relocated to `WORKSPACE_CACHE_KEYS` and the dead `ORMEntitySchemas`
entry removed.

## Deliberately unchanged

- `incrementMetadataVersion` and all its callers stay: the version still
feeds the `X-Schema-Version` check and the pinned upgrade commands.
Deprecating the column is a later stage.
- The runner's `FindAllViews` pattern-flush is kept for exactly one
release: view-only migrations never bump `metadataVersion`, so old pods
in a rolling deploy have no other invalidation signal for their
version-keyed entries. It gets deleted next release, which removes the
SCAN entirely.
- Old-shape cache entries are not migrated; they expire via the 7-day
TTL.

## Known limitations (follow-ups, not regressions)

- The plugin reads dependency hashes Redis-fresh while resolvers can
serve up to 10s-old memoized data, so a request landing right after a
migration can cache a pre-rotation response under the new key. Same
shape existed under `metadataVersion`; closing it needs request-scoped
snapshot plumbing.
- Concurrent recomputes are last-writer-wins (lost update). Fencing with
a conditional write is a follow-up.

## Validation

- Unit: response-cache plugin behavior (scope, key stash, serve-uncached
on failure, prototype-name guard), atomic `mset` batching, existing
`WorkspaceCacheService` spec passing unchanged.
- Integration: a new drift-guard spec runs the real
`ObjectMetadataItems`/`FindAllViews` operations with full frontend
selection sets against the in-process app, spies on
`WorkspaceCacheService`, and fails if resolvers read a flat map missing
from the declared dependency lists, so the constant cannot silently
drift.
- Manual against a live server: creating a field rotates the field-map
hash and the very next `ObjectMetadataItems` response contains it (hash
rotation is now its only invalidation path); warm hits are ~5ms; SDL
entries appear under hash-shaped keys via introspection.

## Suggested reading order

1. `workspace-cache.service.ts`, `workspace-cache-key.type.ts`,
`combine-cache-hashes.util.ts` (the `{data, hashes}` pipeline)
2. `use-cached-metadata.ts`,
`metadata-graphql-operations-to-cache.constant.ts`,
`metadata.module-factory.ts` (response cache)
3. `workspace-graphql-schema-sdl.service.ts`,
`workspace-cache-storage.service.ts` (SDL cache and renames)
4. `middleware.service.ts` (metadata version seed relocation)
5. `cache-storage.service.ts` (atomic writes)
6. Tests
2026-07-22 16:50:09 +00:00
Etienne c54ad3c0b9 feat(ai-instrumentation): fix AI histogram buckets & add tool-call duration instrumentation (#23173)
## What & why

Two related observability fixes for AI chat / agent / MCP metrics.

### 1. Widen histogram buckets (`widden-ai-histo`)

Latency percentiles for AI chat pinned at exactly **10s** on the
dashboard — not because anything times out, but because the histogram
buckets top out at 10,000.

`recordHistogram` created histograms without explicit bucket boundaries,
so the OTel SDK applied its defaults: `[0, 5, 10, 25, 50, 75, 100, 250,
500, 750, 1000, 2500, 5000, 7500, 10000]`. These were designed for small
generic values; interpreted as **ms**, the last finite bucket is exactly
10s. Every observation above 10s falls into the `+Inf` overflow bucket,
and quantile estimation clamps to the highest finite bound — so any
percentile that lands in the overflow draws a flat line at 10s.

Reality (gpt-5.5, last 30 days): 64% of turns exceed 10s (so even p50
pins at 10s), mean turn latency ~38s, max ~29min. The same 10k cap
affects the token-unit `tool-output-tokens` histograms.

**Changes:**
- `recordHistogram` now accepts an optional `bucketBoundaries`, applied
per-instrument via `advice.explicitBucketBoundaries` (supported in
`@opentelemetry/api@1.9.1`). Colocated with the metric, no
instrument-setup changes.
- Added bucket-boundary constants:
- `AI_LATENCY_MS_BUCKET_BOUNDARIES` — `[250, 500, 1000, 2500, 5000,
10000, 20000, 30000, 60000, 120000, 300000, 600000]` (250ms → 10min)
- `TOOL_OUTPUT_TOKENS_BUCKET_BOUNDARIES` — `[100, 250, 500, 1000, 2500,
5000, 10000, 25000, 50000, 100000, 250000, 500000]` (100 → 500k tokens)
- Wired boundaries into `ai-chat/turn-latency-ms`,
`ai-chat/step-latency-ms`, `ai-chat/ttft-ms` (latency) and
`ai-chat/tool-output-tokens`, `workflow-agent/tool-output-tokens`,
`mcp/tool-output-tokens` (tokens).

### 2. Instrument tool-call duration (`add-tool-duration-monitoring`)

Previously we tracked tool success/failure counts and output tokens, but
not how long each tool call took. Added a `*/tool-execution-duration-ms`
histogram for each execution path.

**Changes:**
- New metric keys: `ai-chat/tool-execution-duration-ms`,
`workflow-agent/tool-execution-duration-ms`,
`mcp/tool-execution-duration-ms`.
- New constant `TOOL_EXECUTION_DURATION_MS_BUCKET_BOUNDARIES` — `[25,
50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000]` (25ms
→ 2min).
- **AI chat / agent node**: use the AI SDK's
`experimental_onToolCallFinish` callback (`ai@6.0.97`), which reports an
exact `durationMs` measured around the tool's `execute()`, plus
`toolCall.toolName` and `success`. Attributes `{ model, tool }`.
- **MCP**: measured directly around `tool.execute` with
`performance.now()`, recorded on both success and failure paths.
Attributes `{ tool }`.

## Notes

- Backward-compatible: metrics without `bucketBoundaries` (e.g.
`job/latency-ms`, `sdk-client-generation/duration-ms`) keep OTel
defaults.
- **Historical data stays clamped** — only writes after deploy use the
new buckets, so percentile panels will show a step change at deploy
time. For truth on existing data, use a mean panel (Sum/Count is exact)
or Sentry trace span durations.
- Provider-executed tools (e.g. native web search) run inside the
provider, so they don't emit a local duration — same limitation as the
existing tool counters.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23173?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:51:06 +02:00
martmull f5b06d20e4 Add per-application API rate limiter (#23100)
## Context

Application-token API requests are not rate limited today:
`throttleQueryExecution` in the common query runner only throttles
API-key requests, per workspace. An application installed on hundreds of
workspaces (Call Recorder is on 700+) can therefore hit the API with
large synchronized bursts, as seen with the recovery crons impacting
production.

## What changed

- New rate limiter in `CommonBaseQueryRunnerService`, applied when the
auth context is an application context, using the existing
`ThrottlerService.tokenBucketThrottleOrThrow` like the per-workspace
API-key throttle. Both REST and GraphQL record operations go through
this path.
- The limiter key is `api:throttler:application:{universalIdentifier}`
with no workspace component: the budget is shared by every installation
of the application on the instance, which is what protects production
from install-count-proportional load. `universalIdentifier` was chosen
over `applicationRegistrationId` because the latter is null for
unpublished/local applications.
- Two new config variables in the `RATE_LIMITING` group:
`APPLICATION_API_RATE_LIMITING_LIMIT` (default 500) and
`APPLICATION_API_RATE_LIMITING_TTL_IN_MS` (default 60000), i.e. 500
requests/min per application across all workspaces.
- Rejections raise the existing `ThrottlerException`, already mapped by
both the REST and GraphQL exception handlers, and increment a new
`common-api-query/application-rate-limited` metric (only for throttler
rejections, so cache infrastructure failures are not reported as rate
limiting).
- Cron trigger dispatch `retryLimit` raised from 3 to 10 so throttled
logic function executions eventually run once the budget refills.

The existing per-workspace API-key throttle is unchanged (extracted to
its own method).

## Notes

- The limit is tunable per environment without a deploy pipeline change.

## Test

- `npx nx lint:diff-with-main twenty-server` clean.
- `npx nx typecheck twenty-server` clean.
- Throttler spec passes (5 tests).

---------

Co-authored-by: martmull <martin@twenty.com>
2026-07-21 14:40:04 +00:00
Weiko 26227eff31 Add metadata cache tracing and avoid duplicate cache lookups (#23080)
## Summary
- Add Sentry tracing around metadata GraphQL cache reads with operation
tags and cache-phase attributes.
- Record cache hits on the request path so the response hook skips a
duplicate lookup after an early return.
- Cover the cache-hit, request-miss/response-hit, and allowlist
filtering cases with unit tests.

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

onResponse cache mechanism is also there to prevent race conditions
like:
```
Did another request populate this key while I was executing?
  yes → keep it
  no  → cache my response
```
2026-07-20 19:22:57 +02:00
Weiko a5108d512f Block filters on restricted fields (#22873)
## Summary
- Reject filter conditions that target fields without read access,
including relation traversals
- Add unit and integration coverage for denied field filter access

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

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

Run:
```gql
query People($filter: PersonFilterInput) {
  people(filter: $filter, first: 0) {
    totalCount
  }
}
Variables:
{
  "filter": {
    "jobTitle": {
      "like": "Par%"
    }
  }
}
```
Ensure at least one Person has a matching jobTitle.
Before the fix, the request succeeds:
```gql
{
  "data": {
    "people": {
      "totalCount": 1
    }
  }
}
```
The caller can probe restricted values using different filters.
After the fix, it returns a permission error:
```gql
{
  "errors": [
    {
      "message": "Permission denied"
    }
  ]
}
```
The same should happen through a relation filter, for example filtering
Companies by a restricted Person field:
```gql
query Companies($filter: CompanyFilterInput) {
  companies(filter: $filter, first: 0) {
    totalCount
  }
}
{
  "filter": {
    "people": {
      "jobTitle": {
        "like": "Par%"
      }
    }
  }
}
```
2026-07-16 15:19:46 +02:00
Etienne 7475b5f16f perf(upsert) - tighten createMany upsert candidate lookup to avoid broad OR scans (#22721)
## Summary

Fixes a performance-correctness bug in the createMany upsert path where
the
existing-record lookup built an overly broad WHERE clause, causing full
table
scans and multi-second latency on bulk upserts.

Reported via Sentry: a 100-record create*(upsert: true) request on
_sdWorkspace
completed with HTTP 200 but took ~14s. The candidate-lookup SELECT alone
took
~6.7s because it fetched a huge superset of rows before matching in
memory.

## Root cause

buildWhereConditions created one IN(...) per conflicting column across
the whole
batch, and findExistingRecords OR-ed them together:

    WHERE "cbCustomerId" IN ($1..$100) OR "environment" IN ($101..$200)

For a composite unique index (cbCustomerId, environment), this is
semantically
wrong: it OR's the columns instead of matching them as a tuple. Because
environment is low-cardinality (prod/staging/dev/test), the second IN
alone
matched almost the entire table, forcing a Seq Scan and shipping the
whole
result set to the app for in-memory filtering.

## Fix

buildWhereConditions now generates targeted lookup conditions:

- Single-column unique keys collapse into one `column IN
(distinctValues)`
  condition (instead of N OR-ed equalities).
- Composite unique keys produce one `(colA = ? AND colB = ?)` condition
per
input record — the columns are ANDed as a tuple, and separate unique
indexes
  are still OR-ed together.
- Conditions are deduplicated (robust JSON-based key, no separator
collisions)
  to avoid redundant OR branches.
- Values are now typed as string | number | boolean instead of being
implicitly
  coerced to string.

## Benchmark

Reproduced the incident with a table mirroring _sdWorkspace:
high-cardinality
cbCustomerId + low-cardinality environment (4 values), composite unique
index on
(cbCustomerId, environment), 100-record upsert batch. EXPLAIN (ANALYZE,
BUFFERS)
on a warm 500k-row / 392 MB table:

Metric | OLD (colA IN OR colB IN) | NEW (targeted) | Improvement

----------------------|--------------------------|----------------|------------
Scan type | Seq Scan (full table) | Index Scan | index vs full scan
Rows returned to app | 500,000 | 100 | ~5,000x fewer
Buffers touched | ~45,455 (~355 MB) | 400 (~3.2 MB) | ~110x fewer
Execution time | 224 ms | 11.9 ms | ~19x faster
Data shipped to app | ~322 MB | ~64 KB | ~5,000x less

The cache-independent facts are the proof: the old query returned the
entire
table for a 100-record batch (the "overly broad record set" from the
report),
while the new query returns exactly the matching rows via the composite
index.


## Test plan

- [x] Unit tests for buildWhereConditions (single-column IN batching,
nested
paths, composite AND tuples, dedup incl. separator-collision safety,
numeric values, mixed single-column + composite OR) — 22 passing across
build-where-conditions, get-matching-record-id, get-value-from-path.
- [x] Upsert integration suites pass (upsert +
composite-unique-index-upsert,
      10 tests).
- [x] EXPLAIN ANALYZE benchmark confirms Index Scan and bounded row
counts.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22721?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-09 15:47:54 +02:00
Etienne 54aa52d11c feat(index): support composite unique indexes in create-many upsert conflict resolution (#22604)
## Context

The `createMany` upsert path resolved conflicts by scanning individual
field
metadata and only treating a field as a conflict target when it was
flagged
`isUnique` (plus the primary `id`). This ignored **composite unique
indexes**
(multi-column unique constraints), so upserting against a multi-field
unique
key never matched an existing row and could either insert a duplicate or
fail.

## What changed

- **Conflict groups are now derived from unique indexes**, not from
per-field
`isUnique` flags. `getConflictingFields` reads the object's index
metadata
(`flatIndexMaps`) and builds one `ConflictingFieldGroup` per unique
index
  (the primary `id` remains its own group).
- Each index group correctly expands its fields into DB columns,
handling:
- **Composite field types** — expands to the sub-columns included in the
unique constraint (or a specific sub-field when the index targets one).
- **`MANY_TO_ONE` relation fields** — resolves to the join column name.
  - **Scalar fields** — used directly.
- `ConflictingFieldGroup.baseField: string` → **`baseFields:
string[]`**, since
  a composite index spans multiple fields.
- **Clearer multi-match error message**: conflicting values are now
grouped per
index (`baseFields (fullPath: value, ...)`, groups joined by `;`) so
it's
  obvious which unique key caused the ambiguity when a payload matches
  different rows across different indexes.
- `CommonCreateManyQueryRunnerService` now fetches `flatIndexMaps` via
  `WorkspaceManyOrAllFlatEntityMapsCacheService` and passes them into
`getConflictingFields`; the cache module is wired into
`CoreCommonApiModule`.

## Tests

- New integration suite
`composite-unique-index-upsert.integration-spec.ts`:
- single composite unique index — insert, update-on-match, and
insert-when-key-differs
- **two independent composite unique indexes** — happy path (single row
matches
both) and failure path (payload matches different rows across the two
indexes → `Multiple records found with the same unique field values` /
    `BAD_USER_INPUT`).
- Updated unit specs for `get-conflicting-fields`,
`get-matching-record-id`,
  `build-where-conditions`, and `categorize-records` to reflect the
  index-driven grouping and the `baseFields[]` shape.

## Test plan

- [ ] `npx nx run twenty-server:test:integration:with-db-reset --
composite-unique-index-upsert`
- [ ] `npx nx test twenty-server -- get-conflicting-fields
get-matching-record-id build-where-conditions categorize-records`
- [ ] Manual: upsert against a composite unique index updates the
matching row instead of inserting a duplicate.

fixes
https://github.com/twentyhq/twenty/issues/22580#issuecomment-4894266699

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22604?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 13:54:33 +02:00
Paul Rastoin 22b49a502c chore(server): remove unused flat-field-metadata per-object mocks (#22581)
## Context

The `flat-field-metadata/__mocks__/` directory contained 11 large
per-object `as const` mock catalogs (`OPPORTUNITY_FLAT_FIELDS_MOCK`,
`PERSON_FLAT_FIELDS_MOCK`, `PET_FLAT_FIELDS_MOCK`, ...) plus a
`getRelationTargetFlatFieldMetadataMock` helper. An audit of the whole
server package (searching both the constant names and any import of the
directory) showed almost none of them are consumed anymore — tests have
moved to building exactly the fields they need with the
`getFlatFieldMetadataMock` factory.

Usage found:
- `getFlatFieldMetadataMock` (factory): ~25 spec files + 2 core-modules
mocks — **kept**
- `COMPANY_FLAT_FIELDS_MOCK`: 1 spec
(`object-record-event-publisher.spec.ts`), which only used the `name`
field
- The other 10 `*_FLAT_FIELDS_MOCK` catalogs and
`getRelationTargetFlatFieldMetadataMock`: **zero consumers**

## Changes

- Delete the 11 unused `*-flat-fields.mock.ts` catalogs and
`get-morph-or-relation-target-flat-field-metadata-mock.ts` (~4,900
lines). Only `get-flat-field-metadata.mock.ts` remains.
- In `object-record-event-publisher.spec.ts`, build the company `name`
field inline with `getFlatFieldMetadataMock` (wired to
`COMPANY_FLAT_OBJECT_MOCK.id`/`workspaceId`) and replace the three
`COMPANY_FLAT_FIELDS_MOCK.name.type` references with
`FieldMetadataType.TEXT`.

The sibling `flat-object-metadata/__mocks__/` catalogs are untouched —
several of those are still consumed by the morph/relation specs.

## Verification

- `object-record-event-publisher.spec.ts`: 27/27 passing
- `npx nx lint:diff-with-main twenty-server`: green
- `npx nx typecheck twenty-server`: green

https://claude.ai/code/session_01XcGEtwdXQo9uJibGRPexuG

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22581?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-06 13:47:17 +02:00
Etienne 1270054d35 feat(ai): dashboard & view building (#22411)
## Why

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

## What changed

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

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

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

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

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

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

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22411?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 14:15:27 +00:00
Abdul Rahman 429e8c4b84 fix: align email validation between front and server and roll back optimistic value on failed save (#22490)
## Summary

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

## Changes

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

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

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

Twenty had **two** override mechanisms:

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

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

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

### GraphQL breaking change (accepted)

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

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

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

### Rolling-deploy safety

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

### `isActive` guard

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

### Verification (local)

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

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-07-02 12:01:15 +02:00
Félix Malfait 2a21eb46c0 perf: cap to-many relation records per parent and inline chips in table (#22206)
## Problem

Record table views that show a to-many relation column (e.g. Workflows
with a "Runs" column) get slow and janky to scroll when some records
have many related records.

Two root causes, found by profiling the page live:

1. **Backend over-fetch + unfairness.** Nested one-to-many relations
were loaded with a single flat limit of `QUERY_MAX_RECORDS_FROM_RELATION
* parentCount` shared across *all* parents in the page (`WHERE
parentColumn IN (ids) LIMIT 60*N`, no per-parent cap). A single hot
parent can consume the entire budget — returning thousands of rows for
one cell, and potentially starving sibling parents of records they
actually have. The `limit * parentCount` shape shows the original intent
*was* a per-parent budget; it was just implemented as a global limit.

2. **Frontend DOM explosion.** `ExpandableList` mounts the *entire*
child array inline (clipped with `overflow: hidden`) when unfocused, and
mounts all children for measurement when focused. A cell with 2,000+
relation chips mounts ~14k DOM nodes — one observed page reached ~55k
nodes for 43 rows, producing 100–300 ms main-thread long tasks on every
scroll.

## Fix

- **Backend:** load one-to-many relations with a true **per-parent** cap
via a `LATERAL` join — each parent runs its own indexed, `LIMIT`-ed scan
that stops after the per-parent budget. This is `O(perParentLimit ×
parentCount)` and never reads or sorts a parent's full relation set. The
per-parent query is built through the workspace query builder (so it
stays schema-qualified and keeps the soft-delete predicate) and wrapped
as a `FROM` subquery; read/row-level permissions are enforced when
records are hydrated by id, as elsewhere in the relation loader.
Many-to-one is unchanged.
- **Frontend:** add an opt-in `maxInlineCount` to `ExpandableList` so
to-many relation cells mount only a small inline preview; the expand
dropdown still renders the full fetched set. Fully backward compatible
(no cap → identical behavior).

## Why LATERAL over a window function

A windowed `ROW_NUMBER() OVER (PARTITION BY parent) <= limit` is correct
and fair too, but a window function **cannot stop early within a
partition** — it must read every matching row (and sort it). Measured on
skewed data (one parent with ~4k children, on the existing single-column
join index, PG16):

| Approach | Time | Buffers | Rows read from the hot partition |
|---|---|---|---|
| Pre-PR (`LIMIT 60×N`) | 1.6 ms | 91 | ~180 total, early-stops, but
**unfair** (starves siblings) |
| Window (`ROW_NUMBER`) | 3.7 ms | 128 | **all ~4k + sort** |
| **LATERAL (`per-parent LIMIT`)** | **0.5 ms** | **57** | **~60, index
early-stop** |

LATERAL matches the pre-PR read cost while being fair, needs no new
index, and scales independently of how large any single relation is.

## Verification

- Backend integration test (`nested-relation-per-parent-limit`): a
parent with 65 children is capped at 60 while a sibling with 3 keeps all
3 — passes.
- `EXPLAIN ANALYZE` on the generated SQL: Index Scan with the `LIMIT`
pushed into the per-parent lateral (early-stop).
- Frontend unit test for the `ExpandableList` cap.
- Manual check on a table cell with 40 related records: exactly 10 chips
mount inline (down from 40), no console errors, chips still clickable
and the overflow count reflects the true total.
2026-06-27 15:12:43 +02:00
Amresh Chaurasiya e1120d38b6 fix: stamp MCP and AI Agent writes with FieldActorSource.AGENT (#22215)
## Description
Fixes #21437 MCP and AI Agent writes now correctly stamped with

### Problem
Records created through MCP server were stamped as `WORKFLOW`, making
them indistinguishable from workflow-created records. This breaks
loop-protection filters that skip workflow-originated records.

### Solution
- MCP writes now correctly stamped with `createdBy.source = AGENT`
- AI Agent execution now uses `AGENT` instead of `MANUAL`
- Added `WorkspaceCacheModule` to MCP module
- Updated tests to verify AGENT source

### Files Changed
- `mcp.module.ts`: Added WorkspaceCacheModule import
- `mcp-protocol.service.ts`: Set AGENT source in buildMcpToolSet
- `mcp-protocol.service.spec.ts`: Updated tests
- `agent-actor-context.service.ts`: Changed MANUAL → AGENT
- 

## Type of Change
- [x] Bug fix (non-breaking change)

## Checklist
- [x] Code follows project style
- [x] Tests added/updated
- [x] Issue linked

Fixes #21437

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22215?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-26 17:38:05 +02:00
souheyl gouadria bf345bb177 Allow workflows listing in MCP (#22013)
This resolves https://github.com/twentyhq/twenty/issues/21986

Add `list_workflows `MCP tool

Workflow objects are excluded from the generic database CRUD tools
exposed via MCP, which meant the only way to list workflows was through
a direct API call.

This adds a `list_workflows `tool to the `WorkflowToolProvider`, making
it available via MCP alongside the existing workflow builder tools. It
supports optional filtering by status (`DRAFT`, `ACTIVE`, `DEACTIVATED`)
and pagination (`limit`/`offset`). The status filter uses an
array-membership predicate (`ANY`) since `statuses `is a multi-value
field.

---------

Co-authored-by: Souheyl Gouadria <souheyl.gouadria@medius.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-06-24 15:52:29 +02:00
Charles Bochet dd7435b807 fix: normalize date-time field input on backend to prevent timeline crash (#22035)
## Context

Reported via support
([private-issues#477](https://github.com/twentyhq/private-issues/issues/477)):
a customer saw **"Invalid Configuration"** in red on a record's
**Timeline** tab. The dev console was flooded with:

```
RangeError: Cannot parse: 2026-05-07
    at Temporal.Instant.from (...)
    at RecordFieldComponent ...
```

## Root cause

A `DATE_TIME` field in their workspace holds **date-only** values like
`2026-05-07`.

`validateDateTimeFieldOrThrow` (the write-path validator) **accepts**
date-only formats — `'yyyy-MM-dd'` is in `ACCEPTED_DATE_TIME_FORMATS` —
and **returns the raw input string unchanged**, with no normalization.
So a date-only string passes validation and propagates verbatim into the
mutation response and the timeline event payload.

On render, `DateTimeDisplay` builds the timezone hint with
`Temporal.Instant.from(value)`. That's strict — it requires a full
instant (time + offset/`Z`) and throws `RangeError` on a bare date. The
throw escapes into the page-layout widget error boundary, which renders
the **"Invalid Configuration"** fallback and breaks the whole timeline.

## Fix

**Backend (root cause) — normalize on write.**
`validateDateTimeFieldOrThrow` now canonicalizes every accepted value to
a full ISO 8601 instant, so a date-only value can never reach storage,
the mutation response, or timeline events for a `DATE_TIME` field:

- strict ISO-8601 carrying an offset/`Z` -> kept as its exact instant
(server-timezone-independent)
- zoneless / date-only / lenient formats -> interpreted as **UTC**
(date-only -> midnight UTC), deterministically

Lenient input is preserved — parsing still uses date-fns for the ~20
accepted formats (which `Temporal.Instant.from` cannot parse); only the
*output* is canonicalized, via Temporal.

| input | before (stored raw) | after (normalized) |
|---|---|---|
| `2026-05-07` | `2026-05-07` | `2026-05-07T00:00:00Z` |
| `2026-05-07T12:00:00+02:00` | `2026-05-07T12:00:00+02:00` |
`2026-05-07T10:00:00Z` |
| `2026-05-07T12:00:00.000Z` | `2026-05-07T12:00:00.000Z` |
`2026-05-07T12:00:00Z` |
| `January 15, 2024` | `January 15, 2024` | `2024-01-15T00:00:00Z` |

**Frontend (existing data) — Temporal-native guard.** Existing
workspaces already have date-only values stored in events, so the
backend fix alone won't un-break the reporting customer's timeline.
`DateTimeDisplay` now parses the value via a new
`parseStringToInstantOrNull` helper (Temporal `Instant.from` with a
`PlainDate` start-of-day-UTC fallback) and only renders the timezone
hint when valid — so stored bad data renders gracefully instead of
crashing. This replaces the initial `new Date()` guard with a
Temporal-native one, in line with the codebase's Temporal migration.

## Tests

- `validate-date-time-field-or-throw.util.spec.ts` updated to assert the
normalized instant output, incl. explicit date-only -> midnight-UTC
cases.
- `parseStringToInstantOrNull.test.ts` — unit coverage for the frontend
helper (instant, offset, date-only, unparseable).
- `DateTimeDisplay.stories.tsx` — story rendering a date-only value
under a non-system timezone (the previously-crashing path).
2026-06-24 12:42:05 +00:00
Etienne 5ca41d55fb feat(ai): humanize tool-call (#21976)
# Humanize tool-call labels

cc: https://github.com/twentyhq/twenty/pull/21462

## Preview
<img width="459" height="156" alt="Screenshot 2026-06-22 at 19 13 11"
src="https://github.com/user-attachments/assets/e7a2f5f5-cd09-4ec6-920b-5eb16b98285c"
/>
<img width="461" height="156" alt="Screenshot 2026-06-22 at 19 14 54"
src="https://github.com/user-attachments/assets/c2114d2e-2aa8-499a-9801-68e3bb7c45f8"
/>
<img width="461" height="505" alt="Screenshot 2026-06-22 at 19 15 01"
src="https://github.com/user-attachments/assets/ee9ca5d0-8e79-4c63-a2ff-ed5e359a9a9c"
/>

## Why

In the AI chat, tool steps were displayed using raw tool identifiers
(`find_many_companies`, `create_one_task`, `send_email`...) and labels
were partially reconstructed/humanized on the frontend. This was hard to
localize and inconsistent across tool categories.

This PR makes the **backend the single source of truth for
human-readable, localized tool labels**, exposes them through
`getToolIndex`, and reduces the frontend to a thin resolver that picks
the right label for the current status (in-progress / completed).

## What changed

### Backend

- `ToolIndexEntry` (and the `getToolIndex` GraphQL DTO) now carry
`label`, `inProgressLabel?`, `completedLabel?`.
- New `getCrudToolLabels(operation, objectLabel, i18nService, locale)`
builds CRUD labels from a verb table (Search / Find / Group / Create /
Update / Upsert / Delete × imperative / in-progress / completed) + the
(translated, lowercased) object label.
- New `translate-tool-label.util.ts` translates a source label via
`I18nService` (`generateMessageId` → fallback to source when no
translation exists).
- Action tools: labels extracted to the `ACTION_TOOL_LABELS` constant
(`msg` + `i18nLabel`) and translated in
`ActionToolProvider.buildDescriptor`.
- Logic-function tools use the function name as label;
`toolSetToDescriptors` (workflow / view / metadata / dashboard) accepts
an optional `labels` map and falls back to a humanized tool name.
- Labels are localized server-side using the request locale
(`@RequestLocale` → `buildToolIndex` → `context.locale`, threaded
through `ToolContext` / `ToolProviderContext`).
- `code_interpreter` schema now asks the model for `loadingMessage`
(present tense) and `completedMessage` (past tense), so its status text
is model-generated.
- Removed the old generic `loadingMessage` injection mechanism
(`wrap-tool-for-execution.util.ts` deleted; `wrapJsonSchemaForExecution`
/ `stripLoadingMessage` no longer wrap every tool).

### Frontend

- New `useToolLabelMap()` hook builds a `Map<name, { label,
inProgressLabel, completedLabel }>` from `getToolIndex`.
- `getToolDisplayMessage` → `resolveToolDisplayMessage({ input,
toolName, isFinished, labelMap, output })`: a small resolver registry
keyed by tool name (`execute_tool`, `web_search`, `learn_tools`,
`load_skills`, `code_interpreter`, default).
- Default resolver prefers backend `completedLabel` / `inProgressLabel`,
falling back to `Ran X` / `Running X`.
- `learn_tools` / `load_skills` resolve their inner tool/skill names to
labels (label map → tool output labels via `getToolOutputLabelEntries` →
raw name).
- `code_interpreter` step is now expandable to show the code even while
running.

## How tool labelling flows (BE → FE)

```text
BACKEND
┌───────────────────────────────────────────────────────────────────────────┐
│ Tool providers (per category) → ToolIndexEntry                              │
│                                                                             │
│  DatabaseToolProvider                                                       │
│    getCrudToolLabels(operation, object.labelPlural/Singular, i18n, locale)  │
│      verb table (Search/Create/Update/Delete…) + translateToolLabel(object) │
│      → { label, inProgressLabel, completedLabel }                           │
│                                                                             │
│  ActionToolProvider                                                         │
│    ACTION_TOOL_LABELS[toolId] (msg) → translateToolLabel(…, locale)         │
│      → { label, inProgressLabel?, completedLabel? }                         │
│                                                                             │
│  LogicFunctionToolProvider   → label = logicFunction.name                   │
│  toolSetToDescriptors        → label = labels[name] ?? humanize(name)       │
│  (workflow / view / metadata / dashboard)                                   │
└───────────────────────────────────────────────────────────────────────────┘
            │ 
            ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ GraphQL  Query getToolIndex : [ToolIndexEntry]                              │
│   { name, label, inProgressLabel, completedLabel, description,              │
│     category, objectName, icon }                                            │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
FRONTEND ─ resolve the right label for the current status
┌───────────────────────────────────────────────────────────────────────────┐
│ useGetToolIndex() → useToolLabelMap()                                       │
│   Map<name, { label, inProgressLabel?, completedLabel? }>                   │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ resolveToolDisplayMessage({ input, toolName, isFinished, labelMap, output })│
│                                                                             │
│   TOOL_LABEL_RESOLVERS[toolName] ?? defaultResolver                         │
│   ├─ execute_tool     → unwrap { toolName, arguments } then re-resolve      │
│   ├─ web_search       → "Searching/Searched the web for <query>"           │
│   ├─ learn_tools      → "Learning/Learned <labels>"                         │
│   ├─ load_skills      → "Loading/Loaded <labels>"                           │
│   │     inner names resolved via: labelMap → output labels → raw name       │
│   ├─ code_interpreter → model's loadingMessage / completedMessage           │
│   └─ default          → isFinished                                          │
│                           ? completedLabel ?? "Ran <label>"                 │
│                           : inProgressLabel ?? "Running <label>"            │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
   Rendered by ThinkingStepsDisplay / ToolStepRenderer
```

## Localization notes

- Standard object labels and action/CRUD verbs are translated
server-side via `I18nService` using the requester's locale.
- Custom object labels are not translated unless a workspace custom
translation exists (matched by `generateMessageId`); otherwise the
source label is used as-is.

## Tests

- **FE:** `resolveToolDisplayMessage` / `getToolOutputLabelEntries`
(status selection, inner-name resolution, `code_interpreter` model
labels, fallbacks).
- **BE:** `toolSetToDescriptors` (label map + humanized fallback) and
`database-tool.provider` label generation.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21976?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-24 13:41:09 +02:00
Paul Rastoin e9d5d71cd3 Wire up search field metadata (#21964)
## Part 1 - Exact scope of the current PR (#21964)

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

This PR introduces `searchFieldMetadata` as a first-class flat metadata
entity and migrates the existing search surface onto it, with **no
change to which records are searchable** (ISO with `main`).

In scope (what the PR does):
- New flat entity `searchFieldMetadata` (universalIdentifier,
applicationId, **`position`**, maps, conversions), registered in the
central flat-entity constants and the migration build orchestrator.
- `searchVector.asExpression` is **derived server-side** from
`searchFieldMetadata` rows (validated by `isSafeTsVectorExpression`);
never trusted from client input.
- **Derivation order is deterministic, driven by each row's `position`**
([compute-search-vector-as-expression-from-search-field-metadatas.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/compute-search-vector-as-expression-from-search-field-metadatas.util.ts)),
replacing the previous non-deterministic `(createdAt, id)` sort. That
sort collapsed to random UUIDs for standard fields (same `createdAt`),
so any rename/relabel rewrote the `STORED` generated column to a
logically-identical-but-textually-different expression and produced a
permanent per-workspace diff vs the standard definition. Ordering now
equals provisioning order; ties break on `universalIdentifier`.
- Provisioning at object creation mirrors the existing surface exactly
**and seeds `position`**:
- custom objects -> the `name` field only, at `position: 0`
([build-default-search-field-metadatas-for-custom-object.util.ts](packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-search-field-metadatas-for-custom-object.util.ts))
- standard objects -> their curated `SEARCH_FIELDS_FOR_*` sets,
`position` = the curated index
- Backfill (instance + workspace commands in `2-16`) provisions rows for
existing workspaces with the same surface **and the same positions**
(standard from the curated standard maps, custom `name` = `0`), scoped
to the workspace's own custom application
([build-search-field-metadata-backfill-operations.util.ts](packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts)).
The `position` column is added in the same `2-16` fast instance command
as `universalIdentifier`/`applicationId`.
- Field rename of an already-indexed field recomputes `asExpression`
(positions preserved, so order is stable)
([recompute-search-vector-on-field-rename.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-search-vector-on-field-rename.util.ts)).
- Field delete drops the matching row(s) and recomputes; remaining rows
keep their relative order (no renumber)
([from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts)).
- Object relabel is **additive** and ISO/regression-fix only: it indexes
the new label identifier **appended last (`position = max(existing) +
1`)** without dropping `name`
([recompute-search-vector-on-label-identifier-update.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-on-label-identifier-update.util.ts)).
This is a deliberate, temporary bridge.

Explicitly OUT of scope (deferred):
- No API to edit `searchFieldMetadata` (no user-facing search-field
configuration, including `position` — it is internal and only written by
provisioning/backfill/recompute).
- No auto-indexing of arbitrary searchable fields. Creating a custom
TEXT/EMAILS/etc. field does NOT add it to search (the
`computeSearchFieldMetadataCreationForFields` behavior was removed in
`e6820ad`).
- No field-type-transition handling (field type is immutable - not in
`FLAT_FIELD_METADATA_EDITABLE_PROPERTIES`, so that path was dead code).
- No `position` validation (uniqueness/range) and no multi-vector /
per-field `weight` config — deferred to the configurable-search
follow-up (#1428).

Net: `searchFieldMetadata` becomes the source of truth for the *same*
surface as `main`. The only intentional divergences from `main` are
"relabel preserves `name`" (additive) and the deterministic
`position`-ordered `asExpression` (a correctness/perf fix that is
byte-identical to provisioning order, so it does not change the
searchable surface).

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-06-23 16:27:13 +02:00
Etienne 7b45380777 feat(ai): large tool output handling + navigation tools (#21982)
## Summary

Large tool outputs (e.g. a workflow run that serializes to ~70k tokens)
blow the chat context budget and force per-tool "raw" variants. This PR
handles oversized outputs generically in one place:

1. **Producer:** when a tool result exceeds a byte budget, it is spilled
to a `FileFolder.AgentChat` file and replaced with a compact `{ spilled,
outputRef, shape, hint }` envelope.
2. **Consumer:** two bounded, in-server navigation tools —
`extract_json_path` and `search_output` — let the model dig into the
spilled file by `fileId` without spinning up `code_interpreter`.

Together they add a fast, auditable middle tier between "truncated
inline preview" and "full code_interpreter relay," and enable an
enterprise "restricted" mode (spill + navigation, no sandbox).

## Data flow

```mermaid
flowchart TD
  exec["resolveAndExecute / hydrateToolSet closure"] --> compact[compactToolOutput]
  compact --> enabled{"spillLargeOutput enabled? (chat only)"}
  enabled -->|no| inlineRaw["inline raw (MCP, workflow, sandbox bridge)"]
  enabled -->|yes| size{"bytes > MAX_INLINE_TOOL_OUTPUT_BYTES?"}
  size -->|no| inline["inline result"]
  size -->|yes| skeleton["jsonShapeSkeleton + largeOutputHint"]
  skeleton --> write["writeFile(AgentChat)"]
  write --> envelope["return { spilled, outputRef, shape, hint }"]
  envelope --> model[Model]
  model --> nav["extract_json_path / search_output / code_interpreter (by fileId)"]
```

## Part 1 — Navigation tools (consumer)

- `extract_json_path`: extracts a sub-tree from a spilled JSON file by a
JSONPath-lite expression (dot/bracket access, array slicing,
single-level wildcard), with `maxItems`/`maxDepth` bounding. No filters
or recursive descent — those belong to `code_interpreter`.
- `search_output`: grep-like line search with context lines and
stateless `offset` pagination (`{ matches, totalMatches, hasMore }`).
- Both read from `FileFolder.AgentChat` by `fileId`, enforce their own
output byte cap, and are registered in `ActionToolProvider` (always
available; read-only).

## Part 2 — Spill producer

- Spilling slots in right after the existing `compactToolOutput` step at
the two seams in `ToolRegistryService` (`resolveAndExecute` and the
`hydrateToolSet` execute closure).
- `ToolOutputSpillService.spillIfTooLarge()` measures
`Buffer.byteLength`; over `MAX_INLINE_TOOL_OUTPUT_BYTES` (16 KB ≈ 4k
tokens) it writes the full payload and returns the envelope. Spill
failures never block the call (inline + warning).
- `jsonShapeSkeleton` computes a bounded structural map (depth 4, arrays
as `"array[N] of <type>"`, id-keyed maps collapsed, long leaves as size
markers, hard-capped at 1024 bytes) so the model knows the key paths in
one pass.
- Optional per-tool `largeOutputHint` (on the `Tool` type, threaded via
the descriptor) is used as the hint when present, else a generic hint.
The `shape` is always computed generically.

## Surfaces

Spilling is an opt-in flag (`spillLargeOutput`) mirroring
`compactOutput`:

| Surface | `spillLargeOutput` | Behavior |
| --- | --- | --- |
| AI chat / agent | `true` (in `chat-execution.service.ts`) | Spill on;
nav tools + `code_interpreter` in catalog |
| External MCP clients | unset | Raw output |
| Workflow agents | unset | Raw output |
| `code_interpreter` sandbox bridge | unset (it's an MCP call) | Raw
output |

The sandbox bridge inherits "no spill" for free via the MCP path — no
header sniffing, no `ToolContext.source` field.

## Design constraints (anti-micro-OS)

Exactly two navigation tools, no composition/piping, read-only, bounded
output. The boundary is: expressible as a single path lookup or text
search → nav tool; aggregation/correlation/transform →
`code_interpreter`.

## Notes / deviations from the plan

- `jsonShapeSkeleton` and `ToolOutputSpillService` live under the `tool`
module (not `tool-provider/output-transforms`) to avoid a `tool →
tool-provider` import cycle.
- Spill files use `{ isTemporaryFile: false, toDelete: false }` (same as
`code_interpreter`); `isTemporaryFile` here means files-field promotion,
not a TTL.

## Test plan

- [x] `extract-json-path` + `search-output` util unit tests (23 cases)
- [x] `jsonShapeSkeleton` unit tests (6) and `ToolOutputSpillService`
unit tests (4)
- [x] oxlint + oxfmt clean on changed files; `twenty-server` typecheck
clean (pre-existing unrelated errors aside)
- [ ] Manual: trigger an oversized tool result in chat, confirm the
envelope is returned and `extract_json_path` / `search_output` read the
spilled file by `fileId`

## Why no automated e2e

Spilling is chat-only and the chat path runs a live model, so the
black-box MCP integration harness can't deterministically trigger a
spill (MCP intentionally doesn't spill). The seam is small, explicit
flag-threading mirrored on `compactOutput`, covered by the unit suites.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21982?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-23 10:46:06 +00:00
Abdul Rahman 02a966bb7f make mergeMany atomic and optimize relation/field-map handling (#21885)
Closes
[core-team-issue#2333](https://github.com/twentyhq/core-team-issues/issues/2333)

## Summary
Hardens and optimizes `CommonMergeManyQueryRunnerService`:

- **Atomicity**: wrap relation migration + duplicate deletion + survivor
update in a single transaction so a mid-merge failure rolls back fully
(previously failures were swallowed and could leave orphaned/half-merged
data).
- **Perf**: drop the redundant `find`-before-`update` in relation
migration (2N → N queries, no row hydration) and hoist
`buildFieldMapsFromFlatObjectMetadata` out of the per-field loops.

### Why a transaction (not parallelization)
The relation migrations could be parallelized with `Promise.all`, but
merge is a destructive operation: a partial failure leaves orphaned or
half-merged records. We prioritize correctness, so the steps run inside
one transaction.


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

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

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

## Root cause

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

## Fix

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

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

## Tests

- Unit: `getRecordNodeFromRecord` skips a null to-many (reproduces the
exact crash without the guard).
- Integration: an empty morph `ONE_TO_MANY` read returns `{ edges: []
}`, not null.
2026-06-21 18:05:04 +02:00
Abdul Rahman ccc77932a0 Tool execution metrics (#21587)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21587?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-18 10:58:47 +02:00
Charles Bochet 050f7dcf85 fix(server): allow ordering operators on SELECT and RATING fields (#21506)
## Problem

On a record **show page**, prev/next navigation fails with toasts like:

```
Invalid filter : Operator "lt" is not valid for this "severity" SELECT field
```

…but only when the record is reached via in-app navigation from an index
view sorted by a SELECT field (a hard reload is clean).

## Root cause

The record show page paginates with **keyset (cursor) pagination**.
`useRecordShowPagePagination` builds `before`/`after` filters via
`computeCursorArgFilter`, which emits `{ field: { gt|lt: value } }` for
**every** field in the parent view's `orderBy`, regardless of type.

When a view is sorted by a **SELECT** field (e.g. the *All Bugs* view
sorts by `severity`), the keyset filter becomes `{ severity: { gt:
"HIGH" } }` / `{ severity: { lt: "HIGH" } }`. SELECT/RATING fields use
`ENUM_FILTER_OPERATORS` (`eq, neq, in, containsAny, is, isEmptyArray`) —
no ordering operators — so filter validation rejects them, breaking both
the neighbour queries and the rank-in-view ("X of Y") count query. A
hard reload has no parent-view `orderBy`, so neighbour queries are
skipped → no error.

This isn't a technical limitation: the column is comparable, `ORDER BY
severity` works, and the backend already emits the same comparison for
its own cursor path (`buildCursorWhereCondition`) — that path just
bypasses the user-filter allowlist. Ordering operators were omitted from
`ENUM_FILTER_OPERATORS` because they aren't meaningful *user* filters
for categorical fields, but keyset pagination legitimately needs them.

## Fix

Add `gt/gte/lt/lte` to `ENUM_FILTER_OPERATORS`, so SELECT/RATING keyset
filters are accepted — exactly as `UUID_FILTER_OPERATORS` already
carries these operators for the `id` cursor tiebreaker. The filter UI
never offers these operands for SELECT, so this only enables the
keyset/cursor use case.

## Tests

- `get-operators-for-field-type.util.spec.ts`: SELECT/RATING now include
`gt/gte/lt/lte`.
- `validate-operator-for-field-type-or-throw.util.spec.ts`: regression —
ordering operators on a SELECT field no longer throw.
2026-06-13 08:52:21 +02:00
Félix Malfait 1efa3567ef Rename isUIReadOnly to isUIEditable, add isUICreatable, expose both to app developers (#21504)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# UI capability flags: `isUIEditable` + `isUICreatable`

## Per-verb capability model

This PR replaces the negative `isUIReadOnly` metadata flag with
positive, per-verb capability flags (à la Salesforce
`createable`/`updateable`):

- **`isUIEditable: boolean`, default `true`** — rename of `isUIReadOnly`
with inverted polarity, on **both** `objectMetadata` and
`fieldMetadata`. It is one concept ("can the user edit this through the
generic UI?") at two altitudes, so it carries one name at both levels.
- **`isUICreatable: boolean`, default `true`** — new, **object-level
only** (fields have no create verb). When `false`, no generic UI
affordance to create a record of this object appears anywhere (table "+"
buttons, board column add, calendar add, relation-section "Add new",
record picker "Add new", command-menu create action and its keyboard
shortcut).

Both flags are **UI-affordance flags only**: the server does not block
create/edit mutations based on them, so the system, API, and workflows
continue to mutate these records freely. They are orthogonal statements
about the object's nature with no implication rule in the data model.
Because today's inline creation UX creates a blank record the user must
then edit, the frontend create predicate currently requires both
`isUICreatable` and effective editability.

There is no CREATE permission in `ObjectPermissions`; the frontend keeps
gating creation on `canUpdateObjectRecords` as a proxy, ANDed with the
new flags.

## Unified create predicate

All generic creation entry points now flow through one predicate,
`canCreateRecordsForObjectMetadataItem` (`isUICreatable` && not
`isSystem` && not effectively read-only, where effective read-only
covers `isUIEditable`, `isRemote`, and the `canUpdateObjectRecords`
proxy via `isObjectMetadataReadOnly`). This deletes the previously
hardcoded suppression lists:

- `isRecordTableCreateDisabled.ts` and its hardcoded
`WorkflowRun`/`WorkflowVersion` list — deleted; those objects (plus
`workspaceMember`) now declare `isUICreatable: false` in the standard
application instead.
- The hardcoded `workspaceMember` guard inside
`useAddNewRecordAndOpenSidePanel.ts` — deleted.
- The `CREATE_NEW_RECORD` command menu item's availability expression
now checks `objectMetadataItem.isUICreatable`, `isUIEditable`,
`isSystem`, and `isRemote`; a workspace upgrade command re-syncs the
expression in existing workspaces.

Component-local conditions (soft-delete filter active, layout
customization mode) stay in their components.

## GraphQL compatibility and removal plan

The schema delta versus main is **purely additive plus deprecations —
zero breaking changes**:

- `isUIReadOnly` remains on both the ObjectMetadata and FieldMetadata
GraphQL output types for **one release** as a deprecated field computed
as `!isUIEditable` (`deprecationReason: 'Use isUIEditable'`). The Twenty
frontend no longer queries it.
- `isUIReadOnly` also remains on the **input side** for one release
(`CreateFieldInput`, `UpdateFieldInput`, `FieldFilter`, `ObjectFilter`),
keeping the schema shape identical to main for those members. On create
it acts as a legacy alias mapped to `!isUIReadOnly` (`isUIEditable` wins
when both are provided); on update it is ignored, exactly as on main (it
was never an editable property). Filtering on the deprecated member
keeps working until the column is dropped at upgrade time; after that it
is a deprecated no-op surface kept only for schema compatibility.

**Removal plan for next release: drop `isUIReadOnly` from the output
DTOs (and resolvers' `@ResolveField`s), from the input/filter types,
from the create-input mapping, and the `@WasRemovedInUpgrade`-retained
entity columns and decorators.**

## ⚠️ Webhook / database-event payload shape change

The `database-event-payload` type in `twenty-shared` got a clean rename
(no alias): metadata snapshots in webhook and database-event payloads
now carry `isUIEditable` (and `isUICreatable` at object level) **instead
of** `isUIReadOnly`, with inverted polarity. Consumers of these payloads
that read `isUIReadOnly` must switch to `isUIEditable`.

## New manifest properties (app-developer DX)

Application developers can now set these flags in their app manifests
(purely additive — existing manifests and older `twenty-sdk` versions
are unaffected, defaults apply when omitted):

- `objects[].isUICreatable?: boolean` (default `true`)
- `objects[].isUIEditable?: boolean` (default `true`)
- `fields[].isUIEditable?: boolean` (default `true`)

The manifest converters previously hardcoded `isUIReadOnly: false`; they
now read the manifest values with `?? true` defaults. The types are
re-exported through `twenty-sdk` from `twenty-shared`.

## Migration & backfill

- One fast instance command: adds `isUIEditable` (NOT NULL default
`true`) on `core."objectMetadata"` and `core."fieldMetadata"`, backfills
`isUIEditable = false` exactly where `isUIReadOnly = true`, drops
`isUIReadOnly`, and adds `isUICreatable` (default `true`) on
`objectMetadata`. The `down` is the exact inverse. Uses `ADD/DROP COLUMN
IF (NOT) EXISTS`, matching the 2-12 drop-`isCustom` precedent. Verified
up and down in separate transactions against a dev database with exact
backfill counts.
- **Cross-version upgrade safety (multi-version self-hosted jumps):**
the upgrade sequence interleaves per version (instance → workspace
commands), so pre-2.13 workspace commands run **before** the 2.13 rename
when an old instance jumps several versions. Following the `isCustom`
precedent: `isUIEditable`/`isUICreatable` are marked
`@WasIntroducedInUpgrade` and `isUIReadOnly` stays on both entities as
`@WasRemovedInUpgrade`, so the upgrade-aware entity metadata adapter
hides the not-yet-existing columns (and keeps the legacy column live) at
pre-2.13 cursors. **No committed upgrade command outside the 2-13
directory is modified**: the old 1-21/2-8/2-9 commands keep their
original `isUIReadOnly: true` inputs, which still compile (entity
property retained, deprecated create-input alias mapped) and still
produce the correct legacy column writes pre-rename.
- A 2-13 workspace command (`sync-standard-ui-capability-flags`)
re-syncs `isUICreatable` **and** `isUIEditable` on standard objects and
`isUIEditable` on standard fields from the standard-application
definitions. This backfills `isUICreatable: false` on
`workflowRun`/`workflowVersion`/`workspaceMember` and heals fields
created mid-cross-upgrade by pre-2.13 commands (whose hidden
`isUIEditable` value cannot reach the insert). Both 2-13 sync commands
pass `isSystemBuild: true` — the flat metadata validator otherwise
rejects direct updates to system objects (verified against a
deliberately drifted dev database; the run is idempotent).
- A second 2-13 workspace command re-syncs the create-record command
availability expression.

## Testing

- Unit tests for `canCreateRecordsForObjectMetadataItem`
(flag/permission/system combinations) and for the manifest converters
(flags set / omitted → defaults).
- Full `upgrade --dry-run` boots the sequence (107 steps) and validates
the upgrade-aware decorator references; both 2-13 sync commands verified
end to end against real drift and re-run idempotently.
- Schema verified by live introspection after the input-alias restore:
all four input/filter members match main, output deprecations intact;
frontend metadata types and `twenty-client-sdk` schema regenerated from
the running server.
- Read-only-related and touched jest suites pass on both packages;
typecheck and lint pass on `twenty-server` and `twenty-front`.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21504?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: Cursor Agent <cursoragent@cursor.com>
2026-06-13 07:10:22 +02:00
Marie e334551da9 (Fix) Upsert no longer rewrites position on existing records (#21375)
## Fix: upsert no longer rewrites `position` on existing records

### Problem
`createX(..., upsert: true)` resets the `position` of records that
resolve to an **update**, even when the payload doesn't include a
`position`.

The create-many/upsert runner backfills `position` (to `"first"`) in
`computeArgs` over the **whole batch**, before records are split into
insert vs update. So existing rows get a freshly recomputed `position`
written on every upsert. For callers that re-upsert their full dataset
on a schedule (e.g. a daily sync), this rewrites `position` for every
record on each run and drifts the values steadily negative — and it
floods audit/event logs with position churn.

The dedicated `updateOne`/`updateMany` runners already pass
`shouldBackfillPositionIfUndefined: false`; the upsert path did not.

### Fix
Only backfill `position` for records that are actually inserted:
- `computeArgs` now passes `shouldBackfillPositionIfUndefined:
!args.upsert` in both the create-many and create-one runners, so
undefined positions are left untouched on upsert.
- `performUpsertOperation` backfills `"first"` positions for
`recordsToInsert` only, **after** categorization, via
`RecordPositionService`.

Explicit `position` values (`"first"`, `"last"`, or a number) in the
payload are still honored. Plain (non-upsert) create behavior is
unchanged.

### Behavior
| Scenario | Before | After |
|---|---|---|
| Upsert updates existing row, no `position` sent | `position` rewritten
| `position` untouched |
| Upsert inserts new row, no `position` sent | gets `"first"` | gets
`"first"` (unchanged) |
| Explicit `position` on upsert | applied | applied |
| Plain create | unchanged | unchanged |
2026-06-12 08:50:09 +00:00
martmull d0884bd708 Fix missing datetime filter type (#21451)
Currently datetime fields are only typed to be filtered by string

Add a proper typing to match gql filters

## Before
<img width="750" height="492" alt="image"
src="https://github.com/user-attachments/assets/ff3a5423-3bb0-4295-84c9-e404489354f6"
/>

## After
<img width="537" height="511" alt="image"
src="https://github.com/user-attachments/assets/d8c8219f-b7de-41b0-96cb-5adbfda7a91d"
/>
2026-06-11 13:47:08 +00:00
Charles Bochet 184c4948d6 security: strip Node dev headers from images + lingui 5.9.5 (drops vulnerable esbuild) (#21448)
## Context

AWS Inspector flags the `prod-twenty` image (built from current main)
with 16 findings, and Dependabot alert 174 flags esbuild. This PR fixes
the OpenSSL scanner findings and the esbuild CVE. The typeorm bump
(CVE-2025-60542) was **pulled out of this PR** — see "typeorm status"
below.

## Changes

### Strip `/usr/local/include/node` from runtime stages
(`twenty-server`, `twenty-app-dev`)
15 OpenSSL CVEs (June 9 advisory, incl. CRITICAL CVE-2026-34182) are all
detected via **Node's bundled OpenSSL dev headers**: 3 GENERIC
`openssl/openssl` 3.5.6 detections per CVE at
`/usr/local/include/node/openssl/archs/linux-x86_64/{asm,asm_avx2,no-asm}/include/openssl/opensslv.h`.
The headers are only needed by node-gyp and native addons are compiled
in the build stages — nothing compiles at runtime. Dropping them clears
all 45 detection instances and permanently ends this class of finding
(third occurrence: 3.5.5 → 3.5.6 → 3.5.7). None of these CVEs are
reachable through Node (no CMS/PKCS#7 API, `pfx` is operator-supplied,
Node's QUIC uses ngtcp2, ASN.1 issues need ~2GB inputs).

**Follow-up (~June 17, 2026):** the `node` binary itself still
statically links OpenSSL 3.5.6 — invisible to the scanner after this PR
and unreachable in practice, but the real fix is bumping the pinned
`node:24-alpine` digest once the [announced June 17 Node.js security
releases](https://nodejs.org/en/blog/vulnerability/june-2026-security-releases)
ship a 24.x linking OpenSSL ≥ 3.5.7 (verify via
`deps/openssl/openssl/VERSION.dat` on the release tag — 24.16.0 is still
on 3.5.6). A dated TODO sits next to the cleanup in the Dockerfile.

### esbuild dev-server CORS CVE (Dependabot alert 174,
GHSA-67mh-4wv8-2f99)
`@lingui/cli@5.1.2` (pins `esbuild ^0.21.5`) was the last parent
resolving a vulnerable esbuild (≤ 0.24.2 lets any website send requests
to the dev server and read responses). Instead of a resolution override,
this bumps the lockstepped **lingui suite 5.1.2 → 5.9.5** (within-major;
lingui adopted `esbuild ^0.25.1` in 5.4.1), which:

- removes `esbuild@0.21.5` and all its platform packages from the
lockfile with no forced ranges;
- drops the `@lingui/core` lockstep resolution (its comment marked it
droppable on the next coordinated lingui bump — the tree now resolves a
single `@lingui/core@5.9.5`);
- `@lingui/swc-plugin` stays at `^5.11.0` (peers on `@lingui/core: 5`;
its 6.x line targets lingui 6).

**lingui 5.9.5 behavioral fallout handled here:**
- Translation functions now **throw without an active locale** (5.1.2
fell back silently). The global `i18n` singleton that backs server-side
`` t`…` `` calls only had a messages compiler set, never an activated
locale → activate the source locale in `I18nService.loadTranslations()`,
mirrored in the server jest setup (unit tests bypass Nest bootstrap).
- `msg`/`t` placeholders are now strictly typed (reject
`null`/`undefined`/`unknown`) → one server call site and 16 twenty-front
files adapted with minimal nullish-coalescing fixes that preserve
rendering.
- `.po`/compiled-catalog churn from the new extractor/compiler
(reference reordering, sorted keys — verified content-identical on
unchanged `.po` inputs) is intentionally not committed: the scheduled
i18n workflows regenerate those.

## typeorm status (pulled out)

typeorm 0.3.20 → 0.3.26 was originally in this PR but **made workspace
metadata sync intermittently lossy**: `example-app-postcard` failed
twice with a *different* field missing from the synced PostCard object
each run, and one integration shard's `DataSeedWorkspaceCommand` died
with "Could not find flat entity with universal identifier …" — versus
zero such failures on recent main. Local runs (db reset + seed, group-by
integration suite 19/19) pass, so it is a nondeterministic
CI-load-sensitive regression that needs dedicated debugging (typeorm
changed LIMIT/OFFSET 0 semantics, lazy count for `getManyAndCount`,
upsert WHERE construction, and topological-sort internals in that
range). The resolutions comment documents this as the blocker;
CVE-2025-60542 is MySQL-driver-only (`sqlstring`), so Postgres-only
Twenty is not exposed in the meantime.

## Verification

- `npx nx typecheck twenty-server` / `twenty-front` — clean (no cache)
- `npx nx test twenty-server` — full suite green
- `lingui:extract` + `lingui:compile` — clean for twenty-server /
twenty-emails / twenty-front
- `oxfmt --check` — clean for both packages
- Lockfile diff: lingui 5.9.5 entries, `esbuild@0.21.5` +
`@esbuild/*@0.21.5` platform packages removed, no typeorm changes
2026-06-11 15:11:29 +02:00
Etienne 303c415dd1 fix(ai) - add logs + remove dashboard building (#21440)
- add logs for thread finishing without agent message
- add logs to monitor toolCall token usage
- remove dashboard building via AI (before fixing it)
- fix Anthropic compute
2026-06-11 12:45:25 +00:00
Charles Bochet 24839d044a fix(server): repair server typecheck broken by isCustom deprecation (#21376)
## What

Repairs `server-lint-typecheck`, which is **currently red on `main`**.

After #21228 retyped `FlatObjectMetadata.isCustom` as
`WasRemovedInUpgrade<boolean> | undefined`, the spec added in #21311
still passed `flatObjectMetadata.isCustom` to
`computeTableName(nameSingular, isCustom: boolean)`:

```
graphql-query-order-group-by.parser.spec.ts(83,5): error TS2345:
Argument of type 'WasRemovedInUpgrade<boolean> | undefined' is not assignable to parameter of type 'boolean'.
```

Both PRs merged via stale bases, and `server-lint-typecheck` only runs
on PRs (not `main` pushes), so the regression landed undetected — the
next PR to touch anything server-wide surfaces it.

## Fix

Compute the expected physical table name with
**`computeObjectTargetTable`** — the production helper that derives
custom-ness from the application (`applicationUniversalIdentifier !==
TWENTY_STANDARD_APPLICATION`), which is exactly the pattern the
`isCustom` deprecation steers callers toward. This stops reading the
deprecated field and won't break again when it's removed.

One-line change in a single test file; behaviour is unchanged (custom
object → `_`-prefixed physical table).

## Verification

- `nx typecheck twenty-server`  (was failing on `main`, now passes)
- The spec runs green (3/3)
- `nx lint:diff-with-main twenty-server`  (lint + format)
2026-06-09 17:37:51 +02:00
Weiko 9c66975520 isCustom deprecation for Objects and Fields (#21228)
## Context

`isCustom` was a legacy denormalized boolean on `ObjectMetadataEntity`
and `FieldMetadataEntity`.
Now that every metadata row carries `applicationId` (via
`SyncableEntity`), "is this custom" is fully derivable, and the stored
boolean was a redundant second source of truth that could drift.

The real meaning of `isCustom` is **"the owning application is not the
twenty-standard application"** — i.e. `!belongsToTwentyStandardApp`.
Note this is *not* "belongs to the workspace custom app" as I initially
thought: third-party-application
objects/fields are custom too. 
The standard application has a globally stable `universalIdentifier`, so
the value derives with no per-workspace lookup.

## Changed
## `isCustom` checks — before → after

`isCustom` is no longer a stored column. The table below lists every
site that branched on it and how it resolves now. The unifying rule:
`isCustom ≡
!isTwentyStandardApplicationUniversalIdentifier(applicationUniversalIdentifier)`.

### Server — behavioural checks

| Location | Purpose | Before | Now |
|---|---|---|---|
| `utils/compute-object-target-table.util.ts` | Physical table name `_`
prefix | `computeTableName(nameSingular, objectMetadata.isCustom)` |
derives from `applicationUniversalIdentifier` (single source for all
table-name callers) |
| `twenty-orm/factories/entity-schema.factory.ts` +
`…/entity-schema-metadata.type.ts` | ORM table name (hot path) |
`object.isCustom` | `object.applicationId !== standardApplicationId`
(computed in `buildEntitySchemaMetadataMaps`) |
|
`twenty-orm/repository/workspace-{delete,soft-delete,update}-query-builder.ts`
| Table name for mutations | `computeTableName(nameSingular,
objectMetadata.isCustom)` | `computeObjectTargetTable(objectMetadata)` |
| `index-metadata/utils/generate-deterministic-index-name-v2.ts` | Index
name hash (must stay bit-identical) | `flatObjectMetadata.isCustom` |
derives from `applicationUniversalIdentifier` |
| `object-metadata/object-record-count.service.ts` | Table name for
record count | `computeTableName(nameSingular, isCustom)` |
`computeObjectTargetTable(flatObjectMetadata)` |
|
`workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts`
| Match seed config by table name | `computeTableName(item.nameSingular,
item.isCustom)` | `computeObjectTargetTable(item)` |
| `commands/workspace-export/workspace-export.service.ts` +
`…/utils/generate-workspace-schema-ddl.util.ts` | Export table name (raw
entity) | `objectMetadata.isCustom` |
`!isTwentyStandard…(objectMetadata.application?.universalIdentifier)` |
|
`flat-field-metadata/services/flat-field-metadata-type-validator.service.ts`
| Block users creating reserved field types |
`args.flatEntityToValidate.isCustom` |
`!args.flatEntityToValidate.isSystem` |
| `api/common/.../common-create-many-query-runner.service.ts` | Don't
let client overwrite system `createdBy` |
`createdByFieldMetadata.isCustom === false` |
`createdByFieldMetadata.isSystem === true` |
|
`field-metadata/utils/resolve-field-metadata-standard-override.util.ts`
| Skip i18n/overrides for custom fields | `if (fieldMetadata.isCustom)
return raw` | **removed** — falls through on
`isDefined(standardOverrides)` |
|
`object-metadata/utils/resolve-object-metadata-standard-override.util.ts`
| Skip i18n/overrides for custom objects | `if (objectMetadata.isCustom)
return raw` | **removed** — same fall-through |
|
`command-menu-item/utils/build-navigation-interpolation-context.util.ts`
| Override context for nav labels | passed `isCustom` into resolver |
dropped (resolver no longer needs it) |
| `api/common/.../data-arg-processor.service.ts` | `isCustom` for
record-position table name | `flatObjectMetadata.isCustom` | derives
from `applicationUniversalIdentifier` |
| `metadata-modules/minimal-metadata/minimal-metadata.service.ts` |
Minimal DTO + override context | `flatObjectMetadata.isCustom` | derives
from `applicationUniversalIdentifier` |
|
`commands/upgrade-version-command/1-23/…backfill-record-page-layouts.command.ts`
| Filter to custom objects | `objectMetadata.isCustom` |
`!isTwentyStandard…(applicationUniversalIdentifier)` |

### Server — DTO / API population

| Location | Before | Now |
|---|---|---|
|
`flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util.ts`
| passthrough `isCustom` | derives from `applicationUniversalIdentifier`
|
|
`flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util.ts`
| passthrough `isCustom` | derives from `applicationUniversalIdentifier`
|
|
`object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util.ts`
(REST) | `entity.isCustom` | `entity.applicationId !==
standardApplicationId` |
|
`field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util.ts`
(REST) | `entity.isCustom` | `entity.applicationId !==
standardApplicationId` |
| `dataloaders/dataloader.service.ts` | passed
`flatFieldMetadata.isCustom` into override resolver | dropped (resolver
no longer needs it) |

> REST controllers (`object-metadata.controller.ts`,
`field-metadata.controller.ts`) resolve `standardApplicationId` once per
request from the cached `flatApplicationMaps`.

### Frontend

| Location | Purpose | Before | Now |
|---|---|---|---|
| `settings/.../SettingsObjectFieldDisabledActionDropdown.tsx` | Whether
an inactive field is deletable | `isDeletable = isCustomField` |
`isDeletable = isCustomField && !isSystemField` |

### Unchanged (out of scope)

`isCustom` on `IndexMetadata` / `View` / `Skill` / `Agent` and their
guards still read the persisted column.

Breaking change is on the isCustom filter on field and object APIs, this
is never used in the FE and unlikely used by external consumers
2026-06-09 13:57:19 +00:00
Marie c27c8c88b0 Fix various graphs bugs (#21311)
Some bugs fixed in this PR
1. From UI any field could be chosen to group the query by it, while for
instance, RAW_JSON type (eg workflowRun.state) is not supported by
PostgreSQL to group a query by. Fix: removed it from the "group by"
fields options in FE + in BE -->
2. The BE check existed (isFlatFieldMetadataSupportedInGroupBy) but the
signature was malformed: it expected`{ fieldMetadataType,
fieldMetadataName, fieldMetadataIsSystem }` while every caller passes a
flat field metadata object with type/name/isSystem. So the check is
mis-wired — at runtime the destructured props are undefined, making it
always return true (validation bypassed). Fixed this.
3. Group by does not work with Morph relations if their direction is
ONE_TO_MANY. Added that constraint.
4. Group by with morph relations were broken even for MANY_TO_ONE,
because a morph is stored as one field per target
(polymorphicOwnerRocket, polymorphicOwnerSurveyResult…), each with its
own join column, but the frontend collapsed them into a single
polymorphicOwner field — so the backend tried to resolve a non-existent
polymorphicOwnerId. Fix: Frontend: added a target picker so you choose
the specific morph target (then its sub-field), storing the real
per-target field id. Backend: fixed validate-relation-subfield to use
the per-target field's own relationTargetObjectMetadataId instead of the
multi-target resolver that returned null.
5. (improvement) When an error occured in the query, the graph showed
"No data". Updated it to "error". (screenshot 1)
6. When a field used as a filter on a graph is deleted, it is not
deleted as a graph filter (which is ok because it would involve parsing
all the graph's configuration json to find whether a field is
referenced; there is no foreign key), which prevented from further
modifying the graph's filters. Fixed this + add an indicator that the
filter is can/should be removed (see screenshot 2)
7. "Ambiguous column name" PG error occurs when ordering by "creation
date" of a related field, because both objects have createdAt field.
Fixed it by adding table alias as prefix.
8. (improvement) While working on #5 I did not understand why we could
directly do `"objectMetadataNameSingular"."columnName" `while I expected
that for custom objects it would have to be
`_objectMetadataNameSingular`. that's simply because we use an alias
from the beginning. To add clarity, within groupBy code I replaced
`objectMetadataNameSingular` with `objectAlias` everywhere it is indeed
inherited from us using objectAlias.

<img width="685" height="391" alt="Screenshot 2026-06-08 at 12 01 45"
src="https://github.com/user-attachments/assets/f2b15ca5-da39-4114-8188-69f58f3c4cbf"
/>

<img width="598" height="341" alt="Screenshot 2026-06-08 at 11 53 55"
src="https://github.com/user-attachments/assets/66372811-4a37-40d9-b43a-4af51f89b6e6"
/>
2026-06-09 16:08:22 +02:00
Etienne b56fea69aa fix(ai) - optimize metadata CRUD tools (#21235)
Reduces output tokens for all 13 metadata tools by (~49%) based on
production sampling data.

GET tools (field + object metadata)

System fields are now returned as compact {id, name, type} instead of
the full ~20-key payload (opt-in includeFullSystemFields to get full
payload). System objects are similarly compacted to {id, nameSingular,
namePlural}.
Internal fields the agent never uses (searchVector, deletedAt, position,
updatedBy) are excluded entirely.
workspaceId and applicationId are hoisted into a response envelope
instead of being repeated on every record.
Null/default-false properties are stripped from custom field and object
payloads (e.g. options: null, settings: null, isUIReadOnly: false).

CUD tools (create/update/delete)

Create and update field tools now return {id, name, type, label} instead
of the full DTO.
Create and update object tools now return {id, nameSingular,
labelSingular} instead of the full DTO.
Delete tools return {id, success: true} instead of the full DTO of the
deleted entity.
Validation errors are grouped by message — e.g. 10 fields failing the
same check produce one line with all names instead of 10 identical
lines.

Learn schemas (all tools)

UUID pattern regex stripped from JSON schemas (keeps format: "uuid").
$schema and additionalProperties: false stripped from all generated
schemas.
All Zod .describe() annotations and tool descriptions shortened.
Skill & tool description updates:

All references to the removed list_object_metadata_items tool replaced
with get_object_metadata / get_field_metadata across skill instructions,
dashboard tools, view filter/sort tools, and MCP server instructions.
2026-06-08 11:55:29 +00:00
Félix Malfait 91f2f08995 feat(server): unify workspace-event ingestion behind one EventSink pipeline (#21197)
## Why

The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`,
`usageEvent`, `applicationLog`) each wrote to ClickHouse through their
own fire-and-forget writer (`AuditService`, `UsageEventWriterService`,
and the `application-logs` driver), with the per-type knowledge (table
names, normalization, access rules) spread across several modules. Three
of them reimplemented the same ClickHouse insert, and the read side, the
live stream, and the producers lived in different modules under two
different names.

This consolidates them into one `core-modules/event-logs/` subsystem
(emit, write, live, read), with the per-type config in a single registry
so adding an event type is roughly one file.

The base Logs settings tab and free application logs shipped separately
in #21180 (merged). This PR adds the unified backend, the registry, and
the viewer's live mode and entitlement gating.

## Pipeline

```mermaid
flowchart TB
    subgraph PROD["Producers"]
      A["auth, billing, impersonation,<br/>webhook, custom-domain"]
      U["usage listener"]
      F["logic-function executor (app logs)"]
      R["record CRUD (entity events)"]
    end
    EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"]
    EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"])
    CIE["CreateEventLogFromInternalEvent"]
    SINK["WorkspaceEventSinkService.ingest()"]
    C1["ClickHouseEventSink"]
    C2["ConsoleEventSink"]
    LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"]
    CH[("ClickHouse, 5 tables, async_insert")]
    CHAN(["WORKSPACE_EVENTS_CHANNEL"])
    RS["EventLogsService (registry-driven read)"]
    LR["EventLogsLiveResolver"]
    UI["Settings > Logs"]

    A --> EM
    U --> EM
    F --> EM
    EM -->|direct| SINK
    R --> EQ --> CIE -->|ingest| SINK
    SINK --> C1 --> CH
    SINK --> C2
    SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI
    CH --> RS --> UI
```

## What it does

- Producers call `EventLogEmitterService.createContext().insert*()`,
which builds a typed `WorkspaceEventEnvelope` and writes it through
`WorkspaceEventSinkService` to the configured sinks (ClickHouse,
Console) plus a presence-gated live fan-out. Record/CRUD events reach
the same sink through the existing `entityEventsToDbQueue`. There is no
dedicated queue; ClickHouse `async_insert` batches server-side. Writes
are best-effort, as on main today.
- `EVENT_LOG_TYPES[table]` is the per-type source of truth: the
ClickHouse table, the required entitlement, the free-text filter column,
and the row-to-GraphQL mapping. Read row shapes derive from the write
rows.
- Four modules along their dependency boundaries:
`EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink
layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the
entitlement-gated GraphQL read, which is where
billing/enterprise/permissions stay so producers stay light).
- Logs viewer: per-table columns, filters (text, date, record), live
mode, and an upgrade card that points to Billing on Cloud or the Admin
Panel on self-hosted. Application logs are free on every plan; the other
four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT`
fallback to the upgrade card).
- Renames `AuditService` to `EventLogEmitterService`, and the generic
`Monitoring` event to a typed `Impersonation` event (`level` +
`action`).
- Removes `UsageEventWriterService`, the `application-logs`
driver/module, and `AuditService`'s direct inserts.

## Durability

Writes are best-effort, the same as main today (the old writers were
fire-and-forget). A dedicated queue was tried mid-PR and removed:
`async_insert` already batches server-side, so the queue only added
durability, which isn't a requirement right now. The `EventSink` seam
keeps a durable transport (e.g. a Redis-Streams buffer) easy to add
later without touching producers.

## Out of scope

S3 peer sink (seam only), Postgres or any second read path,
`ReplicatedMergeTree`, ClickHouse table-schema changes, and the
record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern).

## Testing

Unit tests cover the registry definitions and row normalization, the
entitlement gating, the envelope builders, and the producers.
Integration tests cover the write paths (record create produces an
`objectEvent`; the track mutation produces a `workspaceEvent`) and the
read/query path across all five tables. Verified with typecheck, lint, a
server boot, and GraphQL/SDK codegen.
2026-06-06 10:32:56 +02:00
Félix Malfait c3dd6b25a6 fix: use canonical oxlint rule id in lint-disable directives (#21253)
## What

Many `oxlint-disable` / `eslint-disable` directives across the repo
carry a corrupted rule id — `@typescripttypescript/<rule>` — most likely
a find-and-replace accident that mangled the eslint-era
`@typescript-eslint/` prefix.

oxlint matches disable directives **loosely by rule name**, so these
still suppress in practice (not a silent no-op), but the id is malformed
and misleading.

## Change

Replace them with the **canonical oxlint id** `typescript/<rule>` —
matching the plugin name and rule keys declared in `.oxlintrc.json` —
**127 files, 262 directives**:

| rule | count |
| --- | ----- |
| `typescript/no-explicit-any` | 250 |
| `typescript/ban-ts-comment` | 6 |
| `typescript/no-misused-promises` | 4 |
| `typescript/no-empty-object-type` | 2 |

- `twenty-server`: 122 files
- `twenty-front`: 5 files

Comment-only — no code or runtime changes.

## Verification

`oxlint --type-aware -c .oxlintrc.json` reports **0 warnings / 0
errors** for both `twenty-server` and `twenty-front`. Every changed line
is exactly the id correction inside a disable directive (262 insertions
/ 262 deletions, no collateral edits).

> Addresses the cubic review, which flagged that the canonical oxlint id
is `typescript/...` (no `@`). Worth noting the original
`@typescripttypescript/` was not actually a silent no-op — oxlint
matches these directives loosely by rule name — but `typescript/` is the
correct, config-aligned id.
2026-06-05 13:52:32 +02:00
Etienne 8bd4cbc3fd fix(ai) - optim (#21233)
1. tool-registry.service.ts, Pass precomputed catalog to
resolveSchemas()

resolveSchemas() now accepts an optional precomputedCatalog parameter.
Both getToolsByName() and getToolInfo() pass the catalog they already
fetched, eliminating a redundant getCatalog() rebuild inside
resolveSchemas().

2. database-tool.provider.ts, Skip field lookup when schemas=false

When building the catalog index (includeSchemas=false),
getFlatFieldsFromFlatObjectMetadata() is no longer called for each of
the 25 objects. The hasGroupByToolInputSchema() check is also skipped,
group_by tools are always included in the index, with the real
eligibility check deferred to learn_tools time.

--> 100/150ms gain on learn/execute_tool execution
2026-06-04 17:27:25 +00:00
Raphaël Bosi 41d5d80a65 Migrate Company and Person standard fields in preparation for the enrichment app (#21171)
# Migrate Company and Person standard fields in preparation for the
enrichment app

## Why

Our standard `Person`/`Company` objects accumulated fields that aren't
generic to every
business, while missing a more universal revenue field that essentially
every CRM ships.
This PR makes the **Standard application** hold a tighter, more
universal set of fields,
and sets the stage for a follow-up PR that introduces a **People Data
Labs enrichment app**
to populate them.

## What changes

### Standard fields

**Demoted (Standard → Workspace Custom application)** — not generic
enough to ship as standard:

| Object  | Field                          | Type     |
| ------- | ------------------------------ | -------- |
| Company | annualRecurringRevenue (ARR)   | CURRENCY |
| Company | employees                      | NUMBER   |
| Company | idealCustomerProfile (ICP)     | BOOLEAN  |
| Company | xLink (X/Twitter)              | LINKS    |
| Person  | xLink (X/Twitter)              | LINKS    |
| Person  | city                           | TEXT     |

**Added (new generic Standard field)** — present in
Salesforce/HubSpot/Zoho, PDL-populatable:

| Object | Field | Type |
| ------- | ------------- |
-------------------------------------------------------- |
| Company | annualRevenue | CURRENCY (generic total revenue; replaces
the niche ARR) |

### Behavior by workspace

* **New workspaces:** demoted fields are gone; `annualRevenue` is
**active**.
* **Existing workspaces:** demoted fields are **preserved as active
custom fields, data intact**;
`annualRevenue` is created **inactive (opt-in)** with its column ready,
so a later activation
  is a metadata-only toggle.

### Upgrade commands (v2.9)

Three idempotent, per-workspace commands, run in timestamp order:

1. **`upgrade:2-9:move-demoted-standard-fields-to-custom-application`**
(1799000040000) —
re-owns the 6 demoted fields to the workspace custom application
(`isCustom = true`,
new `applicationId` + fresh `universalIdentifier`), keeping their data
and active state.
2. **`upgrade:2-9:rename-conflicting-custom-fields`** (1799000045000) —
if a workspace already
has a *custom* field named `annualRevenue`, renames it to
`annualRevenueCustom`
(data preserved via column rename) so the standard field can be added.
Skips non-custom matches.
3. **`upgrade:2-9:add-inactive-generic-standard-fields`**
(1799000050000) — creates
`Company.annualRevenue` on existing workspaces as inactive, guarded to
skip workspaces
   missing the target object or where the name is still taken.

**Failure model:** the workspace iterator isolates failures per
workspace (one workspace failing
never affects others); within a workspace the runner records per-command
status and resumes on the
next run, and every command is idempotent, so partial runs self-heal.

### Supporting changes

* **Field-option color palette:** widened the `TagColor` union
(`twenty-shared` `FieldMetadataOptions`
+ the field-metadata `options.input` DTO) from 10 colors to the full
theme palette, benefiting any
  future SELECT/MULTI_SELECT field.
* **Dev seeder:**
* The default "Annual Recurring Revenue" dashboard widget now points at
the generic
    `annualRevenue` field (renamed to "Annual Revenue").
* Removed the "Companies by Size (Stacked by City)" widget (relied on
the demoted `employees`).
* `employees` is dropped from company data seeds and re-added as a
**custom** field seed, so dev
workspaces still get an `employees` column matching the demoted
behavior.

### Cleanup

Front-end record types (`Company.ts`/`Person.ts`), the
`getDisplayNameFromParticipant` test mock,
metadata integration specs, the Zapier `crud_record` test, and the
regenerated
`get-standard-object-metadata-related-entity-ids` snapshot.

## ⚠️ Breaking change (intentional)

Removes standard fields `Company.annualRecurringRevenue`,
`Company.employees`,
`Company.idealCustomerProfile`, `Company.xLink`, `Person.xLink`, and
`Person.city` from the core
GraphQL schema (replaced by `Company.annualRevenue`).

This is why the breaking-changes check reports a large number of
removals — `graphql-inspector`
flags any removed object field plus its derived
aggregate/order-by/filter/update types.

**Mitigation:** the
`upgrade:2-9:move-demoted-standard-fields-to-custom-application` command
re-owns these fields as custom fields per workspace, preserving their
name and data, so existing
tenants keep working. New workspaces won't have them.
2026-06-04 15:54:04 +00:00
Paul Rastoin 3d49642d12 [AUDIT] Run knip over twenty-server (#21159)
# Introduction
Run [knip](https://knip.dev/) over twenty-server
Used config:
```json
{
  "$schema": "https://unpkg.com/knip@5/schema.json",
  "workspaces": {
    "packages/twenty-server": {
      "entry": [
        "src/main.ts",
        "src/command/command.ts",
        "src/queue-worker/queue-worker.ts",
        "src/database/scripts/setup-db.ts",
        "src/database/scripts/truncate-db.ts",
        "src/database/clickHouse/migrations/run-migrations.ts",
        "src/database/clickHouse/seeds/run-seeds.ts",
        "src/instrument.ts",
        "lingui.config.ts",
        "test/integration/graphql/codegen/index.ts",
        "test/integration/utils/setup-test.ts",
        "test/integration/utils/teardown-test.ts",
        "scripts/**/*.ts",
        "**/*.spec.ts",
        "**/*.integration-spec.ts"
      ],
      "project": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"],
      "ignore": [
        "src/database/typeorm/**/migrations/**",
        "src/database/typeorm/**/*.entity.ts",
        "**/*.workspace-entity.ts",
        "**/logic-function-resource/constants/seed-project/**"
      ],
      "ignoreDependencies": ["@types/psl", "@types/aws-lambda"],
      "ignoreBinaries": ["nest", "lingui", "typeorm"]
    }
  }
}
```
2026-06-04 10:05:22 +00:00
Etienne 15eaabdbc1 fix(ai) - optimize crud tools (#21133)
- **Add delete many**, `delete_many_{object}` added alongside the
existing `delete_one_{object}`.
- **Uniformize naming**, crud module, type names, and MCP helper
constants renamed for consistency.
- **Optimize tool schema (learn phase)**
  - `find_many(_companies)`: **7 158 → 2 700 tokens**
  - `find_one(_company)`: **280 → 126 tokens**
  -  ....
- Main mechanism: `reused: 'ref'` (line 7 of
`to-tool-json-schema.util.ts`). Zod walks the schema tree, tracks which
Zod schema instances appear more than once, and emits each reused
instance exactly once in `$defs`, replacing all subsequent occurrences
with a `$ref`. Works because filter and value schemas are now extracted
as shared objects.

- **Optimize system prompt (tool catalog)**, DATABASE_CRUD section
restructured to list operation patterns (`find_many_{object}`, …) once +
objects once, instead of the full N×M cross-product of tool names.
- **Optimize execute_tool**, shared record-properties schema (same
`$defs` deduplication applies at call time); introduced `upsert_many`;
added `selectedFields` to `find_*` so the agent only fetches the fields
it needs.
2026-06-03 17:57:40 +00:00
Etienne dd0039ca1c feat(mcp) - optimize instruction prompt and hide get_tool_catalog (#21183)
Workspace-aware initialize.instructions

- Deleted the static mcp-server-instructions.const.ts
- Created build-mcp-server-instructions.util.ts — a comprehensive system
prompt with identity, object list, tool grammar, routing decision tree,
intent mapping, skills vs tools, safety constraints, and data efficiency
guidelines
- Created McpInstructionBuilderService — fetches workspace-specific
object names + skill names and injects them into the instructions


Hide/deprecate get_tool_catalog

Benefit : skip first MCP call (tools are included in instruction)
2026-06-03 16:58:32 +00:00
Matt Van Horn 6ac797a69c fix: REST cursor encoding for nested order_by composite fields (#20974)
## Summary

fix: REST cursor encoding for nested order_by composite fields

Closes #20109

---
AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-06-02 07:10:20 +00:00
Weiko b9e5ff2065 fix: broadcast timeline activities to live SSE subscriptions (#21104)
## Context
Timeline activities never updated in real time. They were explicitly
excluded from the database-event pipeline
(formatTwentyOrmEventToDatabaseBatchEvent early-returned for the
timelineActivity object), so no SSE event was ever broadcast, and the
frontend timeline only refreshed on mount/manual refetch.

## Implementation
Backend
- Feat: Stop dropping timeline-activity events in
formatTwentyOrmEventToDatabaseBatchEvent.
Instead route them through EntityEventsToDbListener, which publishes
them directly to live subscriptions (but still skipping webhook/audit
handling).
- Fix: Harden ObjectRecordEventPublisher: wrap nested-relation
enrichment in try/catch so a failure broadcasts the event without
relations instead of dropping it (logs a warning).
- Fix: Skip unreadable relation targets in CommonSelectFieldsHelper when
the role lacks canReadObjectRecords, preventing errors while computing
selected fields.
- Fix: Support MORPH_RELATION alongside RELATION in RLS row-level
permission predicate matching (timeline activities use morph targets).

Frontend
- Feat: useTimelineActivities now registers the timeline query with the
SSE system via useListenToEventsForQuery and refetches on incoming
timeline-activity record operations.
- Feat: Add a skip option to useListenToEventsForQuery so the listener
isn't registered when the object has no timeline field.

## Test


https://github.com/user-attachments/assets/ed1d1c66-d6ea-434d-ac9c-9b83d2b78338


Note: "UpdatedBy" seems to be listen to and visible in the timeline
activity summary, this is probably a bug that we want to fix
2026-06-01 16:32:08 +02:00