## Background
The 2026-04-26 incident saw 716M Sonnet 4.6 tokens consumed in a single
trial workspace. Two causes: failed agent executions weren't billed
(addressed by #20065) and the credit-cap gate had been removed from
`WorkflowExecutorWorkspaceService.executeStep` in #19904, leaving no
enforcement point at all.
## Why not just revert #19904#19904 was right that gating at the workflow executor is too coarse.
When one user exhausted a workspace's credits via chat, *all* workflows
hard-failed mid-run — including cheap DB/CRUD/branch automations costing
essentially nothing. Reverting would re-introduce that cliff.
## New design: gate at the AI entry points
The chat resolver already gates this way
(`agent-chat.resolver.ts:137-148`). This PR replicates the same pattern
at every other point where the workspace can incur real AI cost:
- `executeAgent` in `agent-async-executor.service.ts`
- the REST handler in `ai-generate-text.controller.ts`
- `generateThreadTitle` in `agent-title-generation.service.ts`
In each, after auth/validation: skip if `IS_BILLING_ENABLED` is false;
otherwise call `BillingService.canBillMeteredProduct(workspaceId,
BillingProductKey.WORKFLOW_NODE_EXECUTION)`; on `false`, throw
`BillingException(BILLING_CREDITS_EXHAUSTED)`. No new method, no new
exception code, no new product key.
This matches industry convention (Lovable/Replit also gate at the
expensive-operation boundary, not at every cheap step).
## Deliberately not gated
- `WorkflowExecutorWorkspaceService.executeStep` — the design choice is
now intentional, so the #19904 TODO is replaced by a one-line
absolute-behavior comment explaining why the gate isn't here. Cheap
workflow steps (DB CRUD, branching, action steps) are not gated, so a
chat-driven cap exhaustion does not block non-AI automations.
- `repair-tool-call.util` — repair is a sub-call inside an already-gated
AI flow. If the parent is gated, repair will naturally not run. Adding a
gate here adds complexity without value.
## Net effect
A workspace that exhausts credits via chat or AI agent stops making AI
calls. Its non-AI workflows continue running normally. A workflow with
both AI and non-AI steps fails at the AI step with
`BILLING_CREDITS_EXHAUSTED`, but downstream non-AI steps that don't
depend on the AI output still run.
## Conflicts
This PR overlaps with three other in-flight PRs in the same files. None
of them touch the gate logic; rebasing on top of any of them is trivial:
- #20065 (agent-async-executor): adds `workspaceId` to `executeAgent`
args and bills in `finally`. The gate at the top of `executeAgent` from
this PR sits naturally above that.
- #20066 (REST controller): adds usage billing to the controller.
- #20067 (title gen): adds usage billing to title generation and
tool-call repair.
Recommend landing #20065/#20066/#20067 first; this PR rebases trivially
on top.
## Tests
Out of scope per the PR series convention. The existing chat-resolver
gate isn't unit-tested either; this PR follows the same precedent.
Follow-up: add integration coverage that exercises a workspace at
`hasReachedCurrentPeriodCap=true` against each of the three new gates
plus the pre-existing chat-resolver gate.
## Future follow-ups
- Per-user soft cap inside a workspace (the Lovable Business-tier
pattern), so one user can't exhaust the workspace's cap.
- Pre-flight cost estimate so the user sees an "approaching cap" warning
before the hard stop.
- Rename `BillingProductKey.WORKFLOW_NODE_EXECUTION` — the name predates
this design choice and is misleading now that it gates AI entry points
rather than workflow nodes.
## Test plan
- [ ] Trigger a workspace into `hasReachedCurrentPeriodCap=true`.
- [ ] Send a chat message — expect failure with
`BILLING_CREDITS_EXHAUSTED`.
- [ ] Run a workflow whose only AI step is an `ai-agent` action — expect
that step to fail with `BILLING_CREDITS_EXHAUSTED`, downstream non-AI
steps still run.
- [ ] POST to `/rest/ai/generate-text` — expect
`BILLING_CREDITS_EXHAUSTED`.
- [ ] Create a new chat thread (which kicks off `generateThreadTitle`) —
expect `BILLING_CREDITS_EXHAUSTED`.
- [ ] Run a workflow with no AI step (only DB CRUD/branching/actions) —
expect it to run unaffected.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EDIT :
- solving auto-caching from Anthropic by updating ai-sdk/anthropic +
adding providerOption at stream level
- concerning Bedrock, it needs breakpoint
**1. Breakpoints were only on the system prompt**
The code already placed a cache marker on the system prompt (~10K
tokens). But the conversation history — which can grow to hundreds of
thousands of tokens — had no marker, so Anthropic re-read it at full
price on every turn.
The fix adds a prepareStep hook inside streamText that stamps the last
message with a cache breakpoint before every LLM call. Anthropic then
caches the entire conversation prefix, and subsequent turns read it at
$0.30/M instead of $3/M.
prepareStep is used rather than a one-shot pre-processing step because
an agentic turn makes multiple internal LLM calls as tool results
accumulate — the hook refreshes the breakpoint before each one.
**2. Bedrock was using the wrong field**
The system prompt marker for Bedrock was set as cacheControl: { type:
'ephemeral' } — which is the Anthropic wire format. The Bedrock Converse
API expects cachePoint: { type: 'default' }. The system prompt was
silently not being cached on Bedrock at all.
Both the system prompt and the new prepareStep now go through a shared
getCacheProviderOptions helper that returns the correct field per
provider.
**3. Persisted cached token usage to monitor cache strat. efficiency**
Hardened CalDav with new approach of wrapping axios ssrf http agent to
fetch via `@lifeomic/axios-fetch` because `tsdav` only accept `fetch`
override.
Also Hardened test endpoint
## Summary
Adds `h1`–`h6` component overrides to `LazyMarkdownRenderer` so that
`[[record:...]]` references placed inside markdown headings in the AI
chat are parsed by `processChildrenForRecordLinks` and rendered as
clickable `RecordLink` chips, matching the behavior already in place for
`p`, `li`, `td`, `th`, and `a`.
Fixes#20072
## Test plan
- [ ] In AI chat, ask a question whose answer places a record reference
inside a markdown heading (e.g. `## Found [[person:uuid:John Doe]]`) and
confirm a clickable `RecordLink` chip renders instead of the raw
`[[...]]` text.
- [ ] Verify heading styling (sizes, weights, margins) is unchanged.
Generated with [Claude Code](https://claude.ai/code)
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: nitin <ehconitin@users.noreply.github.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>
# Introduction
Prevent using both `--start-from-workspace-id` and `--workspace`
When any of the two are being passed we prevent passing to the next
instance segment, it would require an upgrade re run even if legit
When `--start-from-workspace-id` is passed we filter from all the
fetched active or suspended workspace ids and apply equivalent filter as
before
# Summary
(fixes#20044)
This PR implements two fixes for the REST API to enforce stricter
validation and provide better error messages.
Issue 1: Cursor parameter silently ignored
Problem: When users provided common cursor aliases (e.g., cursor, after,
before) instead of the correct parameter names (starting_after,
ending_before), the API silently ignored them and returned page 1 on
every request.
Solution: Added strict validation to detect common cursor aliases and
throw a clear error directing users to use the correct parameter names.
Files modified:
-
packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts
-
packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util.ts
-
packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util.ts
- Test files for both parsers
Example error:
Invalid cursor parameter 'cursor'. Use 'starting_after' for pagination.
---
Issue 2: OpportunityStageEnum not validated on REST
Problem: When creating or updating opportunities via REST with an
invalid stage value, the API either silently dropped the value or
returned a generic error without listing valid options.
Solution: Updated the SELECT field validation to include valid options
in the error message.
Files modified:
-
packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-rating-and-select-field-or-throw.util.ts
- Test file
Example error:
Invalid value "BAD_VALUE" for field "stage". Valid values are: NEW,
SCREENING, MEETING, PROPOSAL, CUSTOMER
---
### Testing
- Added 5 new test cases for `parse-starting-after-rest-request.util.ts`
- Added 6 new test cases for `parse-ending-before-rest-request.util.ts`
- Added 1 new test case for
`validate-rating-and-select-field-or-throw.util.ts`
- All 21 tests passing
---
Breaking Changes
None - correct usage is unaffected.
---------
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
- Update usageEvent clickhouse table, partitioning, indexing and
projection (auto materialized view) to optimize credit usage queries
- Add caching for available credits and billing subscription
To do in next PR: deprecate enforceCapUsage cron. Bonus : real-time on
billingSubscription
## Summary
- Rename safeParseRelativeDateFilterJSONStringified to
safeParseRelativeDateFilterJsonStringified
- Update the matching utility file, exports, tests, and workflow usages
Part of #19839.
## Validation
- CI passed
## Summary
`AgentAsyncExecutorService.executeAgent` consumes Anthropic tokens at
two points (the main `generateText` and the optional structured-output
sub-call). Billing was previously the **caller's** responsibility,
executed only after `executeAgent` returned. If `executeAgent` threw —
e.g. when `structuredResult.output == null` for a schema-mismatched
response, or anything caught by the catch-and-rethrow — we paid
Anthropic but never recorded a `usageEvent`. Likely the dominant source
of the 716M-token-vs-3.27-credits discrepancy seen on the affected
workspace in the 2026-04-26 incident.
## What changed
- Inject `AiBillingService` into `AgentAsyncExecutorService`. Add
`workspaceId` (required), `userWorkspaceId`, and `operationType`
(default `AI_WORKFLOW_TOKEN`) to `executeAgent`'s args.
- Capture `accumulatedUsage`, `cacheCreationTokens`, and
`nativeWebSearchCallCount` into mutable locals as each `generateText`
resolves. A throw between the main and structured-output calls still
bills the first call's tokens; the schema-validation throw still bills
the merged usage.
- Wrap the body in `try { ... } finally { ... }`. The finally calls
`calculateAndBillUsage` and `billNativeWebSearchUsage`, each guarded by
its own `try/catch + logger.error` so a billing exception can't mask the
original execution error or block the second emit.
- `ai-agent.workflow-action.ts`: pass the new args; drop the
now-redundant billing calls and `AiBillingService` injection.
`AiBillingModule` removed from this action's module imports.
- `run-evaluation-input.job.ts`: pass `workspaceId` (already in `data`)
and `userWorkspaceId: null`. **As a side effect, the eval pipeline now
bills correctly** — closing an additional billing leak from the audit
(`RunEvaluationInputJob` previously called `executeAgent` and discarded
`executionResult.usage`).
## Behavior change worth calling out
Previously, failed agent executions were silently free. They will now be
billed for the tokens Anthropic charged us. This is intentional and
correct.
## Test plan
- [ ] Trigger a workflow agent action that succeeds — `usageEvent` count
should match what was previously emitted.
- [ ] Trigger a workflow agent with a JSON response schema and ambiguous
input that produces a non-schema-conforming output
(`structuredResult.output == null`) — verify a `usageEvent` row is now
written for the consumed tokens (was 0 rows previously).
- [ ] Trigger a `runEvaluationInput` GraphQL mutation — verify a
`usageEvent` row is written (was 0 rows previously).
## Notes for review
- Conflicts trivially with the Sentry-context PR on
`run-evaluation-input.job.ts`. Recommend merging Sentry-context first;
this PR's 2-line argument addition rebases inside that PR's
`aiCallContextService.run(...)` callback wrapper.
- A small follow-up after this lands: thread `billingContext` through
the `experimental_repairToolCall` callback in
`agent-async-executor.service.ts:198` (currently marked with a TODO from
the title-gen+repair-tool PR).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.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>
## Summary
`POST /rest/ai/generate-text` calls `generateText` and returns `usage`
to the client without emitting a `usageEvent`. Authenticated, gated only
by `PermissionFlagType.AI` — any workspace user with that permission
could call it in a loop without billing. Identified during the
2026-04-26 incident audit.
## What changed
- Inject `AiBillingService` into `AiGenerateTextController`.
- Add `@AuthUserWorkspaceId() userWorkspaceId: string` to source the
user-workspace identifier.
- Wrap the `generateText` call in `try { ... return ... } finally { ...
}` so billing fires even if the controller throws after Anthropic was
paid.
- Bill with `UsageOperationType.AI_WORKFLOW_TOKEN` and
`cacheCreationTokens: result.usage.inputTokenDetails?.cacheWriteTokens
?? 0`.
- Inner `try/catch` around the billing emit so a billing error can't
break the response.
- One-line module change: `AiGenerateTextModule` imports
`AiBillingModule` (NestJS DI requirement).
## Test plan
- [ ] Call `POST /rest/ai/generate-text` with a small prompt; verify a
`usageEvent` row appears in ClickHouse for the workspace with the
correct token count and `operationType = AI_WORKFLOW_TOKEN`.
- [ ] Call with a malformed model id that throws after the API key is
validated — verify no spurious billing call occurs (no Anthropic call
was made).
## Notes for review
- Response shape unchanged.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
## Summary
Two `generateText` call sites were unbilled — identified during the
2026-04-26 incident audit:
- **Thread title generation**
(`AgentTitleGenerationService.generateThreadTitle`) — fires once per new
chat thread; low-volume but completeness matters.
- **Tool-call repair** (`repairToolCall` util, used inside
`experimental_repairToolCall` callbacks) — can fire `MAX_STEPS` times
per agent turn if a model gets stuck producing malformed tool calls.
## What changed
- `AgentTitleGenerationService` — inject `AiBillingService`, expand
`generateThreadTitle` signature to accept `workspaceId` and
`userWorkspaceId`, bill in a `finally` block with
`UsageOperationType.AI_CHAT_TOKEN`. Restructured so the no-default-model
short-circuit happens before the `try`, avoiding a fake billing call.
- `repair-tool-call.util.ts` — added an optional `billingContext` arg
containing `aiBillingService`, `modelId`, `workspaceId`,
`userWorkspaceId`, `operationType`. Wraps the `generateText` call in
`try/finally`; bills in finally with the operation type provided by the
caller. Optional so existing callers keep compiling.
- `chat-execution.service.ts` — threads `billingContext`
(`AI_CHAT_TOKEN`) into the repair callback.
- `agent-chat.service.ts` — passes `workspaceId` and
`thread.userWorkspaceId` to `generateThreadTitle`.
- `agent-async-executor.service.ts` — TODO comment marking that the
repair callback should thread billing once `executeAgent` accepts
`workspaceId` (depends on the executeAgent-finally PR).
## Follow-up
After the executeAgent-finally PR lands, `workspaceId` is in scope at
the `experimental_repairToolCall` callback in
`agent-async-executor.service.ts:198`. Thread `billingContext` through
to fire repair-call billing for workflow agents too, and remove the
TODO. Tiny follow-up.
## Test plan
- [ ] Create a new chat thread; verify a `usageEvent` row is written for
the title generation call.
- [ ] Send a chat message that triggers tool-call repair (e.g. force a
malformed tool call); verify a `usageEvent` row for the repair sub-call.
## Notes for review
- `billingContext` arg made **optional** to allow incremental rollout —
the executeAgent-finally PR plus a small follow-up will close the
agent-async-executor side.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary
After the 2026-04-26 token-usage incident, identifying the responsible
workspace from a Sentry trace required a Postgres scavenger hunt —
Vercel AI SDK auto-instrumentation captures token counts and model name
but no twenty-specific identifiers, and that same gap exists for every
other auto-instrumented span (HTTP outbound, Postgres queries, GraphQL
resolvers, Redis, etc.).
This PR plugs that gap globally, not just for AI:
- A small utility
(`packages/twenty-server/src/engine/core-modules/sentry/utils/sentry-workspace-context.util.ts`)
that writes workspace identifiers onto Sentry's active isolation scope
as a `twenty` context block plus filterable tags and a `Sentry.setUser`
call.
- Two hook points covering all server traffic:
- **`WorkspaceAuthContextMiddleware`** — already runs after token
hydration on the GraphQL, metadata, admin-panel, and REST routes. It now
calls the utility once per authenticated request, before delegating to
`withWorkspaceAuthContext`.
- **`BullMQDriver.work` and `SyncDriver.processJob`** — every queue job
now runs inside `Sentry.withIsolationScope` and applies workspace
context from `job.data.workspaceId` (skipping silently for system jobs
that don't carry one).
- A `beforeSendSpan` hook in `instrument.ts` that reads the scope's
`twenty` context block back and projects it onto every span as
`twenty.workspace.id` and (when available) `twenty.user_workspace.id` —
dotted-namespace naming consistent with OTel/Sentry conventions like
`user.id` and `http.response.status_code`. Spans without a workspace
context (unauthenticated traffic) pass through untouched.
## Why this shape
Sentry's docs position `beforeSendSpan` as a per-span hook. The previous
iteration set context only at AI-specific call sites, which left non-AI
spans (DB queries, outbound HTTP, regular GraphQL queries, workflow
steps not touching AI) entirely unenriched. Hooking the two existing
global boundaries — auth middleware for HTTP/GraphQL/REST, and the queue
driver `work()` callback for background jobs — covers every
authenticated span across the app with no per-handler instrumentation.
## What's not in this PR
AI-specific identifiers (`twenty.agent.id`, `twenty.thread.id`,
`twenty.turn.id`, `twenty.workflow_run.id`) are out of scope here.
They're useful additions but require either propagating the IDs through
the call stack or a more fine-grained scope (per-step, per-turn) than
the request/job boundary, which is best handled in follow-up PRs that
target those specific call sites.
## Test plan
- [ ] Make any authenticated GraphQL request locally and confirm the
resulting span(s) in Sentry carry `twenty.workspace.id` and (for
user-authenticated routes) `twenty.user_workspace.id`.
- [ ] Make any authenticated REST request and confirm the same.
- [ ] Trigger a queue job (chat stream, agent turn evaluation, workflow
run, etc.) and confirm spans produced inside the worker carry
`twenty.workspace.id`.
- [ ] Confirm that DB and outbound HTTP spans produced under the
request/job also carry the workspace tag — these previously had no
twenty-specific identifiers.
- [ ] In the Sentry UI, filter events by the `twenty.workspace.id` tag
and confirm matching events appear.
## Notes for review
- Sentry init lives in `instrument.ts`, loaded before Nest bootstraps,
so `beforeSendSpan` runs outside Nest DI and reads context off the
isolation scope rather than holding a service reference.
- The middleware change is three lines; the BullMQ wrap is a single
`Sentry.withIsolationScope` around the existing job handler body; the
SyncDriver wrap mirrors it for the dev/test path. No new modules or DI
providers.
- The previous iteration's `AiCallContextService` and per-handler
`setContext` / `withContext` calls have been removed in favor of these
two hooks.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Introduction
In a nutshell, added a more readable logs that relates that when
performing a workspace fitlered workspace you can only browser the
workspace commands sequence
This PR is not a fix, but a log improvement
As before when cross-upgrading a single workspace you would be facing a
`instance` sync barrier error as not all your workspaces would have been
updated
Now logging and early returning instead of letting the guard hard throw
## Tests
Created a dedicated test for instance prevention on filtered upgrade
Standardized `migrationRecordToKey` usage across all upgrade integration
tests suites
## Summary
`ImapGetAllFoldersService.isMailboxSelectable` checked
`mailbox.flags?.has('\\Noselect')`, which is case-sensitive. Per [RFC
3501 §6.3.8](https://www.rfc-editor.org/rfc/rfc3501#section-6.3.8), IMAP
attribute names are case-insensitive — different servers spell the flag
differently:
| Server | Spelling |
|---|---|
| Dovecot | `\Noselect` |
| Stalwart | `\NoSelect` |
| Cyrus | `\Noselect` |
| RFC examples | `\NOSELECT` |
The previous check only caught Dovecot's spelling. On other servers,
virtual namespace placeholders (e.g. Stalwart's `Shared Folders` parent)
passed through `isMailboxSelectable` and got persisted as folders. When
`MessagingMessageListFetchJob` later ran, it issued `SELECT "Shared
Folders"`, the server correctly rejected it with `NO [NONEXISTENT]
Mailbox does not exist.`, and the entire message-list fetch failed for
the channel.
## Reproduction
1. Connect an IMAP account whose server advertises a `\NoSelect` (or
`\NOSELECT`) namespace placeholder. Stalwart Mail v0.15.x exhibits this
with shared mailboxes:
```
* LIST (\NoSelect) "/" "Shared Folders"
```
2. The folder discovery job persists it as a syncable folder.
3. `MessagingMessageListFetchJob` runs and fails:
```
IMAP: Error fetching message list: D0 SELECT "Shared Folders"
responseStatus: NO serverResponseCode: NONEXISTENT
```
## Fix
Iterate the flag set and lowercase-compare against `'\\noselect'`. This
matches every legal spelling without changing behavior for compliant
`\Noselect` clients.
```diff
private isMailboxSelectable(mailbox: ListResponse): boolean {
- return !mailbox.flags?.has('\\Noselect');
+ if (!mailbox.flags) return true;
+ for (const flag of mailbox.flags) {
+ if (flag.toLowerCase() === '\\noselect') return false;
+ }
+ return true;
}
```
## Test plan
- [x] Existing `\Noselect` test cases (`should not issue STATUS against
a \Noselect folder`, parent-reference preservation, Sent-folder
exclusion) still pass — the lowercase comparison subsumes them.
- [x] New parameterized test covers `\Noselect`, `\NoSelect`,
`\NOSELECT`, `\noselect` spellings against a Stalwart-style `Shared
Folders` namespace placeholder, asserting Twenty does **not** issue
`STATUS` on the placeholder and does **not** include it in the
discovered folder set.
---------
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
refactored `SentMessagePersistenceService` to be thin wrapper over
`saveMessagesAndEnqueueContactCreation`
this fixes a bug where the old logic did not call match participants
causing messages to not show up
## Summary
- Add optional `rawBody?: string` to `LogicFunctionEvent` and forward it
from the route trigger so HMAC-based webhook signatures (GitHub's
`X-Hub-Signature-256`, Stripe, …) can be verified by user logic
functions.
- Update `github-connector`'s `getRawBodyForSignature` to prefer
`event.rawBody` (with the existing string/base64/null fallbacks kept for
older runtimes).
## Why
GitHub computes `X-Hub-Signature-256` over the **raw bytes** of the
request body. The receiver must verify against those exact bytes — key
order, whitespace and unicode escaping all matter, so the parsed JSON
body cannot be re-serialized to them.
Today the route trigger calls `extractBody(request)` which returns the
parsed object only. NestJS already preserves the raw body on
`request.rawBody` (the app is bootstrapped with `rawBody: true` in
`main.ts`), but it was never propagated into `LogicFunctionEvent`.
As a result the github-connector's webhook handler always took the "raw
body unavailable" branch and rejected every delivery (after #19961 /
962c2b3c14). With this change, signature verification can succeed
end-to-end.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.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>
## Summary
- Add user avatars and workspace logos to the admin general table and
workspace member detail view
- Show app icons in the admin app registrations table and reuse the
shared application display component
- Expose the needed avatar and logo fields through admin GraphQL queries
and backend lookup/statistics services
- Keep workspace fallback behavior consistent when no logo is set and
clean up a few local table styling duplicates
## Testing
- `./node_modules/.bin/tsc -p packages/twenty-front/tsconfig.json
--noEmit --pretty false`
- Manual UI verification of the admin general, workspace detail, and
apps tables
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
## Summary
Fixes a race condition in `LocalDriver` where concurrent `execute()`
calls for the same application can trash each other's layer build,
causing logic function executions to fail with either:
- `Error: ENOENT: process.cwd failed with error no such file or
directory, the current working directory was likely removed without
changing the working directory, uv_cwd` (the yarn-install child's `cwd`
got wiped mid-run), or
- `ENOENT: no such file or directory, open '…/<pkg>/<file>.js.map'`
raised from yarn's berry link step (files under the deps layer vanish
while yarn is extracting).
Both are thrown from `…/deps/<checksum>/.yarn/releases/yarn-4.9.2.cjs` —
i.e. the yarn process `copyYarnEngineAndBuildDependencies` spawns with
`cwd: buildDirectory`.
## Root cause
`LocalDriver.createLayerIfNotExist` (and `ensureSdkLayer`) both follow a
check-then-act pattern with no mutual exclusion:
```ts
if (await pathExists(depsNodeModulesPath)) return;
await fs.rm(depsLayerPath, { recursive: true, force: true });
await copyDependenciesInMemory(...);
await copyYarnEngineAndBuildDependencies(depsLayerPath); // spawns yarn with cwd=depsLayerPath
```
The deps layer path is shared across all `execute()` calls that match a
given `yarnLockChecksum`. Two concurrent callers (e.g. a webhook
invocation + a cron-triggered logic function firing while the first
run's layer is still being built) both see no `node_modules`, both
`fs.rm` the directory, and one's yarn child ends up with its `cwd` or
its extraction target gone.
## Fix
Wrap the critical sections with `CacheLockService.withLock` — the same
cache-backed lock the Lambda driver already uses for its layer/executor
builds:
- `createLayerIfNotExist` → lock key
`local-driver-deps-layer:${yarnLockChecksum ?? 'default'}`
- `ensureSdkLayer` → lock key
`local-driver-sdk-layer:${workspaceId}:${applicationUniversalIdentifier}`
A fast-path `pathExists` check is kept outside the lock (so warm
executions still skip lock acquisition entirely), and the existence +
staleness check is **repeated inside the lock** so followers no-op after
the leader finishes.
Lock parameters mirror the Lambda driver's layer-build lock (`ttl=120s`,
`retry=500ms`, `maxRetries=240`).
`cacheLockService` is now passed to `LocalDriver` from
`LogicFunctionDriverFactory` (it was already injected there for the
Lambda driver).
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>
## Summary
We are releasing Twenty v2.2.0. This PR sets up the
upgrade-version-command machinery for the new release line:
- Promote `2.1.0` into `TWENTY_PREVIOUS_VERSIONS` (it just shipped)
- Set `TWENTY_CURRENT_VERSION` to `2.2.0`
- Reset `TWENTY_NEXT_VERSIONS` to `[]`
- Refresh the `InstanceCommandGenerationService` snapshots to reflect
the new current version (`2.2.0` / `2-2-` slug)
- Update the failing-sequence-runner snapshot to include `2.2.0` in the
covered versions list
The `2-2/` upgrade-version-command module is already in place and wired
into `WorkspaceCommandProviderModule`, so future upgrade commands
targeting `2.2.0` can land directly under `2-2/` (or be generated
against `--version 2.2.0`).
## Summary
The fast instance command adding `isPreInstalled` to
`core.applicationRegistration` was added after 2.0 was released, so it
must run as part of the 2.1 upgrade rather than 2.0.
- Renamed
`2-0/2-0-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration.ts`
to
`2-1/2-1-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration.ts`
- Updated `@RegisteredInstanceCommand` version from `'2.0.0'` to
`'2.1.0'`
- Updated the import path in `instance-commands.constant.ts`
The original timestamp (`1776886452831`) was kept; it is smaller than
the existing 2.1 fast command timestamp, so this command will simply run
first within the 2.1.0 batch.
## Summary
- Hides the `exportRecords`, `exportView`, and `importRecords` command
menu actions from
users whose role does not hold the matching `EXPORT_CSV` / `IMPORT_CSV`
permission flag.
- Exposes the current user's role permission flags to
`conditionalAvailabilityExpression`
by adding `permissionFlags: Record<string, boolean>` to
`CommandMenuContextApi`, mirroring
how `featureFlags` is already accessible.
- Adds a `2.1.0` workspace upgrade command that rewrites the three
existing rows on every
active/suspended workspace.
## Before
<img width="1294" height="287" alt="Screenshot 2026-04-22 at 19 37 40"
src="https://github.com/user-attachments/assets/11ca8635-14d7-40a0-9ca0-76329c54e3c6"
/>
## After
<img width="1283" height="285" alt="Screenshot 2026-04-22 at 19 32 25"
src="https://github.com/user-attachments/assets/5e49fa8a-4541-42ee-96da-4c1de7d00aae"
/>
## Intent
This is a small foundation cleanup for the tool architecture.
The main decision is: registry tools and native SDK/model tools are
different things.
- Registry tools have descriptors, schemas, catalog entries, and execute
through `ToolExecutorService`
- Native model tools are opaque AI SDK objects, bound directly into the
model `ToolSet`
- Surfaces still own their policy: chat, MCP, and workflow agents decide
what they expose
## What changed
- Removed `NATIVE_MODEL` from `ToolCategory`
- Kept `ToolRegistryService` focused on registry-backed tools only
- Moved native model tool binding through `NativeToolBinderService`
- Reused native binding from chat instead of duplicating
provider-specific web-search logic
- Kept MCP local execution exclusions in a dedicated constant
- Moved surface-specific constants into dedicated constant files
## What comes next
- Move hardcoded chat app preloads, like Exa web search, into
app/manifest metadata
- Decide a clearer policy for local runtime tools like code interpreter
and HTTP request
- Gradually document the three tool shapes: registry tools, native model
tools, and local runtime tools
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
## Summary
Fixes Sentry errors of the form:
> \`messages.3: \`tool_use\` ids were found without \`tool_result\`
blocks immediately after: srvtoolu_…. Each \`tool_use\` block must have
a corresponding \`tool_result\` block in the next message.\`
### Root cause
When the model invokes a **provider-hosted tool** (e.g. Anthropic's
native \`web_search\` — note the \`srvtoolu_\` ID prefix), the AI SDK
marks the resulting \`UIMessagePart\` with \`providerExecuted: true\`.
\`convertToModelMessages\` uses that flag to emit the
tool_use/tool_result pair *inside the same assistant message* — the
format Anthropic requires for server-side tools.
Our \`AgentMessagePart\` persistence was dropping \`providerExecuted\`
on the way to the DB (and re-hydration didn't know to set it). On the
next turn, \`convertToModelMessages\` treated the rehydrated part as a
client-side tool call, splitting it into \`assistant(tool_use)\` +
\`user(tool_result)\` — which Anthropic then rejects with the error
above.
### Fix
- Add nullable \`providerExecuted BOOLEAN\` column on
\`core.agentMessagePart\` via a fast instance command.
- Surface the field on \`AgentMessagePartDTO\` (GraphQL).
- Preserve it through \`mapUIMessagePartsToDBParts\` (server) and both
\`mapDBPartToUIMessagePart\` mappers (server + frontend).
- Include it in \`GET_CHAT_MESSAGES\` and \`GET_AGENT_TURNS\`
selections.
- Regenerate \`generated-metadata/graphql.ts\`.
### Backwards compatibility
Existing rows have \`NULL providerExecuted\` and round-trip as the
omitted flag — which is exactly the pre-fix behaviour for tool parts
that were never provider-executed. Only *new* assistant messages using
\`web_search\` (or other provider-hosted tools) will write \`true\`, and
those are the only ones that were breaking.
## Test plan
- [x] \`npx tsgo\` typecheck — server + front clean
- [x] \`oxlint\` + \`prettier --check\` on all touched files — clean
- [x] \`npx nx run twenty-server:database:migrate:prod\` runs the new
instance command locally; \`providerExecuted\` column present on
\`core.agentMessagePart\`
- [x] Regenerated \`generated-metadata/graphql.ts\` —
\`providerExecuted\` wired into both queries and \`AgentMessagePart\`
type
- [ ] Manual: start a chat with Anthropic web_search enabled, invoke the
tool in turn 1, reply in turn 2 — should not throw the srvtoolu error
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Context
Phase 1 of removing the legacy `gridPosition` field from
`PageLayoutWidget` in favor of the new `position` discriminated union
(`grid` / `vertical-list` / `canvas`). This PR is purely additive —
`gridPosition` is still required and read everywhere; we just guarantee
that every widget now also has a non-null `position` so a follow-up PR
can drop `gridPosition` cleanly.
## Changes
- **Slow instance command**
`BackfillPageLayoutWidgetPositionSlowInstanceCommand` (2.1.0):
for every `core.pageLayoutWidget` row where `position IS NULL`, copies
`gridPosition`
into `position` with `layoutMode: 'GRID'`. Historically only grid
widgets used
`gridPosition`, so a single SQL update covers every existing row.
- **`PageLayoutDuplicationService`**: when duplicating a widget, also
forwards
`originalWidget.position` (was previously only forwarding
`gridPosition`).
- **Frontend default layouts** (10 `Default*PageLayout.ts` files): added
a `position`
sibling to every widget, matching the parent tab's `layoutMode` —
`VERTICAL_LIST` widgets
get `{ layoutMode, index }`, `CANVAS` widgets get `{ layoutMode }`
<img width="338" height="226" alt="Screenshot 2026-04-24 at 16 23 17"
src="https://github.com/user-attachments/assets/c3319318-f1b8-4271-96b4-196b209a1f5e"
/>
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>
## Automated fix for [bug
30463](https://sonarly.com/issue/30463?type=bug)
**Severity:** `critical`
### Summary
When createMany is called with upsert:true and records lack pre-assigned
IDs, findExistingRecords() executes a SELECT with no WHERE clause,
scanning the entire table. This caused an 11-second transaction on the
opportunity table (2.9s query + 7.7s JS processing).
### Root Cause
1. WHY was the transaction 11 seconds? Because a SELECT on the
opportunity table took 2.9s and its result processing took 7.7s.
2. WHY did the SELECT take 2.9s? Because it was a full table scan with
NO WHERE clause, fetching every record (including soft-deleted).
3. WHY was there no WHERE clause? Because findExistingRecords() in
common-create-many-query-runner.service.ts calls buildWhereConditions()
which returned an empty array, so no .orWhere() was applied to the query
builder before .getMany() was called.
4. WHY did buildWhereConditions return empty? Because the input records
had no pre-assigned IDs and the opportunity object has no other unique
fields, so there were no conflicting field values to build conditions
from.
5. WHY wasn't the empty-conditions case guarded? The
findExistingRecords() method was written without an early-return check
for empty whereConditions — it always executes the query regardless.
**Introduced by:** etiennejouan on 2025-10-15 in commit
[`4ae2999`](https://github.com/twentyhq/twenty/commit/4ae299973bcea3470252c4c68540d33a4df59cc7)
> Common Api - createOne/Many (#15083)
### Suggested Fix
Added an early return in findExistingRecords() when
buildWhereConditions() returns an empty array. When no WHERE conditions
exist (because input records lack values for any unique/conflicting
field), the method now returns an empty array immediately instead of
executing a SELECT with no WHERE clause that scans the entire table.
This prevents the O(n) full table scan + O(n) JS result processing that
caused the 11-second transaction. The downstream categorizeRecords()
correctly handles empty existingRecords by classifying all input records
as recordsToInsert.
---
*Generated by [Sonarly](https://sonarly.com)*
Co-authored-by: Sonarly Claude Code <claude-code@sonarly.com>
## Summary
- Adds a **Billing** tab on the admin-panel workspace detail page that
surfaces Stripe customer + active subscription details (status, plan,
interval, current period, trial, cancellation, line items, credit
balance). Tab is gated on `IS_BILLING_ENABLED` both in the backend
service and in the frontend tab list — completely hidden on instances
where billing is disabled.
- Renders a **workspace avatar next to the name** in the admin Top
Workspaces list by plumbing the workspace `logo` field through the admin
DTO, statistics SQL query, and generated admin GraphQL types.
- **Read-only** by design: no Stripe API calls, no mutations — data
comes from the existing \`BillingCustomerEntity\` /
\`BillingSubscriptionEntity\` / \`BillingPriceEntity\` tables via
\`BillingSubscriptionService.getCurrentBillingSubscription\`.
### What the tab shows
- **Customer** container — Stripe customer ID (with link to the Stripe
dashboard, monospaced), credit balance (formatted, from
\`creditBalanceMicro\`).
- **Subscription** container — status tag (color-coded), plan tag,
billing interval, current period range, trial range (if trialing),
\`cancelAtPeriodEnd\` / \`cancelAt\` / \`canceledAt\` (only when set),
Stripe subscription ID (external link).
- **Line items** — one card per subscription item with product name,
product key tag, seats (if quantity), credits per period (for metered),
unit price (formatted with currency).
### Design choices
- Styling matches the user-facing billing page
(\`SubscriptionInfoContainer\` + \`Tag\` + \`H2Title\` + \`Section\`) —
no new UI primitives.
- Currency is rendered inline with amounts via \`Intl.NumberFormat\`
(e.g. \`\$19.00\`) instead of as a separate row.
- Uses the generated admin GraphQL types
(\`WorkspaceBillingAdminPanelQuery\`, \`SubscriptionStatus\`,
\`SubscriptionInterval\`) — no hand-typed response shapes.
## Test plan
- [x] \`npx nx typecheck twenty-server\` — passes
- [x] \`npx nx typecheck twenty-front\` — passes
- [x] oxlint + prettier on all touched files — clean
- [x] \`graphql:generate --configuration=admin\` — regenerated; new
\`workspaceBillingAdminPanel\` query + \`logo\` field on
\`AdminPanelTopWorkspace\` appear in \`generated-admin/graphql.ts\`
- [x] Backend GraphQL schema introspection shows
\`workspaceBillingAdminPanel\` query on \`/admin-panel\`
- [x] Direct GraphQL call with seeded \`BillingCustomer\` +
\`BillingSubscription\` + \`BillingPrice\` rows returns the expected
shape (\`status: "Trialing"\`, plan \`PRO\`, items with
quantity/unitAmount/includedCredits, trial period dates)
- [x] With \`IS_BILLING_ENABLED=false\` (default) the Billing tab is
hidden — verified in the admin panel UI
- [x] Top Workspaces list renders workspace avatars next to names —
verified in the admin panel UI
- [ ] Smoke test the Billing tab render in a real instance that has
\`IS_BILLING_ENABLED=true\` + live Stripe data (skipped locally due to
dev-env auth friction after toggling billing/multi-workspace; recommend
a reviewer check)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**PR 1 of 2.** Follow-up PR ships the Exa app, sets it as a default
pre-installed app, and removes the current `WebSearchTool` /
`WebSearchService` / `ExaDriver`. This PR adds the plumbing; no
user-visible change yet.
## Summary
- Server admins can declare a list of npm app packages to auto-install
on every new workspace and backfill onto existing workspaces via CLI.
- Server-level secrets (like Exa's API key) live on the
`ApplicationRegistration` (one row per server, encrypted) and are
injected into logic function execution env at runtime. No more
per-workspace storage of global secrets.
- A generic `POST /app/billing/charge` endpoint lets app logic functions
emit workspace usage events for metered features. Exa uses it in PR 2;
future apps (call recorder, etc.) reuse it.
- `LogicFunctionToolProvider` tool name prefix changes `logic_function_`
→ `app_`. Shorter, accurate (they come from installed apps).
## What's in this PR
**Logic function executor — server-level variables**
- `LogicFunctionExecutorService.getExecutionEnvVariables` now resolves
env vars in the order: hardcoded defaults →
`ApplicationRegistrationVariable[]` (server-level) →
`ApplicationVariable[]` (workspace-level override). The manifest
`serverVariables` schema has existed; this closes the loop.
**Config**
- `PRE_INSTALLED_APPS` — comma-separated list of npm packages. Default:
empty.
**\`PreInstalledAppsService\`** (new module)
- \`onApplicationBootstrap()\` — fetches each package's manifest from
the app registry CDN, upserts an \`ApplicationRegistration\`, and seeds
declared \`serverVariables\` from matching env vars (e.g.
\`EXA_API_KEY\` env → encrypted registration variable).
- \`installOnWorkspace(workspaceId)\` — installs all pre-installed apps
on a single workspace. Tolerates per-app failures.
**Auto-install on new workspace activation**
- \`WorkspaceService.prefillCreatedWorkspaceRecords\` invokes
\`installOnWorkspace\` after prefilling standard records. Non-blocking
on failure.
**Backfill CLI command**
- \`install-pre-installed-apps\` — iterates active and suspended
workspaces, installs pre-installed apps that aren't yet installed.
Idempotent. Run after changing \`PRE_INSTALLED_APPS\`.
**App billing endpoint**
- \`POST /app/billing/charge\`. Authenticated via \`APPLICATION_ACCESS\`
token (already injected into logic function execution env as
\`DEFAULT_APP_ACCESS_TOKEN\`). Body: \`{ creditsUsedMicro, quantity,
unit, operationType, resourceContext? }\`. Emits \`USAGE_RECORDED\` with
\`applicationId\` as \`resourceId\`. Generic — reusable by any app.
**Tool name prefix**
- \`LogicFunctionToolProvider.buildLogicFunctionToolName\` now produces
\`app_<name>\` instead of \`logic_function_<name>\`. Only affects tools
sourced from logic functions; other tool providers unchanged.
## Stats
- 16 files, +501 / −2
- 7 new files (1 command, 1 service × 2, 1 controller, 1 DTO, 2 modules)
- Typecheck: 7 pre-existing errors, zero new
- Prettier clean
## Behavior deltas
- **\`PRE_INSTALLED_APPS\` default = empty**: existing servers see no
change on merge.
- **\`ApplicationRegistrationVariable\` is now read by the executor**:
apps that were using manifest \`serverVariables\` but expecting them to
be ignored by the executor will now see them injected. No apps ship with
\`isTool: true\` logic functions today, so this is latent — first
consumer is Exa in PR 2.
- **Tool prefix**: currently no logic-function tools are named
\`logic_function_*\` in any production flow. The prefix change affects
only future tools emitted by \`LogicFunctionToolProvider\`.
## Risks
- **CDN unavailability at startup**: if the app registry CDN is down,
\`ensureRegistrationsExist\` logs warnings but doesn't block server
start. Installation on new workspaces during this window will find no
registrations and log a non-blocking error. Backfill command can retry
after CDN recovers.
- **Cold-start overhead**: \`ensureRegistrationsExist\` is called once
per process on bootstrap. Current configurable default is empty, so zero
overhead. When an admin sets \`PRE_INSTALLED_APPS\`, they accept one
HTTP call per package at boot.
- **Server-level variables flow**:
\`ApplicationRegistrationVariable.encryptedValue\` is shared by all
workspaces of a server. Appropriate for a single-tenant Exa key. Not
appropriate for per-tenant keys — those go in workspace-level
\`ApplicationVariable\` and override.
## Test plan
- [ ] \`npx nx typecheck twenty-server\` passes (verified: 7
pre-existing unrelated errors, zero new)
- [ ] Set \`PRE_INSTALLED_APPS=@twenty-apps/hello-world\` (or any real
npm-published app), \`HELLO_WORLD_API_KEY=xxx\`, restart server:
\`ApplicationRegistration\` row is upserted,
\`ApplicationRegistrationVariable\` for HELLO_WORLD_API_KEY is populated
(encrypted).
- [ ] Create a new workspace: the app is auto-installed,
\`ApplicationEntity\` row created, \`LogicFunctionEntity\` rows created.
- [ ] Existing workspace: run \`yarn nx run twenty-server:command
install-pre-installed-apps\`: apps install across all workspaces,
idempotent on re-run.
- [ ] Trigger a logic function that reads
\`process.env.HELLO_WORLD_API_KEY\`: value resolves from the
server-level \`ApplicationRegistrationVariable\`.
- [ ] Log a charge from the handler: \`POST /app/billing/charge\` with
\`Authorization: Bearer \$DEFAULT_APP_ACCESS_TOKEN\` body
\`{creditsUsedMicro: 1000, quantity: 1, unit: "INVOCATION",
operationType: "WEB_SEARCH"}\` → returns \`{success: true}\`,
\`USAGE_RECORDED\` event emitted with correct
\`resourceId=applicationId\`.
- [ ] Tool name generated by \`LogicFunctionToolProvider\` starts with
\`app_\`.
## What's NOT in this PR (PR 2 scope)
- The Exa app itself (\`packages/twenty-apps/...\` directory)
- Removing \`WebSearchTool\`, \`WebSearchService\`, \`ExaDriver\`,
\`web-search\` module
- Removing \`WEB_SEARCH_DRIVER\` config var
- Removing the current \`exa_web_search\` entry in
\`ActionToolProvider\`
- Chat preload list updated to \`app_exa_web_search\`
- Frontend \`getToolDisplayMessage\` branch for \`app_exa_web_search\`
- Setting \`PRE_INSTALLED_APPS\` default to include \`@twenty-apps/exa\`
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>