## Summary
- Wraps `deleteOneWithSource` calls in `.catch()` during workflow/step
destruction so that a missing logic function (valid UUID but already
deleted) no longer crashes the entire destroy operation
- Adds a `Logger` to `WorkflowVersionStepOperationsWorkspaceService` for
the warning
- Fixes test mock to return a resolved Promise and use a valid UUID
## Context
When a CODE step references a `logicFunctionId` that is a valid UUID but
the logic function no longer exists (e.g. deleted by a previous
operation or orphaned), the destroy fails with "Logic function with id X
not found". This blocks users from cleaning up workflows.
## Test plan
- [x] Destroy a workflow with CODE steps whose logic functions already
exist → succeeds as before
- [ ] Destroy a workflow with CODE steps referencing a
deleted/non-existent logic function → succeeds with a warning log
instead of crashing
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.
**Please review before merging** — verify no critical models were
incorrectly deprecated.
Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
## Closes#19785
In-app management of **server-level admin rights**
(`canAccessFullAdminPanel`, `canImpersonate`) so self-hosters no longer
need raw SQL + a Redis flush + restart to grant access.
> **Draft** — feature complete; `/code-review` + `/security-review` run
and addressed.
### Background
`AdminPanelGuard` / `ServerLevelImpersonateGuard` read
`request.user.{canAccessFullAdminPanel,canImpersonate}`, hydrated each
request from `CoreEntityCacheService.get('user', …)` (local 30-min +
Redis no-TTL). The cache was only invalidated on soft-delete, so a raw
`UPDATE core."user"` never took effect. The **first** signup auto-gets
both flags; every subsequent admin previously needed raw SQL.
### UX
- **Admin Panel → General → Administrators**: a read-only overview of
every user with server-level access; each row links to that user's admin
page.
- **Find anyone** via the user search (Recent Users) — available to full
admins and impersonators — then open their **admin user page**.
- On the user page, an **"Administrator access"** card (gated on
`canAccessFullAdminPanel`) has two toggles — *Full admin panel access*
and *Impersonation* — that work for **any** user (a user with no access
shows both off). Mirrors how **Impersonate** already works (find user →
user page → act). Each change opens a confirm dialog with a **2FA code**
field; the last full admin's toggle is disabled.
### Backend / security
- **Cache fix** — invalidate the user entity cache on committed user
updates (not just soft-delete) so privilege changes propagate (~100 ms,
cluster-wide) with no restart.
- `getServerAdmins` query + `updateServerAdminAccess` mutation (any
`targetUserId`), gated on `canAccessFullAdminPanel`.
- `NoImpersonationGuard` on both — an impersonated full-admin session
can't be used to escalate an impersonator.
- Fresh **2FA TOTP step-up** (enrolled+verified method **and** a fresh
code; genuine 2FA errors surface; dev-skip on trusted `NODE_ENV`).
- **Last-admin lockout** in a transaction with a pessimistic row lock
(no TOCTOU).
- **Email-to-all-admins + affected user** (rendered once per locale),
structured log, audit event-log emit.
- **Authorization**: the read-only `userLookupAdminPanel` +
`adminPanelRecentUsers` lookups now accept `canAccessFullAdminPanel OR
canImpersonate` (new `AdminPanelOrImpersonateGuard`), so a full admin
without impersonate can still find users to manage.
Workspace/impersonation queries stay impersonate-gated.
### Reviews
- `/code-review` (max effort): 3 security findings
(impersonation-escalation sink, lockout TOCTOU, step-up accepting
PENDING 2FA) — **all fixed**. `/simplify`: applied. `/security-review`:
**no high/medium vulnerabilities**.
### Follow-ups (not in this PR)
- Unit tests for `AdminPanelServerAdminService` + a frontend test.
- Point the self-host troubleshooting docs at the new UI.
- OTP retry UX: `ConfirmationModal` closes on confirm, so a wrong code
needs a reopen (kept to reuse the existing modal; no new pattern).
### Notes for reviewers
- `generated-admin/graphql.ts` entries were hand-added to match codegen
output (admin codegen needs a running server); re-run `nx
graphql:generate twenty-front --configuration=admin` to confirm parity.
- First-admin bootstrap (first signup) is unchanged.
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
## What
Adds validation across three auth flows so that a session or reset link
is consistently scoped to a workspace the authenticated principal
belongs to, and to a verified identity.
- **Access token** (`jwt.auth.strategy.ts`): when resolving the
request's user context, the token's `userWorkspaceId` must belong to the
token's `workspaceId` — the same check the application-token path
already performs.
- **OIDC** (`oidc.auth.strategy.ts`): reject sign-in when the identity
provider explicitly reports `email_verified: false`.
- **Password reset** (`reset-password.service.ts`): a supplied
`workspaceId` is only used when the user is a member of it; otherwise it
falls back to the user's own first password-auth-enabled workspace.
## Tests
- `jwt.auth.strategy.spec.ts`: rejects an access token whose user
workspace belongs to a different workspace than the token; existing
mocks updated to carry the cached `workspaceId`.
- `oidc.auth.strategy.spec.ts` (new): rejects unverified email; accepts
verified and absent-claim cases.
- `reset-password.service.spec.ts`: falls back when the supplied
`workspaceId` is not one the user belongs to.
`tsgo`, `oxlint` and `oxfmt` all clean on the changed files.
## 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)
## Context
The view **Hide empty groups** setting (`shouldHideEmptyGroups`) can be
toggled in the UI, is persisted on the `View` entity, exposed in the
`CreateView`/`UpdateView` GraphQL inputs, and tracked by the flat-view
sync machinery — but it could **not** be set from an app's view
manifest.
Root cause: the field postdates the manifest plumbing (added in #16385,
Dec 2025). Two spots were never updated to thread it through:
- `ViewManifest` didn't declare the field.
- `fromViewManifestToUniversalFlatView` hardcoded
`shouldHideEmptyGroups: false`.
Ref: twentyhq/core-team-issues#414
## Changes
- Add optional `shouldHideEmptyGroups?: boolean` to `ViewManifest`.
- Read it in the converter (`?? false`), mirroring the existing
`isCompact` handling.
- Cover it in the converter unit test (default + explicit value).
No migration or schema change — the column already exists, and
downstream sync (`FLAT_VIEW_EDITABLE_PROPERTIES` + the universal-flat
compare type) already handles it.
## Test
- `npx jest from-view-manifest-to-universal-flat-view` → 5 passed
- `tsgo -p tsconfig.json` (twenty-server) → no new errors
- oxlint + oxfmt clean
## 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
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"
/>
## Why
App installs on cloud intermittently fail with a 504, surfacing in
Sentry as `Migration action 'update' for 'logicFunction' failed` +
`Failed to rollback transaction: Query runner already released`. This is
**temporary instrumentation** to pin down where the time goes — to be
reverted once the bottleneck is fixed. Everything is greppable via
`[install-perf]` and marked `// TODO(install-perf)`.
## What the local repro already told us
I instrumented the manifest-sync/migration path and ran a local harness
(new skipped spec) installing **1 / 8 / 30 logic functions**, for both
create and the checksum-bump **update** (the incident path):
| stage (N=30, update) | ms |
|---|---|
| flat-maps recompute | ~1 |
| build migration | ~11 |
| transaction (all actions + commit) | ~79 |
| post-commit cache invalidate | ~6 |
| **full sync** | **~135** |
Nothing approached 1s, let alone 10s; no slow queries logged. So the
migration/cache code is **not** the algorithmic cause. Given the
in-transaction `UPDATE ... WHERE id=?` is intrinsically fast, a >10s in
prod almost certainly means it was **blocked on a lock**, and the 10s
node-pg `query_timeout` (`core.datasource.ts`) then killed the
connection → the observed errors + 504. Local can't reproduce prod lock
contention / table sizes, hence this instrumentation.
## What this adds (all `TODO`-marked)
- **hrtime per-stage timing** — flat-maps recompute, build vs run,
per-action (`>50ms`), transaction summary, post-commit cache
invalidation. Uses `process.hrtime` because the integration harness
enables fake timers (so `Date.now()` is useless there).
- **`maxQueryExecutionTime`** slow-query logging on the core datasource
(logs the offending SQL).
- **Scoped `SET LOCAL lock_timeout = '8s'`** on the migration
transaction (below the 10s `query_timeout`) → a blocked action fails
fast with a clear *"canceling statement due to lock timeout"* instead of
the opaque connection kill.
- **Best-effort `pg_stat_activity` snapshot on failure** (on a fresh
pooled connection) to identify the blocking session, plus a **guarded
rollback** so a released connection stops masking the real error.
- **Skipped local perf harness**
(`logic-function-install-performance.integration-spec.ts`) — run
manually with `nx test:integration:with-db-reset -- --testPathPattern
"logic-function-install-performance"`.
## How we'll use it
Deploy, reproduce the failing install, and read the `[install-perf]`
logs: the per-action timing names the action, the `lock_timeout` message
+ `pg_stat_activity` snapshot name the **blocking** query/PID. Then
revert this PR and fix the actual contention.
Typecheck (`nx typecheck twenty-server`) is clean.
## Summary
- Increase BullMQ worker concurrency for `logicFunctionQueue` from 1
(default) to 10
- Logic function executions are I/O-bound Lambda calls — the worker just
holds an HTTP connection open, so higher concurrency doesn't add
CPU/memory pressure
- With 13 worker pods, this goes from 13 to ~130 concurrent slots, which
should resolve the ~3h average queue latency observed in Grafana
## Test plan
- [ ] Monitor `avg_latency_ms` for `logic-function-queue` in the Grafana
job queue dashboard after deploy
- [ ] Verify worker pod CPU/memory remains stable
## Summary
- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version
## Checklist
- [ ] Verify version constants are correct
- [ ] Verify npm package versions match
Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
## What
Makes workspace resolution consistent across the SSO, OTP and
login-token sign-in flows — the target workspace is derived from the
authenticated principal rather than from separate request-supplied
values.
- **SSO callback** (`sso-auth.controller.ts`): validates the resolved
workspace against the authenticating identity provider's own workspace,
so every SSO session is scoped to the provider that issued it. The
`workspaceInviteHash` is request-controlled and shouldn't select a
different workspace than the provider.
- **`getAuthTokensFromOTP`** (`auth.resolver.ts`): reuses the shared
`validateWorkspaceAccess` helper already used by
`getAuthTokensFromLoginToken`, so the login token and the
origin-resolved workspace are checked the same way in both flows.
- **SSO enablement** (`auth.service.ts`, `workspace.validate.ts`): SSO
sign-in now checks the workspace operates an active SSO identity
provider, matching how the other providers gate on their per-workspace
settings, instead of treating SSO as unconditionally enabled.
## Tests
- `auth.service.spec.ts`: SSO sign-in throws when the workspace has no
active SSO identity provider; proceeds when it does.
The controller and resolver spec additions were dropped from this PR;
the behaviours below cover them via manual testing on `main`.
`tsgo`, `oxlint` and `oxfmt` all clean on the changed files.
## How to test on main
Check out this branch on top of `main` and exercise each flow against a
multi-workspace setup (workspace **A** and workspace **B**, each on its
own domain).
**1. SSO callback is scoped to the issuing provider**
(`sso-auth.controller.ts`)
- Configure an SSO identity provider (SAML or OIDC) on workspace **A**
and set it to **Active**.
- Start an SSO sign-in for workspace **A**, but tamper with the callback
so the resolved workspace points at **B** (e.g. supply a
`workspaceInviteHash` belonging to **B**).
- Expected: the callback is rejected with `OAUTH_ACCESS_DENIED`
("Identity provider does not belong to this workspace"). A clean
callback that resolves to **A** still completes sign-in.
**2. Inactive SSO provider blocks sign-in** (`auth.service.ts` /
`workspace.validate.ts`)
- Take workspace **A**'s SSO identity provider and set its status to
something other than `Active` (e.g. inactive/draft).
- Attempt SSO sign-in for **A**.
- Expected: sign-in is denied with `OAUTH_ACCESS_DENIED` ("Identity
provider not found"). Flipping the provider back to `Active` lets
sign-in proceed.
**3. OTP login token must match the origin workspace**
(`getAuthTokensFromOTP` in `auth.resolver.ts`)
- Enable two-factor authentication for a user who belongs to workspace
**A**.
- Sign in to obtain a login token scoped to **A**, then call
`getAuthTokensFromOTP` (submit the OTP) from workspace **B**'s
origin/domain.
- Expected: the request is rejected with `FORBIDDEN_EXCEPTION` ("Token
is not valid for this workspace") and no tokens are issued. Submitting
the OTP from **A**'s origin issues tokens as before.
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.
**Please review before merging** — verify no critical models were
incorrectly deprecated.
Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
Bumps `@nestjs` packages to clear the scanner findings they pin on the
prod image. All within-major bumps, past the repo's `npmMinimalAgeGate:
3d`.
## Changes
| Package | From → To | Clears |
|---|---|---|
| `@nestjs/common` | 11.1.16 → **11.1.24** | `file-type@21.3.0` → 21.3.4
|
| `@nestjs/core` | ^11.1.18 → **^11.1.24** | (path-to-regexp 8.4.2) |
| `@nestjs/platform-express` | 11.1.16 → **11.1.24** |
`path-to-regexp@8.3.0` → 8.4.2 |
| `@nestjs/serve-static` | 5.0.4 → **5.0.5** | `path-to-regexp@8.3.0` →
8.4.2 |
| `@nestjs/testing` | 11.1.16 → **11.1.24** | — |
Verified in the regenerated lockfile: **`file-type@21.3.0` and
`path-to-regexp@8.3.0` are gone**. `twenty-server:typecheck` passes
locally.
## Not in scope
- **`lodash@4.17.21`** and **`ws@8.16.0`** are pinned by
**`@nestjs/graphql@12.1.1`** (and lodash also by
`@nestjs/config@3.3.0`). Bumping graphql 12→13 would clear them, but
it's blocked by a **316-line custom patch** implementing Twenty's
multi-schema scoping (`resolverSchemaScope`, `computeReachableTypes`)
welded to 12.1.1's compiled internals — a dedicated effort, not a
routine bump. (Twenty uses the Yoga driver, so it's *not* an Apollo
migration.)
- `@nestjs/config` 3→4 alone wouldn't clear `lodash` (graphql still pins
it), so deferred with the graphql work.
- `path-to-regexp@0.1.12` is express 4.x's own — separate from @nestjs.
This PR migrates the p-limit library to Native graph SDK batching fixing
the concurrency and rate limit issues in production seen for some larger
accounts
## Summary
- Adds a server-side guard that rejects deletion of standard fields
(`isCustom: false`) in the `deleteOneField` path
- The UI already prevents this, but the API had no enforcement, allowing
standard fields to be deleted via direct GraphQL calls
Without this guard, deleting a standard field like `jobTitle` cascades
to drop dependent generated columns (e.g. `searchVector`), leaving the
object in a broken state where all subsequent queries fail with "Data
validation error."
## Test plan
- [x] Call `deleteOneField` with a standard field ID → should return
`FIELD_MUTATION_NOT_ALLOWED` error
- [x] Call `deleteOneField` with a custom field ID → should succeed as
before
- [x] UI deactivation of standard fields still works (deactivate !=
delete)
## Summary
- The `twenty_queue_jobs_waiting_total` gauge was summing all queues
into a single value without a `queue` label, making the Grafana "Jobs
Waiting by Queue" panel show a single aggregated line instead of
per-queue breakdown.
- Uses `getMeter()` directly to call `observableResult.observe(count, {
queue: queueName })` per queue, matching the `by (queue)` grouping the
dashboard already expects.
## Test plan
- [x] Deploy and verify the Grafana "Jobs Waiting by Queue" panel
displays separate series per queue
- [x] Confirm Prometheus scrape returns
`twenty_queue_jobs_waiting_total{queue="..."}` with distinct queue
labels
Clears the `uuid` "missing buffer bounds check in v3/v5/v6" advisory —
patched in **11.1.1**. Bumps `twenty-server`, `twenty-shared`,
`twenty-front` from 9 → `^11.1.1`.
### Why 11 and not 13
uuid **11.1.x still ships a CommonJS build**, so jest loads it with **no
config changes**. uuid went **ESM-only at v12+**, which would otherwise
force `transformIgnorePatterns` workarounds across the jest projects
(and broke server/integration/storybook CI on the earlier 13 attempt).
11.1.1 is the actual patched version, so this is the minimal fix.
### Changes
- `uuid` → `^11.1.1` in the three workspaces (lockfile regenerated under
hardened mode)
- one test (`useCreateManyRecords.test.tsx`): pin the mocked `v4` to its
string-returning overload — uuid's types declare a `Uint8Array` overload
that `jest.mocked` resolves to (present in v11 too, unrelated to ESM).
All usages are named imports, so no source migration. typecheck passes
(server/shared/front); affected specs pass. **No jest config changes.**
## What
Two standalone dep-set lockfiles bundled for the logic-function /
application-package sandbox carried vulnerable transitive versions.
Within-range bumps only (these dep sets are handed to user logic
functions, so no breaking majors):
**`seed-dependencies`**
- `nodemailer` `^8.0.4 → ^8.0.5` (resolves 8.0.10) — SMTP CRLF command
injection (direct dep)
- `brace-expansion`, `picomatch`, `lodash` → patched
**`common-layer-dependencies`**
- `lodash`, `brace-expansion` → patched
## Left for follow-up (can't be fixed within-range)
- `qs` — pinned transitively by `body-parser`, stays at 6.14.2 (needs
body-parser bump)
- `ip-address` 9.x → 10.x and `uuid` 10.x → 11.x — major bumps; left out
since these are exposed to user functions and would be breaking
`axios` here is already on the patched `^1.16.1`.
## What
Adds a `LOG` driver to the emailing-domain feature, selected via a new
`EMAILING_DOMAIN_DRIVER` config variable (defaults to `AWS_SES`, so
production behavior is unchanged).
The LOG driver:
- resolves domains to `VERIFIED` instantly (no DNS / SES setup)
- logs each `sendEmail` and returns a synthetic `messageId` instead of
calling SES
It also dev-seeds a pre-verified domain per workspace
(`<workspaceId>.dev.twenty.local`) so the feature works out of the box.
## Why
The emailing-domain feature currently ships only the AWS SES driver, so
the verify → send flow can't be exercised locally (or in CI) without
real AWS credentials. This unblocks local development and review of
anything built on emailing domains.
## Usage
```
EMAILING_DOMAIN_DRIVER=LOG
```
The seeded `*.dev.twenty.local` domain is already verified; sends are
logged (`[log-driver] sendEmail ...`).
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
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.
## What
Renames the historical TypeORM migrations directory so it's obvious at a
glance the path is frozen.
- `packages/twenty-server/src/database/typeorm/core/migrations/common/`
→ `…/typeorm/core/legacy-typeorm-migrations-do-not-add/common/`
- `packages/twenty-server/src/database/typeorm/core/migrations/billing/`
→ `…/typeorm/core/legacy-typeorm-migrations-do-not-add/billing/`
- `packages/twenty-server/src/database/typeorm/core/migrations/utils/` —
**left in place** (those SQL helpers are still imported by current
instance/workspace commands, see e.g.
`1-21-workspace-command-1775500002000-add-global-key-value-pair-unique-index.command.ts`)
- Updates `core.datasource.ts` globs to the new path and adds an inline
comment explaining the dir is frozen
- Adds a `README.md` at the new dir pointing readers at
`UPGRADE_COMMANDS.md` and the active `upgrade-version-command/` tree
## Why
The TypeORM migration system was replaced by fast/slow instance commands
+ workspace commands (PR #19356), but `typeorm/core/migrations/common/`
still looked structurally identical to an active migrations folder, with
new files merging in as recently as last week. New contributors — and AI
agents — kept inferring it was the active path and adding TypeORM
`MigrationInterface` files. See #21286 for the most recent instance.
This is recommendation **1b** from #21286
(`https://github.com/twentyhq/twenty/pull/21286#discussion_r3369356792`).
A renamed folder is the single strongest signal — no one instinctively
adds to a `do-not-add` directory.
## Notes
- Imports from `src/database/typeorm/core/migrations/utils/…` keep
working because `utils/` did not move.
- No behavior change at runtime: TypeORM loads the same files via
`_typeorm_migrations`, just from the new path.
## Test plan
- [ ] CI green
- [ ] `nx build twenty-server` succeeds
- [ ] Fresh `nx database:reset twenty-server` from a clean DB still
replays the legacy TypeORM migrations (i.e. `_typeorm_migrations` rows
get inserted at boot/init)
- [ ] Spot-check that an instance command that imports from
`migrations/utils/` still resolves at build time (e.g.
`1-21-workspace-command-1775500002000-add-global-key-value-pair-unique-index.command.ts`)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
## Problem
Interrupting an AI chat turn mid tool-call batch permanently bricks the
thread. Every subsequent message fails with:
> Tool results are missing for tool calls toolu_…, toolu_…
## Root cause
When the model fires a parallel batch of tool calls, it streams all the
calls first, then results come back one by one. If the stream is aborted
(user hits stop, credit cutoff, etc.) after only some have resolved, the
AI SDK's `onFinish` still fires with the partial assistant message —
including tool parts left in `input-available` state (a tool call with
no result).
`addMessage` persists that message verbatim. On the next turn the
history is rebuilt and `streamText` validates it: every `tool-call` must
be cleared by a `tool-result` before the next user message, or it throws
`MissingToolResultsError` (`ai/dist`, the `MissingToolResultsError`
check). The orphaned calls are now in the DB, so the thread fails on
every turn from then on.
## Fix
Enforce the invariant at the single write chokepoint. Every chat message
is persisted through `AgentChatService.addMessage`, so
`finalizeDanglingToolParts` runs there once: any tool part still in
`input-available` is rewritten to `output-error` ("Tool execution was
interrupted.") before mapping to DB rows.
`output-error` converts to a real `tool-result`, so the persisted turn
is always self-consistent and the next request is valid. Interrupted
calls are kept (not dropped) and surfaced as errored rather than
perpetually "running" — honest, since a partially-executed call may have
committed side effects the model should be able to reconcile.
One guard at one point covers every abort source — no read-side
patching, no migration, no schema change.
## Caching impact
None on the happy path. A completed turn has no `input-available` parts,
so the helper is a no-op and the persisted bytes (and therefore the
cached prefix) are identical to before. For an interrupted turn, the
finalized content is deterministic and written once, so it caches
cleanly on the following request and stays stable across later turns —
there is no scenario where this invalidates an existing cache entry. Net
effect: turns a hard failure into a normally-cached continuation.
## Testing
- New unit test covering finalize / no-op cases (7 cases, passing)
- `oxlint --type-aware` + `oxfmt` clean on changed files
## 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.
Split out of #21240. Stacked on #21250 (review/merge that first).
`yarn twenty dev --once --dry-run` computes the migration plan and
prints the diff **without applying anything** (no migration, no
app-record update, no SDK generation). Also renders the diff on a normal
`dev --once` sync.
<img width="646" height="179" alt="image"
src="https://github.com/user-attachments/assets/59f3ddcd-2a5b-4b8a-b21a-c659abe16af0"
/>
A junction relation points at a target field on the join object that
another action may create later (two junctions into the same join
reference each other). The builder validator now also looks up the
target in the to be created set, and the runner mints every field id up
front so the target resolves regardless of action order, the same way
relation pairs are already handled.
---------
Co-authored-by: Félix Malfait <felix@twenty.com>
## Problem
Self-hosted upgrades crossing 2.6 (e.g. `2.4 → 2.6/2.9`) can abort with:
```
column ViewFilterEntity.relationTargetFieldMetadataId does not exist
at WorkspaceFlatViewFilterMapCacheService.computeForCache
at WorkspaceCacheService.recomputeDataFromProvider
[UpgradeSequenceRunnerService] Workspace steps ended with 1 failure(s). Aborting
```
This is **Failure #1** from #20841 — the counterpart to the
role-permission cache crash fixed in #21257 (Failure #2). Same shape: a
workspace **cache recompute runs mid-upgrade and reads schema that the
target version's migration hasn't applied yet**.
## Root cause
`ViewFilterEntity.relationTargetFieldMetadataId` is added to
`core.viewFilter` only at the **2.6.0** cursor
(`AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand`, ts
`1798000005000`). But the workspace cache recompute SELECTs every column
of the entity, and it runs during *earlier* (2.5) workspace steps.
Unlike `RolePermissionFlagEntity.permissionFlag`, this column has **no
`@WasIntroducedInUpgrade` gate**, so the proxy can't hide it — and the
SELECT fails when the column isn't there yet.
There are three `IF NOT EXISTS` backport commands (2.3/2.4/2.5) meant to
add the column sooner, but they use **low timestamps** that sort to the
front of their version bundles. An instance whose cursor has already
advanced past those positions (e.g. it reached 2.4, or a prior failed
attempt advanced it through 2.5 instance commands) treats them as
already-applied and **skips them** — so the column is never created, yet
the entity keeps selecting it.
## Fix
Gate the column with `@WasIntroducedInUpgrade` pointing at the **2.6.0**
command that adds it:
```ts
@WasIntroducedInUpgrade({
upgradeCommandName:
'2.6.0_AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand_1798000005000',
})
@Column({ nullable: true, type: 'uuid', default: null })
relationTargetFieldMetadataId: string | null;
```
`UpgradeAwareRepositoryProxy` then hides the column from reads while the
cursor is < 2.6, so the cache recompute simply omits it — no crash — and
it becomes visible once the 2.6.0 command has run (where it's guaranteed
to exist). Gating to **2.6.0** specifically (not the earlier backports)
is what fixes the cursor-skip case: 2.6.0 is the first point where the
column is reliably present regardless of whether the backports ran.
Validator-safe: the referenced command resolves to a real step
(`computeCommandName` = `${version}_${className}_${timestamp}`), so
`validate-upgrade-aware-entity-decorators` accepts it. The existing
backport commands are left untouched (committed instance commands).
## Recovery for already-stuck instances
This prevents *new* failures. An instance already aborted mid-upgrade
needs the column added manually before retrying:
```sql
ALTER TABLE core."viewFilter" ADD COLUMN IF NOT EXISTS "relationTargetFieldMetadataId" uuid;
DELETE FROM core."upgradeMigration" WHERE status='failed';
```
then re-run the upgrade on a build that includes this fix.
Refs #20841
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
- Records the time each job spends waiting in the queue (enqueue →
processing start) as an OpenTelemetry histogram
- Metric is broken down by `queue` and `job_name` attributes, enabling
per-queue p50/p95/p99 latency analysis
- Uses BullMQ's native `job.timestamp` for accurate measurement
## Test plan
- [x] Deploy to staging and verify `job/latency-ms` metric appears in
ClickHouse `otel_metrics_histogram` table
- [x] Confirm Grafana dashboard can query the histogram data
Split out of #21240. Stacked on #21249 (review/merge that first).
Concurrent `syncApplication` calls on the same workspace could
interleave their metadata migrations and leave metadata partially
applied. Wrap the manifest sync in a per-workspace cache lock
(`app-sync:<workspaceId>`), mirroring the install path. The rate-limit
throttle stays outside the lock.
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
## Problem
Self-hosted upgrades that jump versions (e.g. `2.4 → 2.7/2.9`) abort
with:
```
TypeError: Cannot read properties of undefined (reading 'universalIdentifier')
at WorkspaceRolesPermissionsCacheService.hasSettingsGatedObjectPermissions
at WorkspaceRolesPermissionsCacheService.computeForCache
at WorkspaceCacheService.recomputeDataFromProvider
```
Reported in #20841 (Failure #2). The sequence aborts mid-upgrade and
leaves the DB in a half-migrated state.
## Root cause
The per-workspace **cache recompute runs at a `2.5.0` workspace step —
before the `2.6` schema migrations apply**. At that cursor:
- `RolePermissionFlagEntity.permissionFlag` is
`@WasIntroducedInUpgrade('2.6.0_LinkRolePermissionFlagToPermissionFlag…')`,
so `UpgradeAwareRepositoryProxy` **strips the relation**
(`[upgrade-proxy] strip relation
RolePermissionFlagEntity.permissionFlag` in the logs) → `permissionFlag`
is `undefined`.
- `hasSettingsGatedObjectPermissions()` then does an **unguarded**
`rolePermissionFlag.permissionFlag.universalIdentifier` → throws.
The crash only manifests when a workspace has **≥1 `rolePermissionFlag`
row** (custom roles with gated settings perms / SDK `defineRole`). A
vanilla seed has an empty table, so `.find()` over `[]` never
dereferences anything — which is why it didn't reproduce on a clean
instance.
A null-safe fallback to the legacy `flag` column used to exist here; it
was dropped in #20730.
## Fix
Resolve the flag's universal identifier through a small helper that
falls back to the legacy `flag` column (only removed in `2.7.0`) when
the relation is unavailable:
```ts
private getRolePermissionFlagUniversalIdentifier(
rolePermissionFlag: RolePermissionFlagEntity,
): string {
// The `permissionFlag` relation is stripped during upgrades until the 2.6.0
// cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
return (
rolePermissionFlag.permissionFlag?.universalIdentifier ??
SystemPermissionFlag[rolePermissionFlag.flag]
);
}
```
`SystemPermissionFlag[flag]` yields the same UUID the relation would, so
the comparison stays in a single space and the computed permission is
exact (not an over-grant). Correct at every transitional cursor:
pre-`2.6` (relation stripped → use `flag`), `2.6` (both present →
relation wins), post-`2.7` (`flag` removed → relation wins).
## Reproduction & validation
Locally jumped a real `2.4.0` DB → `v2.9.0` build via `yarn command:prod
upgrade`:
| Scenario | Result |
| --- | --- |
| Empty `permissionFlag` (vanilla seed) | passes (no crash) |
| **+1 flag row**, current code | `TypeError … universalIdentifier` →
**3 succeeded, 1 failed** |
| Same fixture, **this fix** | **16 succeeded, 0 failed**, DB fully
migrated to 2.9.0 |
`nx typecheck twenty-server` clean; existing cache-service unit tests
pass; app boots on the upgraded DB.
## Scope / follow-up
This fixes **Failure #2**. **Failure #1** in the same issue
(`viewFilter.relationTargetFieldMetadataId` selected before its column
exists) is a separate instance of the same theme — cache recompute
reading "future" schema before migrations run — and is worth a
follow-up. A more durable systemic fix would defer the workspace cache
recompute until after all schema-adding migrations; this PR is the
low-risk, backport-friendly fix for the immediate breakage.
> Note: an earlier bot branch
(`sonarly-39738-fixupgrade-guard-role-permission-flag-relation`)
proposed the same fallback inline. This PR supersedes it with a named
helper + a focused comment.
Fixes#20841
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
- Adds `isDefined` guard in `handleLogicFunctionSubEntities` to skip
CODE steps with undefined `logicFunctionId` instead of crashing
- Adds same guard in `runWorkflowVersionStepDeletionSideEffects` for
consistency
- Rejects CODE steps in `create_complete_workflow` AI tool at runtime to
prevent creating workflows with missing logic functions in the first
place
Fixes `"Logic function with id undefined not found"`
INTERNAL_SERVER_ERROR when destroying workflows whose CODE steps were
created via `create_complete_workflow` without a proper logic function.
## Test plan
- [x] Destroy a workflow that has a CODE step with undefined
logicFunctionId → should succeed silently
- [x] Try creating a workflow with a CODE step via
`create_complete_workflow` tool → should return error message
- [x] Normal workflow destroy with valid CODE steps still deletes the
logic function
## Summary
- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version
## Checklist
- [ ] Verify version constants are correct
- [ ] Verify npm package versions match
Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
Adds **Call Recording** as a first-class standard object (Twenty's
flat-metadata
standard-object system), with a hidden junction to calendar events and a
backfill
command for existing workspaces. Everything is gated behind the
`IS_CALL_RECORDING_ENABLED` feature flag.
### What's included
- **`CallRecording`**: audio/video files, transcript, status, recording
policy,
timing, external bot/recording ids. Label identifier is
`meetingOccurrenceKey`.
- **`CallRecordingCalendarEventAssociation`**: hidden junction linking a
recording
to a calendar event (dedupes one bot to many subscribers of the same
meeting).
- Full metadata graph via the flat-metadata builders: fields, indexes,
views,
view fields/groups, record page layout, and navigation items.
- **Metadata-only reverse relation** on `CalendarEvent`: present in
standard
metadata, omitted from the TS entity class to avoid expanding recursive
nested-insert types.
- **Upgrade command (2.9.0)** backfilling active/suspended workspaces:
- Creates the full graph; idempotent (skips when it already exists).
- Moves a colliding custom `callRecording` object aside to
`callRecordingOld`
(numeric suffix if that name is also taken).
- Navigation items (commands) are flag-gated by `universalIdentifier`,
so a custom object
reusing the name is never gated.
### QA
Run locally against existing workspaces (with and without a name
collision) and a
freshly created workspace:
- [x] Backfill, collision: custom `callRecording` renamed to
`callRecordingOld`;
standard graph created.
- [x] Backfill, no collision: standard graph created; unrelated custom
object untouched.
- [x] Idempotent: re-run is a no-op, with no duplicate metadata and
counts unchanged.
- [x] New workspace via `init()` produces an identical graph to the
backfill
(`universalIdentifier` set-diff = 0).
- [x] Label identifier (`meetingOccurrenceKey`) holds position 0 in
non-widget views.
- [x] Nav items gated behind the feature flag; collision-renamed
object's nav
expression re-pointed to its new name.
- [x] Unit tests cover collision name resolution and nav-gating logic.
## 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.
## Summary
Closes#21229.
The two AI role permissions behaved **opposite to their labels**. The
trap is that the flag's code name is the inverse of its UI label:
| `PermissionFlagType` | UI label | Section | Means |
|---|---|---|---|
| `AI` | **"Ask AI"** | Actions | End-user: chat with AI |
| `AI_SETTINGS` | **"AI"** | Member / settings | Admin: configure AI
agents |
Before this PR (on `main`):
- `AI` ("Ask AI", chat) gated **both** the AI chat **and** the AI
settings page.
- `AI_SETTINGS` ("AI", configure agents) gated **nothing** the user
could see.
So a chat-only user could reach the whole AI **configuration** page, and
toggling the "AI" settings permission did nothing — exactly the
misalignment reported in #21229.
## Root cause
`PermissionFlagType.AI` *reads* like "the AI permission", so it looks
like the natural gate for the AI settings page — but it's actually the
**chat** flag. The settings page (nav item + route) had been pointed at
`AI` in #21072 to match the Overview stats query
(`findWorkspaceAiStats`), which was itself mis-gated on `AI`. Both the
stats query and the rest of the settings surface are admin/config
features, so they belong on `AI_SETTINGS`.
## Changes
All three move the **AI settings surface** from the chat flag (`AI`) to
the settings flag (`AI_SETTINGS`); chat keeps following `AI`:
- `useSettingsNavigationItems.tsx` — AI nav item → `AI_SETTINGS`
- `SettingsRoutes.tsx` — AI settings route group → `AI_SETTINGS`
- `ai-workspace-stats.resolver.ts` — `findWorkspaceAiStats`
(settings-only, drives the Overview tab) → `AI_SETTINGS`
After this: the "AI" permission controls the AI settings page + its
Overview; the "Ask AI" permission controls the chat. Both toggles now
match their labels.
## Test plan
- [ ] Role with **only "Ask AI"** (`AI`): AI chat tabs/pane visible;
**Settings → AI is hidden** and the route is not reachable.
- [ ] Role with **only "AI"** (`AI_SETTINGS`): Settings → AI is visible,
Overview stats load; chat nav is hidden.
- [ ] Admin (both flags): everything works as before.
## Known follow-ups (out of scope — pre-existing, shared endpoints)
These remain on `AI` because they're shared with non-settings surfaces
and need either OR-gating or a resolver split, so a role with
`AI_SETTINGS` but **not** `AI` still can't use them yet:
- `getAiSystemPromptPreview` (Models/Prompts tabs) lives in the chat
resolver, class-gated `AI`; NestJS guards are additive so it can't be
cleanly method-overridden — it should be pulled into a settings
resolver.
- Agent reads `findManyAgents` / `findOneAgent` (agent create/edit
forms) are class-gated `AI` and shared with the **Workflow** editor and
**Roles** pages; these want a guard that accepts `AI ∨ AI_SETTINGS ∨
WORKFLOWS`.
## Summary
Lets a Twenty application add **view fields (columns) to an existing
view it does not own** — including standard views like the People index
view — without redeclaring/owning that view. This mirrors the existing,
working pattern by which an app adds a custom field to a standard object
via `defineField` + `objectUniversalIdentifier`.
The asymmetry being removed was purely in the manifest schema:
`ViewFieldManifest` only existed *nested* inside
`ViewManifest.fields[]`, so adding a view field forced declaring a
`ViewManifest` — which the sync treats as a view the app creates and
owns, and rejects when the UID is a standard view's. Validation,
persistence, the FK aggregator machinery, and uninstall cleanup were
already generic and cross-app-safe, so no engine changes were needed.
### Changes
- **twenty-shared:** new top-level `StandaloneViewFieldManifest`
(`ViewFieldManifest & { viewUniversalIdentifier }`),
`Manifest.viewFields`, and a `SyncableEntity.ViewField` member.
- **twenty-sdk:** `defineViewField` (validates `universalIdentifier` +
`viewUniversalIdentifier` + `fieldMetadataUniversalIdentifier`), CLI
manifest assembly of a top-level `viewFields` list, and `dev:add
viewField` scaffolding.
- **twenty-server:** one top-level loop over `manifest.viewFields` that
reuses the existing `fromViewFieldManifestToUniversalFlatViewField`
converter (already parameterized by `viewUniversalIdentifier`). No
validator/persistence/aggregator changes.
### Notes for maintainers
- Confirm the `Manifest.viewFields` optionality convention — implemented
as a **required** array to mirror `fields`/`views`.
- Two different apps adding a column for the same field to the same view
conflicts on the existing unique `(fieldMetadataId, viewId)` partial
index; the existing `flat-view-field-validator` duplicate check surfaces
this as a structured validation error.
- `dev:add viewField` scaffolding is included (was optional in the
plan).
## Test Plan
- [x] `twenty-shared` typecheck
- [x] `twenty-sdk` 364 unit tests + `buildManifest` assembly test
(rich-app fixture) + typecheck + prettier
- [x] `twenty-server` typecheck + `lint:diff-with-main`
- [x] **Server integration suite**
`successful-manifest-update-view-field.integration-spec.ts` (4/4):
- standalone view field attaches to the standard `allPeople` view
without recreating it (sync succeeds, no
`INVALID_VIEW_DATA`/`ENTITY_ALREADY_EXISTS`)
- uninstall removes the contributed column while the standard view + its
columns remain intact
- duplicate `(view, field)` rejected with `METADATA_VALIDATION_FAILED`
- unknown target view rejected
- [x] Sibling `successful-manifest-update-field.integration-spec.ts`
still green (no harness regression)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.
**Please review before merging** — verify no critical models were
incorrectly deprecated.
Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
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