5d3b6d05b32e0cd540b8a6ee014bde454b683799
13372 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5d3b6d05b3 |
feat(ai): add a stream heartbeat and reap dead claims so a worker crash cannot brick a thread (#22482)
## Rationale If the worker process dies mid-stream (OOM, deploy, crash), nothing ever clears `activeStreamId`: `aiStreamQueue` runs with `attempts: 1`, the job's `finally` never executes, and the SSE keepalive comes from the API server — so it actively masks worker death. The thread is bricked: every send queues behind a dead claim until someone intervenes manually. This is a CONFIRMED-high from the chat-stack audit, and worker death is not hypothetical: Sentry shows an unhandled promise rejection inside the AI SDK in the worker ([TWENTY-SERVER-H7Y](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-H7Y)) — unhandled rejections terminate Node by default. ## Design - **Claim-time mark**: every enqueue site marks `agent-chat-stream-alive:<streamId>` with a TTL matching the job lock horizon (600s) — covering the enqueue→pickup window where a waiting job holds no lock. - **Running refresh**: the job tightens it to **30s, refreshed every 5s**; if the process dies, the interval dies with it and the key expires. The expiry *is* the death signal. (Was 60s/15s — tightened after review: detection latency is bounded by the TTL, robustness by TTL−interval and the missed-beat tolerance; 30s/5s halves detection while tolerating *more* missed beats, 5 vs 3.) - **Read-path reap**: the send gate and the catchup query convert a heartbeat-less claim into a normal retryable `STREAM_INTERRUPTED` failed-turn state (conditional UPDATE guarded on the observed streamId, so a newer stream's claim is never touched), reset the Redis chunk state, and publish the terminal error. `isAlive` fails open on Redis errors — a liveness probe must not turn a Redis blip into a broken send path. ## Why this is the root cause, not a symptom patch The strongest alternative — BullMQ's own stalled-job detection — fails on four concrete grounds: detection latency is bounded by the deliberate 10-minute `AI_STREAM_LOCK_DURATION_MS` (long silent tool runs must not spuriously stall); the stalled checker needs a *surviving* worker in the pool; the signal fires in the worker process while the thing needing repair is a DB claim read by API-server resolvers; and a `waiting` job holds no lock at all. Reaping at the read path means recovery happens exactly when a user is looking — the moment it matters — with zero background machinery. **Relationship to the graceful-shutdown work (planned follow-ups)**: shutdown hooks + drain-then-abort will make *deploys* (cooperative SIGTERM) end streams cleanly, and disabling stalled re-runs will stop hard-killed jobs from zombie re-executing tools. This PR remains the only recovery layer for non-cooperative deaths — OOMKill is a straight SIGKILL, crashes and unhandled rejections never run shutdown hooks — and the backstop when the drain path itself fails. The two are complements, not alternatives. ## User impact Today a worker crash mid-answer bricks the thread until manual intervention; users see sends silently queue forever. With this, the next interaction (send, reload) converts it into a visible "response was interrupted" error with a working Retry, within ~30s of actual death. ## Test plan - [x] Claim spec: live stream untouched; heartbeat-less claim reaped into retryable `STREAM_INTERRUPTED` + chunk-state reset + published terminal event; no-op when the claim moved to a newer stream mid-check - [x] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 |
||
|
|
203da78b05 |
feat(logic-function): namespace shared Lambda resources per instance (#22509)
## Problem When several Twenty instances run in the **same AWS account + region** (our customer's setup), their logic-function Lambda resources collide. Four shared resources are named purely from a content hash: | Resource | Name | Derived from | |---|---|---| | Builder fn | `twenty-builder-<sha256(handler)>` | handler code (⇒ version) | | Yarn-install fn | `twenty-yarn-install-<sha256(handler)>` | handler code | | Common layer | `twenty-common-layer-<sha256(pkg+lock)>` | dependencies | | Deps layer | `deps-<yarnLockChecksum>` | app yarn.lock | Because the hash is identical across instances of the same version, every instance computes the **same name**. `ensureBuilderLambdaExists` / `ensureYarnInstallLambdaExists` do `GetFunction → if it exists, return` with **no role check**, so the first instance to create the function binds it to *its* `LOGIC_FUNCTION_LAMBDA_ROLE` and every other instance silently reuses it. When that role is later deleted or belongs to a different account, invokes fail with: > The role defined for the function cannot be assumed by Lambda. Nothing tears these shared resources down, so a poisoned function persists indefinitely. (Executors are UUID-named and SDK layers are workspace-scoped, so they don't collide.) ## Fix Namespace the four shared resources by a per-instance segment. - New optional config var **`LOGIC_FUNCTION_LAMBDA_RESOURCE_NAMESPACE`** (LOGIC_FUNCTION_CONFIG group). - When unset it defaults to `sha256(LOGIC_FUNCTION_LAMBDA_ROLE).slice(0, 10)`. Keying the namespace on the execution role makes the sharing boundary correct: - **same role → same names →** instances still dedupe (original intent preserved), - **different role → different names →** full isolation, invoke can never hit a role it can't assume, - **role change →** resources are recreated fresh under a new name (self-healing). Names become `twenty-builder-<namespace>-<checksum>`, `deps-<namespace>-<checksum>`, etc. — the owning instance stays legible in the AWS console. ### Defensive role-heal As a safety net (and to heal already-poisoned functions), after `GetFunction` succeeds we compare `Configuration.Role` to the configured role; on mismatch we delete and recreate the tool function. (`waitFunctionDeleted` polls `GetFunction` until `ResourceNotFoundException` — this SDK version has no `waitUntilFunctionNotExists` waiter.) ## Scope / compatibility - Executor functions (`<logicFunctionId>`) and SDK layers (`sdk-<workspaceId>-<appUUID>`) are unchanged — already unique. - On upgrade, shared-resource names change once (role-hash namespace), so each instance recreates its builder/yarn-install/common-layer/deps on first use; old ones are orphaned (harmless, unreferenced). - Operators who want explicit control can set `LOGIC_FUNCTION_LAMBDA_RESOURCE_NAMESPACE`. ## Ops note (immediate unblock, independent of this PR) Delete the poisoned `twenty-builder-<hash>` (and sibling `twenty-yarn-install-*`) in the affected region; it is recreated with the correct role on next use. ## Tests - `compute-hashed-lambda-resource-name.util.spec.ts` — namespace segment behavior - `get-lambda-deps-layer-name.util.spec.ts` — namespaced deps layer name - `get-lambda-resource-namespace.util.spec.ts` — stable, role-distinct namespace All pass; `lint:diff-with-main` clean; typecheck clean for changed files. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22509?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
8b191d6fcc |
chore(server): remove the five dead FileFolder values and their legacy serving pipeline (#22516)
Follow-up cleanup after #22510: shrink `FileFolder` and `fileFolderConfigs` to only folders that actually exist, so per-folder policy entries are real decisions. ## What **Remove the five dead enum values** — `ProfilePicture`, `WorkspaceLogo`, `Attachment`, `PersonPicture`, `File`. They were already marked replaced/removed in the enum, have no production write path, and `FileByIdGuard`'s `SUPPORTED_FILE_FOLDERS` allowlist already rejects them at the serving endpoint. **Delete the legacy path-based serving pipeline that existed only for them** — verified wired to no route: - `FilePathGuard` — registered as a provider in `FileModule` but applied to no controller - `extractFileInfoFromRequest` (parsed the old `/files/profile-picture/original/TOKEN/file.jpg` format) — only consumer was `FilePathGuard` - `checkFileFolder` — only consumer was `extractFileInfoFromRequest` - `settings.storage.imageCropSizes` — keyed exclusively by the three dead picture folders, zero consumers - the crop-size helpers in `utils/image.ts` (`getCropSize`, `ShortCropSize`, `CropSize`) — zero consumers outside the file; `getImageBufferFromUrl` is kept - `AllowedFolders` type — last consumer was `checkFileFolder` **Test fixtures** referencing dead folders were moved to living ones; the specs of deleted utils are deleted with them. **Generated files** (`twenty-front/src/generated-metadata/graphql.ts`, `twenty-client-sdk` schema) hand-updated to match the shrunk GraphQL enum. ## Legacy data safety Workspaces may still hold `File` rows whose `path` starts with a dead prefix (e.g. `attachment/…`). These stay inert, exactly as today: - Serving: `FileByIdGuard` rejects non-supported folders before any config lookup, and file lookups filter by `path LIKE '<current-folder>/%'`, so dead-prefix rows are unreachable. - Every consumer that feeds stored paths into `removeFileFolderFromFileEntityPath` (which throws on unknown prefixes) is upstream-guarded by a current-folder filter or allowlist — audited all seven call sites. - Stored legacy member `avatarUrl` strings are parsed with `extractFileIdFromUrl(url, FileFolder.CorePicture)` and already fall back to `''` for old formats; unchanged. ## GraphQL note `FileFolder` is exposed as a GraphQL enum (input of the dev-only `uploadApplicationFile` mutation, which only accepts application-code folders). Clients sending a removed value were already rejected at the resolver allowlist; they now fail GraphQL enum validation instead. No supported client sends them — the frontend only uses `CorePicture`. Net: **+10 / −301** across 17 files. https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W --- _Generated by [Claude Code](https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22516?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
8156bf2b89 |
perf(twenty-server): drive immutable file caching from fileFolderConfigs on both serving paths (#22510)
Follow-up to #22166 (now merged). Single commit, rebased onto main. ## Why #22166 introduced a `Cache-Control` header for avatar responses, gated on a hardcoded `CACHEABLE_PICTURE_FILE_FOLDERS = [CorePicture]` list. Two limitations: - The list is an ad-hoc second classification of `FileFolder`, maintained separately from the central `fileFolderConfigs`. - The header is only set on the stream branch of `getFileById`. On S3 deployments with presigned URLs enabled, the controller 302-redirects before `setFileResponseHeaders` runs and the presigned S3 response carries no `Cache-Control` at all — so the header never fires where it matters most. Whether a folder's bytes are cacheable-forever is a property of how the folder is written, and the codebase already has a per-folder source of truth: `fileFolderConfigs`. ## What - Add `immutable: boolean` to `FileFolderConfig`. `true` for folders whose write paths mint a fresh `v4()` file id embedded in the resource path on every upload — so the bytes behind a given URL can never change: `CorePicture`, `FilesField`, `Workflow`, `AgentChat`, `EmailAttachment`, `Dpa`. `false` everywhere else, notably: - `PublicAsset` — path-addressed, overwritten in place on app (re)install (including the new manifest logo import) - `AppTarball` — reuses `tarballFileId` and a stable `${registrationId}/app.tar.gz` path across version bumps - `setFileResponseHeaders` reads the flag instead of the ad-hoc list (list deleted). - Thread `responseCacheControl` through `FileStorageService.getPresignedUrl` → `StorageDriver` → `S3Driver`, which passes it as `ResponseCacheControl` on the `GetObjectCommand`, so presigned S3 responses return the same `Cache-Control: private, max-age=86400, immutable` on the redirect path. `private` is kept because responses are gated by a per-workspace file token; `immutable` is safe because a changed file always gets a new id and URL. ## Tests - `setFileResponseHeaders` spec: header set for each immutable folder, not set for mutable folders (`PublicAsset`, `AppTarball`, deprecated picture folders) or when no folder is provided. - `S3Driver.getPresignedUrl` spec: asserts `ResponseCacheControl` is forwarded onto the `GetObjectCommand`. https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W |
||
|
|
9a7c0f25a5 |
fix(server): resolve foreign key violation blocking application uninstall (#22502)
Fixes [sonarly issue #54192](https://sonarly.com/issue/54192) ## Problem Uninstalling an application fails with a DB error when its `packageJsonFileId` / `yarnLockFileId` columns are populated: update or delete on table "file" violates foreign key constraint "FK_3818380258798f9ffa9963b6dc4" on table "application" Storage was also wiped before the failing DB delete, leaving the app half-uninstalled. ## Root cause `application` and `file` reference each other through `ON DELETE RESTRICT` FKs (`application.packageJsonFileId/yarnLockFileId → file.id` and `file.applicationId → application.id`), so no deletion order works on its own. The deferrable-FK migration doesn't help: in Postgres, `RESTRICT` fires immediately even on `DEFERRABLE INITIALLY DEFERRED` constraints (only `NO ACTION` honors deferral). Uninstall deleted file rows first, in autocommit statements. ## Fix `ApplicationService.delete()` now runs in a single transaction: 1. Clear `packageJsonFileId` / `yarnLockFileId` (breaks the FK cycle) 2. Delete the app's `file` rows 3. Delete the `application` row Storage cleanup moved after commit and made non-fatal, so a failure can no longer leave partial state. `deleteApplicationFiles` is split into `deleteApplicationFileRows` (DB, transactional) and `deleteApplicationFilesFromStorage` (blobs). The test cleanup util had the same file-first ordering bug and is fixed the same way. ## Questions / Follow-ups - **Should the FK cycle be resolved at the schema level?** Both legs could be switched to `ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED`, which appears to be what the deferrable-FK migration intended — deferral would then actually apply to deletes, making transactional deletion order-independent. Happy to open a separate PR if there's interest. - **Should the marketplace install path set the package file FKs?** It stores `package.json` in the `file` table but never populates `application.packageJsonFileId` / `yarnLockFileId` — today only workspace creation and `application:rebuild-default-deps` set them. Marketplace packages also don't ship a `yarn.lock`, so this needs a product decision. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22502?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
af1b89c788 |
feat(server): import application logo into file storage at install (#22437)
Installed apps stored the logo as the manifest's relative path but never imported the file, so the public-assets URL 404'd and logos went missing in the UI for npm/tarball sources. Import the logo (best-effort — a declared but unshipped logo is skipped, not fatal) and record it as a first-class logoFileId on the application so it can be served reliably. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22437?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
20f2e33702 |
feat(server): first-class logo on application registration + narrowed settings queries (#22453)
Part of the application settings architecture work: https://github.com/twentyhq/core-team-issues/issues/2456 Application-registration list queries loaded the entire `manifest` jsonb (potentially 100KB+/row) on every settings/marketplace list request because display data (logo, description, author, category) only exists inside it. This PR: - Adds a first-class nullable `logo` column on `applicationRegistration`, populated at every ingestion point (`updateFromManifest`, `upsertFromCatalog`) and backfilled from `manifest->application->>logoUrl` via a slow instance command (self-sufficient backfill since `runDataMigration` runs before `up`). - Backs the `logoUrl` GraphQL getter with the column (manifest fallback for un-backfilled rows) — **GraphQL surface unchanged**. - Narrows `findMany` / `findAll` / `findOneById` / `findOneByIdGlobal` to an explicit scalar select that excludes `manifest` and `oAuthClientSecretHash` (every caller audited — none needs them; OAuth verification paths are untouched). - Replaces `findManyListed()` with `findManyListedCatalogCards()`: a projection query that extracts the four display strings from the manifest in SQL (with explicit soft-delete filtering) instead of hydrating full entities, feeding `findManyMarketplaceApps`. Verified: typecheck, lint:diff-with-main, unit suites (application-registration 5/5, marketplace 10/10, instance-command 31/31), migration applied via the real runner, and the migration generator reports no pending schema changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei --- _Generated by [Claude Code](https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22453?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
f2e3eb5fb7 |
perf(twenty-server): browser-cache picture file responses (avatars/logos) (#22166)
Fixes #22163. ## Why Picture file responses (`GET /file/:fileFolder/:id`, `FileController.getFileById`) set no `Cache-Control` header, so the browser re-fetches the same avatar/picture on every render. When one member's avatar appears many times on a page (e.g. a record table or Kanban where that member owns many rows), this fires dozens of parallel GETs for the identical image; the browser cancels the redundant in-flight ones, and the server logs each client-aborted stream as `Error streaming file from storage`. Picture files are content-addressed by an immutable file id — changing an avatar or logo mints a new file id (and therefore a new URL) — so the bytes at any given URL never change and can be cached aggressively. ## What - `setFileResponseHeaders` now adds `Cache-Control: private, max-age=86400, immutable` for the picture folders (`CorePicture`, `ProfilePicture`, `WorkspaceLogo`, `PersonPicture`); `getFileById` passes the `fileFolder` through. - Scoped to picture folders so non-image files (attachments, tarballs, source, …) are not cached past a permission/visibility change. - `private` because files are served behind a per-workspace file token; `immutable` + the content-addressed id gives automatic cache-busting when the picture changes. ## Tests - Unit tests for `setFileResponseHeaders`: header is set for each picture folder, and not set for non-picture folders or when no folder is provided. - Controller test asserts the header on a `CorePicture` stream response. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22166?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
b7fc0872c8 |
i18n - docs translations (#22511)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22511?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
1db9b0c657 |
Fix webhook entity dropdown layout (#22497)
## Summary - Reuse the shared `SelectControl` for the webhook entity selector. - Update webhook entity menu icons and object ordering. - Fix dropdown section labels and separators so headers span full width while menu items keep the expected inset and 4px header spacing. - Keep the webhook filter row responsive and the remove-filter button at icon-button width. ## Before/After <img width="3454" height="2000" alt="image" src="https://github.com/user-attachments/assets/bfe0cc39-ad66-4cae-98be-1eddc66f12f3" />  ## Tests - `git diff --check` - `npx nx typecheck twenty-front` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22497?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
25fe66565c |
feat(applications): add type and options to application variables (#22157)
## Before <img width="1452" height="709" alt="image" src="https://github.com/user-attachments/assets/cd384ffa-cbe6-49d5-a807-ca8d580f55a9" /> <img width="1074" height="452" alt="image" src="https://github.com/user-attachments/assets/720d38db-3495-4032-8831-17d24ec6a7e7" /> ## After <img width="1421" height="865" alt="image" src="https://github.com/user-attachments/assets/2275c996-c895-4800-8324-2aa2ddfddd43" /> <img width="1348" height="870" alt="image" src="https://github.com/user-attachments/assets/3e1a891d-6db0-4cbd-870a-2a5bbde4929d" /> ## Summary Adds typed application variables with optional select **options**. This is the other half of #22059, split out from the custom-settings-tab removal. ## Changes - **Shared types**: `ApplicationVariable` / `ServerVariables` gain an optional `type` (a `FieldMetadataType` subset — `TEXT`, `BOOLEAN`, `NUMBER`, `DATE`, `SELECT`, `MULTI_SELECT`, `RAW_JSON`, `RICH_TEXT`, `ARRAY`, …) and select `options`. New `serializeApplicationVariableValue` / `deserializeApplicationVariableValue` helpers convert typed values to/from the encrypted string storage. - **Server**: `type`/`options` columns on `applicationVariable` and `applicationRegistrationVariable` (entities + DTOs), a fast `2-17` instance command, manifest processing via the serialization helpers, and a `QueryDeepPartialEntity` cast where the manifest JSON column is persisted. - **Frontend**: a polymorphic `SettingsApplicationVariableInput` that renders the native `Form*` field component for each type (boolean, number, date/date-time, select, multi-select, array, raw JSON, rich text, text); fragment/query updates to fetch `type`/`options`. - **SDK**: `defineApplication` validates that `SELECT`/`MULTI_SELECT` variables declare non-empty `options` at build time (since `options` is kept structurally optional for TypeORM/SDK compatibility). Variables default to `TEXT` when no type is given, so existing manifests are unaffected. ## Notes The generated GraphQL artifacts (`type`/`options` on the variable types) are regenerated by codegen; that change accompanies this PR. https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23 --- _Generated by [Claude Code](https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22157?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
90f35658c5 |
fix(ai) - sync AI agent step output schema when agent response format changes (#22466)
## Summary
When an AI agent workflow step is built via the AI chat tools, its
persisted
`settings.outputSchema` was left empty (or stale as a text `{ response
}` schema)
even after the agent was given a structured JSON `responseFormat`. The
workflow
still executed correctly (runtime uses actual step results), but the
builder UI
resolves downstream variables (`{{stepId.fieldName}}`) exclusively from
the
persisted `outputSchema`, so those variables showed as **"Not Found"**.
Root cause: the `update_agent` tool only mutated the agent entity and
never
re-derived the linked step's `outputSchema`, and `enrichOutputSchema`
did not
handle `AI_AGENT` steps at all.
## What changed
- **Enrich AI_AGENT output schema on the backend**: added `AI_AGENT` to
`BACKEND_ENRICHED_TYPES` in
`WorkflowSchemaWorkspaceService.enrichOutputSchema`,
so a step's `outputSchema` is computed from the agent's `responseFormat`
on
every create/update (text → `{ response }`, JSON → one field per
property).
- **Re-sync the step when the agent's response format changes**: after
`update_agent` sets a `responseFormat`, the tool now finds the draft
workflow
version(s) whose `AI_AGENT` step references that agent and re-runs the
step
update so the persisted `outputSchema` is regenerated.
- **Fix stale-cache read**: `updateOneAgent` reads `flatAgentMaps`
before its
migration, which can leave a memoized/local stale copy for a few
seconds. The
resync now invalidates `flatAgentMaps` before re-enriching, so the fresh
`responseFormat` is used.
- **Surface failures**: resync errors are logged (`UpdateAgentTool`)
instead of
failing silently; the agent update itself still succeeds.
- Added unit tests for the `update_agent` resync behavior (fires on
`responseFormat` change, invalidates the cache, skips unrelated agents,
and
reports success when the resync fails).
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22466?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
caa282f5da |
fix(front): gate SSO and audit logs on enterprise validity token (#22459)
## Problem The Security settings UI gates the **SSO** card and **audit logs** on `currentWorkspace.hasValidSignedEnterpriseKey`, but the backend enforces enterprise features through `EnterprisePlanService.isValid()`, which returns `hasValidEnterpriseValidityToken()`. These check different things: - **`hasValidSignedEnterpriseKey`** — the `ENTERPRISE_KEY` config var is present and is a correctly-signed JWT (signature check only, no expiry). It's the credential the operator installs. - **`hasValidEnterpriseValidityToken`** — a validity token (fetched from the licensing API, stored as an `AppToken`, refreshed by cron) that is present and not expired. This is the runtime "is enterprise active right now" check, and it's the one every backend gate uses (`EnterpriseFeaturesEnabledGuard`, SSO sign-in, event-log retention, billing, row-level permissions, signing-key rotation). As a result, a workspace with a valid unexpired validity token but no locally-signed key (e.g. the `ENTERPRISE_KEY` env var isn't set on a given replica, or an "orphaned validity token" state) shows SSO **disabled** in the UI while the server would actually authorize SSO operations. ## Change Gate the SSO card and audit-logs section on `hasValidEnterpriseValidityToken` so the UI matches backend enforcement. The Enterprise management page (`SettingsEnterprise.tsx`) deliberately keeps the key/token distinction — it needs the signed key for subscription status, the customer portal, and the orphaned-validity-token warning — so it is left unchanged. ## Files - `SettingsSSOIdentitiesProvidersListCard.tsx` — skip/disable now driven by the validity token - `SettingsSecuritySettings.tsx` — `hasEnterpriseAccess` now driven by the validity token ## Notes - The SSO `skip` condition changed from `=== false` to `!== true`, so an undefined workspace (still loading) now skips the query rather than firing it — consistent with the `disabled` checks below it. ## Test - `nx lint:diff-with-main twenty-front` passes. - Not manually verifiable in local dev without an enterprise license setup (requires a workspace in the token-valid / key-absent state). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22459?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
9ef1af9799 |
fix(ai): make stream claims atomic via conditional UPDATEs with claim-or-queue send (#22481)
## Rationale
`activeStreamId` is the mutex that guarantees one live stream per thread
— but claiming it is a plain read-then-update. The resolver checks it,
then `streamAgentChat` enqueues the job **before** writing the claim.
Two racing sends both pass the check, both start jobs, and each job's
`resetStreamState` wipes the other's Redis chunk list — tokens from two
answers interleave into the visible message. The same window exists for
retry vs. send, the queue drain vs. send, and `stopAgentChatStream`,
which cleared the claim **unguarded** (`{ id, userWorkspaceId }`) and
could wipe a newer stream's claim entirely.
## Why this is the root cause, not a symptom patch
Ownership must live in the `activeStreamId` column regardless of any
locking mechanism — the queue-behind gate, the thread DTO, and stop all
read it. So the correct primitive is a single-row compare-and-set on
that column: `UPDATE … WHERE "activeStreamId" IS NULL` checked via
affected rows, claim **before** enqueue, release on enqueue failure.
Every mutation of the claim is now guarded on the observed value.
Alternatives evaluated and rejected:
- **BullMQ jobId dedup by threadId**: the driver appends a `-${v4()}`
suffix to custom ids and dedups via a non-atomic `getJobs(['waiting'])`
scan that ignores active jobs — two racing sends still run concurrently,
and it does nothing for stop/retry races.
- **`SELECT FOR UPDATE` / Redis SETNX / advisory locks**: all add a
second mechanism (transaction plumbing or a second source of truth) to
protect a single-row write that Postgres can already do atomically.
Path-specific claim predicates fall out naturally: send/drain claim with
`pendingQuestionMessageId IS NULL`, retry claims with `lastStreamError
IS NOT NULL` (and restores the error if its enqueue fails) — closing the
double-retry race for free.
## User impact
Double-send (impatient double-click, two tabs, retry racing a queued
drain) can currently garble the assistant's answer with interleaved
tokens from two model runs and strand one stream's claim. All of these
become deterministic: exactly one winner streams; the loser queues
politely.
## Test plan
- [x] New claim spec: conditional claim before enqueue, race-loser
queues, halted-backlog send queues at the back and kicks the drain
front-first, enqueue-failure releases the claim
- [x] Retry spec updated: rollback restores the prior `lastStreamError`;
guarded shapes asserted
- [ ] CI green
https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22481?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
416f4cf90e |
Add billing plans comparison page (#22424)
## What changed - Added a Billing > Plans tab with a Pro vs Organization comparison table. - Updated subscription card CTAs so Compare plans routes to the new Plans tab, while upgrade/downgrade actions stay inside the comparison page. - Added a reusable segmented control and used it for the billing period toggle and navigation drawer tabs. - Hid billing pages/navigation when billing is disabled, including self-hosted environments. <img width="1417" height="882" alt="file-f98283057b5a700f275cde2a38831ac3" src="https://github.com/user-attachments/assets/182a7ff4-51fa-492e-8c75-51f9dc35b59e" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22424?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> |
||
|
|
3aeb2b0d5d |
Self-host Inter and DM Mono fonts (#22506)
Replaces the two Google Fonts stylesheets with self-hosted fonts via `@fontsource`, imported in the app entry (and Storybook preview), and removes the duplicate Inter that BlockNote was shipping. ## Why - **The caching argument for Google Fonts is dead.** Browsers partition the HTTP cache by top-level site (Chrome 86+, Firefox 85+, Safari even earlier), so a font cached from another website is never reused on ours. Every first-time visitor downloads the fonts either way — Google just adds a detour. - **Faster first paint.** This removes two render-blocking cross-origin stylesheets from `index.html` (DNS + TLS to `fonts.googleapis.com`, then a second connection to `fonts.gstatic.com`, with no preconnect today). The fonts now ship from our own `/assets` alongside the rest of the app, behind the same CDN and cache policy. - **Privacy.** Visitor IPs are no longer sent to Google on every page load. A German court ruled in 2022 that Google Fonts embedding violates GDPR, and privacy-conscious self-hosters currently have no way to opt out of the dependency. - **Air-gapped / offline self-hosted instances** currently render fallback system fonts; they now get the real ones. ## Font unification We were actually loading Inter from two places: the Google stylesheet, plus `@blocknote/core/fonts/inter.css` (8 weights, latin-only, woff+woff2) imported by the two rich-text editors — whichever loaded last won the cascade. Both are gone; `@fontsource` is now the single source: - Inter 400/500/600 (theme weights) + 700 (rich-text bold, previously only covered by the BlockNote copy) - DM Mono 400/500 (DM Mono has no 600 upstream; the old Google link requested one anyway) Fontsource ships the same `unicode-range` subsets as the Google CSS, so browsers still only download the subset they need (~60KB of woff2 for latin), and non-latin locales keep full coverage — which the latin-only BlockNote copy didn't provide. ## Notes - The BlockNote PDF export (`exportBlockNoteEditorToPdf.ts`) still fetches Inter TTFs from `fonts.gstatic.com` at export time — react-pdf needs TTF files, left unchanged here. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22506?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
13be2188cc |
Fix non-idempotent application sync for viewSorts (subFieldName undefined vs null) (#22505)
## Summary Successive application syncs (`yarn twenty dev --once`) kept reporting the same viewSorts as updated, even with no manifest changes. The manifest converter never set `subFieldName`, so the manifest-derived flat viewSort carried `undefined` where the flat viewSort computed from the database carried `null`. The comparator (microdiff) treats `null` vs `undefined` as a change, producing a phantom update action on every sync that never converges — the resulting update is a no-op on the database. Fixes twentyhq/core-team-issues#2629 ## Changes - **Converter**: `fromViewSortManifestToUniversalFlatViewSort` now sets `subFieldName: viewSortManifest.subFieldName ?? null`, matching how the sibling converters (e.g. view filters) handle optional compared properties. - **Type definition**: added optional `subFieldName?: string` to `ViewSortManifest` in `twenty-shared`, mirroring `ViewFilterManifest` — this also makes sorts on composite sub-fields (e.g. `amountMicros`) expressible in app manifests, which the entity already supports. - **Tests**: - Asserts `subFieldName` is `null` (not `undefined`) when omitted — the idempotency regression. - Asserts `subFieldName` is passed through when provided. ## Verification - All 12 application-manifest converter suites pass (47 tests). - Flat-entity comparison/constants suites pass (36 tests, 21 snapshots). - `subFieldName` was already part of the viewSort compare properties, so no comparator/constants changes needed. https://claude.ai/code/session_018FrD42MMQtu1UvDyiEZbSq <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22505?utm_source=github" rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a> |
||
|
|
d8ea406b80 |
fix(front): skip unknown fields in SSE optimistic updates instead of dropping the event (#22474)
## Rationale When an SSE record-update event carries a field the tab's metadata cache doesn't know (someone added a custom field after this tab loaded), `computeOptimisticRecordFromInput` throws `Should never occur, encountered unknown fields …`. The catch in `useTriggerEventStreamCreation` swallows the throw, so the **entire event is discarded** — the tab silently stops reflecting that update. **Production evidence (Sentry):** the `Error while processing SSE message` family — ~860 events / ~480 users in the last 30 days, ongoing ([TWENTY-FRONT-7PD](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-7PD) et al.), with the sampled stack landing exactly on this throw. Related: [TWENTY-FRONT-633](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-633) (903 users). ## Why this is the root cause, not a symptom patch The throw is an assertion that unknown fields "should never occur". That's the correct contract for the 4 local-mutation callers (`useUpdateOneRecord`, `useCreateOneRecord`, `useCreateManyRecords`, `useRunWorkflowVersion`) — there, an unknown field is a programming bug. But for the SSE caller the input comes from the **server**, which can legitimately be ahead of the tab's metadata. Schema convergence is the metadata-event pipeline's job (it flows over the same SSE channel); the record pipeline's job is to tolerate the window. So the fix moves the decision to the right caller instead of weakening the assertion for everyone: - `getUnknownRecordInputFields` — detection logic extracted, shared - mutation callers: still throw (behavior unchanged) - SSE update path: filters unknown fields and applies the rest of the event Dropping the *fields* loses nothing: the tab couldn't render them anyway without the metadata, and the metadata event that follows triggers the proper refresh. ## User impact ~480 users/month currently get silently stale tabs (list/kanban rows not reflecting teammates' updates) whenever any custom field is added while they have Twenty open. After this fix, updates keep flowing; only the not-yet-known field is skipped until metadata converges. ## Test plan - [x] Unit tests for `getUnknownRecordInputFields` (known fields, `__typename`, unknown fields, relation join columns) - [x] Existing `computeOptimisticRecordFromInput` tests cover the unchanged throw path - [ ] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22474?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
429e8c4b84 |
fix: align email validation between front and server and roll back optimistic value on failed save (#22490)
## Summary
Inline edits of EMAILS fields could leave the UI in a misleading state:
the frontend validated with Zod's default `z.email()` while the server
used the stricter `z.regexes.unicodeEmail` pattern (which caps the local
part at 64 characters). A very long email passed client validation and
was optimistically written to the UI; the server then rejected the
mutation. An error snackbar was shown, but the field kept displaying the
unsaved value until a page reload.
## Changes
- **Single source of truth for email validation**: added a shared
`emailSchema` (`z.email({ pattern: z.regexes.unicodeEmail })`) in
`twenty-shared/utils`, now used by:
- the server-side EMAILS field validator
(`validate-emails-primary-email-subfield-or-throw.util.ts`)
- the `EmailsFieldInput` inline editor
- spreadsheet import validation
- **Rollback on failed save**: `useUpdateOneRecord` now restores the
optimistically updated fields in the record store when the mutation
fails, mirroring the store upsert already done in the success path.
Previously the catch block only rolled back the Apollo cache — which
stopped reverting the UI after table virtualization, since the record
store (the render source of truth) is no longer synced reactively from
the cache. The error is still rethrown, so the existing global
promise-rejection handler keeps showing the error snackbar. This fixes
the stale-value-until-reload behavior for all field types and all
callers, not just EMAILS fields.
- **Regression tests**: added unit tests for the shared schema,
including the >64-character local part case.
Fixes [sonarly issue
#54034](https://sonarly.com/issue/54034?share=eyJ0aWQiOjMzMCwidHlwIjoiYnVnIiwicmlkIjo1NDAzNCwiZXhwIjoxNzgzNTI1OTQzfQ.9e7639034a677301512fceeafab764b1)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22490?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
86e4685781 |
fix: format numbers according to user preferences in settings views (#22501)
## Context Record and field counts in several settings views were rendered as raw numbers (e.g. `158355`), ignoring the workspace member's number format preference. This PR routes them through the existing `useNumberFormat` hook (which wraps `formatNumber` with the user's `numberFormat` preference), so they render as `158,355` / `158 355` / `158.355` / `158'355` depending on the preference. ## Changes Starting point (from the screenshot): the Settings → Data model objects table. - **`SettingsObjectItemTableRow`** — Fields and Records columns in Settings → Data model - **`SettingsAvailableStandardObjectItemTableRow`** — Fields column in Settings → Data model → New object - **`SettingsDataModelOverviewObject`** — record count next to the object name in the data model graph overview (guards against `undefined` while the count query loads) - **`SettingsLogs`** — "X of Y" record counts above the event logs table - **`SettingsAdminGeneral`** — Users column in the admin panel top workspaces table - **`SettingsAdminWorkspaceContent`** — Members value in the admin panel workspace info card - **`NoteList`** — total notes count in the record page Notes tab ## Test coverage - `npx nx lint:diff-with-main twenty-front` (oxlint + oxfmt) passes - `npx nx typecheck twenty-front` passes - No behavioral change beyond formatting; `formatNumber` defaults to 0 decimals so integer counts stay integers https://claude.ai/code/session_019pXXZSaza8TXPYK8NefQiS --- _Generated by [Claude Code](https://claude.ai/code/session_019pXXZSaza8TXPYK8NefQiS)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22501?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
024a9b4d94 |
fix(upgrade): re-slot all 2-19 upgrade commands to their real merge epochs and guard timestamps in CI (#22498)
# Fix broken 2-19 upgrade command sequence (dev incident: `lastStreamError` / `workspaceDiscoverability`) ## Incident The dev environment (tracking main) throws: - `Property "lastStreamError" was not found in "AgentChatThreadEntity"` - `Cannot return null for non-nullable field Workspace.workspaceDiscoverability` ## Root cause The 2-19 upgrade commands were committed with **fabricated future timestamps** (year-2027 epochs like `1820000000000`). The upgrade cursor (`upgrade-aware-entity-metadata.adapter.ts`) tracks a **single** most-recent applied step: it looks up the latest `core."upgradeMigration"` row's name in the sequence (sorted by timestamp within kind) and hides every `@WasIntroducedInUpgrade` column at or past that index. Two ways this breaks, and both were live on main: 1. **Cursor regression**: a command merged *later* with a *smaller* timestamp (e.g. `pendingQuestion` at `1811…` after `metadata-overrides` at `1820…` had run) sorts *before* already-applied steps. When migrate runs, the "latest" row now points earlier in the sequence, re-hiding columns that were already applied. 2. **Migrate not running at all** (Felix's hypothesis): if the deploy pipeline skipped `database:migrate:prod`, none of the 2-19 rows exist and every 2-19-gated column is hidden. Both hypotheses have the same fix path; the discriminating query is in the verification section below. ## Fix **Real timestamps** (per maintainer direction — no more fabricated epochs): | Command | Old (fabricated) | New (real merge epoch) | Introduced by | |---|---|---|---| | workspace: backfill-workspace-custom-application-registration | `1820000000000` | `1782853718000` | #22378 | | fast: add-metadata-overrides-column | `1820000100000` | `1782986475000` | #22417 | | slow: backfill-metadata-overrides | `1820000110000` | `1782986476000` (+1s to order after its fast pair) | #22417 | | fast: add-last-stream-error-to-agent-chat-thread | `1821000000000` | `1782996657000` | #22434 | | fast: add-pending-question-to-agent-chat-thread | `1811000000000` | `1782999138000` | #22346 | | fast: add-workspace-discoverability-to-workspace | `1820000001000` | `1783004140000` | #22423 | Each value is the committer epoch of the squash-merge commit that introduced the command on main (verified via `git log --diff-filter=A`). Sorted by real time, the fast sequence is strictly increasing, so the cursor can no longer regress. **Idempotency**: renaming a command changes its step name, so every one of these re-runs on any instance that already applied it under the old name (dev cluster, edge self-hosters — 2.19 is unreleased, so tagged releases are unaffected). All six are now safe to re-run: - `lastStreamError`, `pendingQuestion`, `metadata-overrides` fast: `ADD COLUMN IF NOT EXISTS` (already were) - `metadata-overrides` slow backfill: `WHERE … IS NULL` guard (already was) - workspace command: skips when `applicationRegistrationId` is already set (already did) - `workspaceDiscoverability`: **made idempotent in this PR** — `CREATE TYPE` wrapped in a `duplicate_object` handler, `ADD COLUMN IF NOT EXISTS` **CI guard** (replaces the append-only check added earlier on this branch): - Timestamps must be **real**: within `[now − 60 days, now + 2 days]`. This is the check that would have prevented the original sin — 2027 epochs can never pass. - Still **append-only** within the version directory, but computed from `git diff --name-status --find-renames` so renamed/copied files are checked too (cubic's P2), and files the PR deletes/renames away no longer count toward the existing max (otherwise a re-slotting PR like this one could never pass its own guard). - Covers `workspace-command-<ts>-` filenames, not just `instance-command-fast|slow-<ts>-`; skips `.spec.ts` files. - Failure message documents the escape path (re-slot the fabricated blocker to its real epoch + make it idempotent) and a bypass label `ci:allow-upgrade-command-timestamp-exception` for deliberate exceptions. ## Deploy sequencing (important) After this merges and deploys, `database:migrate:prod` **must run** before the API pods are relied on: the old step names no longer exist in the sequence, so until the renamed commands run once, the cursor resolves to 0 and *every* gated column is hidden. The commands are idempotent, so the re-run is harmless. Running API/worker pods only compute the cursor at boot — restart them after migrate. ## Verification / diagnosis on dev ```sql SELECT name, status, "createdAt" FROM core."upgradeMigration" WHERE "workspaceId" IS NULL ORDER BY "createdAt" DESC LIMIT 15; ``` - Latest rows named `…_182xxxxxxxxxx` (fabricated) and completed → migrate ran, cursor regressed (hypothesis 1). - No 2-19 rows at all → migrate never ran for 2-19 (hypothesis 2). - After the fix: latest row should be `2.19.0_AddWorkspaceDiscoverabilityToWorkspaceFastInstanceCommand_1783004140000`, status `completed`. https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 |
||
|
|
5057f14df9 |
test(ai): pin the queue-updated event that now follows stream-error on the failure path (#22500)
## Rationale main's `server-test` is red: 4 error-path tests in `stream-agent-chat.job.spec.ts` fail. #22494 made the stream failure path publish `queue-updated` **after** the terminal `stream-error` (so background tabs refetch the persisted partial transcript), but these specs still asserted `stream-error` is the *last* published event. Classic squash-merge semantic conflict — #22494's branch predated the spec assertions, both were green in isolation. ## Why this is the right fix (not patching a symptom) The job behavior is the intended one from #22494; the specs encode the old contract. Rather than loosening the assertions to "a stream-error was published somewhere", this pins the full intended terminal sequence — `stream-error` followed by `queue-updated` — so the convergence event itself is now regression-tested on all four failure paths (mid-stream provider error, setup rejection, persistence failure, missing workspace). ## Impact Unblocks `server-test` / `ci-server-status-check` for every open PR (including #22498, which is needed to fix the dev-cluster migration incident). Test-only change. https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22500?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
f00b6ae185 |
use Temporal for date filter evaluation and fix IS operand (#22408)
## Summary - Refactored `evaluateDateFilter` in the workflow filter action from native `Date` to the Temporal API, using the shared `parseToInstantOrThrow` and `isSamePlainDate` utilities from `twenty-shared` (resolving the long-standing `// TODO: refactor this with Temporal`). - Fixed a bug in the `IS` operand: it previously compared only `getDate()` (day-of-month 1–31), so e.g. `2023-01-15` incorrectly matched `2023-02-15`. It now compares the full calendar day in UTC. - Removed server-local-timezone leakage: `IS_TODAY` and day comparisons now run in UTC (consistent with the neighbouring relative-date filter util), instead of relying on `toDateString()`/`getDate()`. ## Behavior changes (intended) - `IS` now matches on the full UTC calendar day, not day-of-month. - `DATE_TIME` `IS` matches on the same UTC day (not exact-instant equality). - Date comparisons are UTC-based, so evaluations near midnight in a non-UTC server locale may differ from the old local-timezone behavior. - Parsing is stricter (ISO + known formats via `parseToInstantOrThrow`); malformed operands resolve to "no match" instead of being loosely guessed by `new Date()`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22408?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
027a098099 |
fix(ai): fully roll back a question answer when the resume enqueue fails (#22492)
## Rationale `answerAgentChatQuestion` is a three-step transaction without the transaction: resolve the question (flip tool part to `answered`, clear `pendingQuestionMessageId`, claim the stream), then enqueue the resume job. If the **enqueue fails**, the catch restores only `activeStreamId`. What's left behind: tool part says `answered`, `pendingQuestionMessageId` is `null`, no job will ever run. The client's own error handler rolls its card back to *pending* — so the user sees an answerable question whose re-submission deterministically throws `QUESTION_NOT_PENDING`. The turn is stuck and state is divergent on three surfaces (DB part, DB thread, client). ## Why this is the root cause, not a symptom patch The failure path was rolling back one of three writes. This makes the rollback total and **exact**: `resolvePendingQuestion` now returns the part's precise previous `toolOutput` (no reconstruction guesswork — question tools can carry arbitrary output fields), and the failure path restores the part verbatim plus the thread's pending-question state, guarded on the observed streamId so a competing claim is never clobbered. After rollback, server and client agree again: the question is pending, answering retries cleanly. The audit's alternative — forward recovery (keep the answers, mark the turn interrupted, resume via Retry) — has nicer UX in isolation but contradicts the client's existing rollback-to-pending behavior; matching the established contract wins until the client changes. ## User impact A transient Redis/queue hiccup at answer time currently bricks the question turn permanently. With this, the user sees the question again and can just re-answer. ## Test plan - [ ] CI green - [ ] Manual: fail the enqueue (kill Redis briefly) at answer time → question card returns to pending, re-answer succeeds https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22492?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
087bee0036 |
fix(ai): notify all tabs when a pending question is answered (#22491)
## Rationale `resolvePendingQuestion` updates the question tool-part to `answered` and re-claims the thread — but publishes **nothing**. The answering tab converges via a local browser event; every other tab keeps rendering the question card as interactive until the resumed stream's first chunk happens to arrive. A second tab (or teammate view on shared context) can attempt to answer an already-answered question and hit a confusing `QUESTION_NOT_PENDING` error. ## Why this is the root cause, not a symptom patch Answering a question is a state transition every subscriber cares about — exactly like queue promotion, message persistence, and stream errors, all of which publish. This transition just never did. The fix publishes the existing refetch-trigger event (`queue-updated`, which every tab already handles by refetching messages + thread state) right after resolution — no new event type, no new client code path, consistent by construction with how every other transition converges tabs. A dedicated `question-answered` event carrying the answers would save one refetch round-trip; the audit's verdict was that's over-engineering for a rare interaction. Publishing *before* the resume-enqueue is deliberate: even if the enqueue fails, the question **is** answered server-side, and tabs should reflect server truth. ## User impact Second tabs stop offering an interactive question that will error when submitted; everyone sees the answered state within a refetch instead of whenever the stream resumes. ## Test plan - [ ] CI green - [ ] Manual: two tabs on one thread, answer the question in tab A → tab B's card flips to answered without interaction https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22491?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
d1a4e250cf |
fix(ai): converge every tab's transcript when a stream fails with partial output (#22494)
## Rationale When a turn fails after emitting output, the partial assistant message **is persisted** — but only the success path publishes the event (`message-persisted`) that makes other tabs refetch. On failure, tabs that weren't watching the live stream keep a stale transcript until manual reload. (Error *visibility* itself already works — `stream-error` reaches every subscribed tab — the gap is purely the persisted-transcript sync. The original gap-analysis framing of this as an error-visibility problem was wrong; this is the corrected scope.) ## Why this is the root cause, not a symptom patch Turn settlement should converge subscribers regardless of *how* the turn settled — success and failure both persist state that tabs need. The failure path now publishes the same refetch-trigger event the rest of the lifecycle uses, right after the terminal `stream-error`. No new event type, no client changes: the existing guarded replay (`firstLiveSeq === null`) already ensures tabs with a live view keep their in-place error rendering while background tabs pick up the persisted partial message and error state. ## User impact Open the same thread in two tabs, have the turn die mid-answer in one: the other tab currently shows the conversation frozen pre-turn until reload. Now both converge to the persisted partial output plus the failed-turn state within one refetch. ## Test plan - [ ] CI green - [ ] Manual: two tabs, kill the provider mid-stream in tab A → tab B shows the partial message + error without reload https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22494?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
a505ed3245 |
feat(ai): typed CONTEXT_WINDOW_EXCEEDED error that hides the pointless Retry (#22488)
## Rationale When message pruning can't fit the conversation into the model's context window, `chat-execution.service.ts` throws a **raw `Error`**. `mapErrorToStreamError` classifies it as generic `STREAM_EXECUTION_FAILED`, so the client renders a standard failure with a **Retry button that deterministically fails again** — the conversation doesn't get shorter by retrying. Users loop on Retry against a permanently-failing thread. ## Why this is the root cause, not a symptom patch The failure is *terminal for the thread by construction*, and the error channel already distinguishes terminal-vs-retryable via typed `AiExceptionCode`s — this failure just never got one. Adding `CONTEXT_WINDOW_EXCEEDED` (typed exception → `UserInputError` mapping instead of a 500 → both error surfaces render the start-a-new-thread message without `onRetry`) puts it on the same rails as `API_KEY_NOT_CONFIGURED` and the other special-cased codes. Both frontend error surfaces route through `AiChatErrorRenderer`, so one case covers the in-message and under-list renderings. The deeper endgame (auto-summarize/compact older turns so threads never brick) is a multi-week feature — and this typed error remains necessary even then, as its terminal fallback. ## User impact Instead of an opaque error and a Retry that never works, users hitting the context limit get told exactly what happened and what to do (start a new thread), and monitoring stops counting a user-condition as a server error. ## Test plan - [ ] CI green - [ ] Manual: fill a thread past the model limit → typed message, no Retry on either error surface https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22488?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
56a6f9419b |
chore(server): fix stream-agent-chat job spec formatting breaking main's lint (#22493)
The stacked merges of #22479 and #22480 left `stream-agent-chat.job.spec.ts` on main failing the `oxfmt --check` gate (2 stray blank lines), which currently fails `server-lint-typecheck` on **every** open PR's merge ref. Two-line whitespace fix, no behavior change. Merging this first unblocks the rest of the queue. https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22493?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
ab4e979352 |
perf(ai): render streaming markdown as memoized blocks (#22489)
## Rationale `LazyMarkdownRenderer` re-parses and re-renders the **entire accumulated message** through react-markdown on every throttled stream flush (10/s). Render cost grows linearly with message length while streaming, so long answers degrade progressively — this is the dominant jank vector in the chat (verified in the perf audit: no memoization anywhere in the message-render path). ## Why this is the root cause, not a symptom patch The waste is structural: 99% of a streaming message is settled text that cannot change, yet it re-renders because the whole string is one react-markdown call. Splitting at real markdown block boundaries via `marked.lexer` (already a dependency, used in the advanced text editor) and memoizing per block means settled blocks keep their rendered subtree; only the growing tail block re-parses per flush — cost becomes O(tail) instead of O(message). Index keys are stable because streaming is append-only. This is the standard memoized-markdown pattern from the AI SDK ecosystem. Deliberately **not** included: list virtualization for very long threads. The audit's verdict was memoize first, virtualize only if profiling still shows mount cost matters — virtualization changes scroll behavior and deserves its own evaluation. One known tradeoff: markdown reference-style links whose definition lives in a *different* block won't resolve across blocks. Model output uses inline links; the tradeoff is shared by every implementation of this pattern. ## User impact Long streaming answers stop stuttering — keystroke-to-paint stays flat instead of degrading as the answer grows. Most noticeable on tool-heavy turns that produce big final summaries. ## Test plan - [ ] CI green (existing markdown rendering covered by storybook visual tests) - [ ] Manual: stream a long answer with code fences and tables — identical rendering, no per-flush jank in the profiler https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22489?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
4a8679327b |
fix(ai): bound silent stream recovery and surface a terminal CONNECTION_LOST state (#22486)
## Rationale Two gaps in the keep-alive recovery (`AgentChatStreamKeepAliveEffect`): 1. It only engages when `isStreaming` is already true — a socket that dies **before the first chunk** leaves the user waiting forever with no recovery path (CONFIRMED-high in the chat-stack audit; the window where Sentry shows failures concentrate). 2. When it does engage, it retries **silently forever** — a genuinely dead connection means an infinite spinner with the user none the wiser. ## Why this is the root cause, not a symptom patch Recovery must be gated on "a response is owed" — which since #22485 is `isStreaming || isAwaitingFirstChunk`, closing gap 1 with the state that actually models the window rather than a timer heuristic. For gap 2, unbounded retry hides a terminal condition; the fix is an honest state machine: 3 silent recoveries (resubscribe + refetch), then a client-only `CONNECTION_LOST` error. Two deliberate choices from the audit: - **No Retry button** on `CONNECTION_LOST` — it's semantically forced, not cosmetic: Retry calls `retryLastFailedTurn`, which requires a persisted `lastStreamError`; after a mere connection loss the server has no failed turn (the stream is likely still running or completed server-side), so Retry would deterministically throw `NO_FAILED_TURN_TO_RETRY`. - **Auto-clear instead of dead-end**: the moment events flow again (SSE reconnect, refetch delivering data), the `CONNECTION_LOST` error clears itself — the state is "connection lost", not "turn failed", and it self-heals when the connection returns. ## User impact A dead connection pre-first-token currently means waiting forever; mid-stream it means silent infinite recovery. Now: three quiet recovery attempts (which fix the transient cases invisibly), then a truthful message, which disappears on its own when connectivity returns — and the server-side answer is intact all along, delivered by the next successful refetch. ## Stack Based on #22485 (pending indicator) — reads the awaiting-first-chunk state. Chain: #22484 → #22485 → this. ## Test plan - [ ] CI green - [ ] Manual: kill the network pre-first-token → 3 recoveries → CONNECTION_LOST; restore network → error clears, transcript catches up https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22486?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
77529191f8 |
fix(ai): apply stream chunks in exact server seq order via a client-side sequencer (#22484)
## Rationale Stream chunks reach the client on two unsynchronized paths: live SSE events and the catchup replay (fired on reload, refetch, SSE reconnect, and keep-alive recovery). The server already stamps every chunk with an authoritative `seq` (Redis `RPUSH` length), but the client applies chunks in **arrival order**. Reload mid-stream and the two paths interleave: duplicated text deltas, or lower-seq catchup chunks applied after higher-seq live ones — the streaming answer visibly garbles until the persist-refetch repaints it. Main's existing guard (`seq < firstLiveSeq` bound on catchup) only prevents duplication in one direction (live-before-catchup); it does nothing for catchup-during-live overlap, and it *creates* a dropped-chunk window when chunks land between the catchup snapshot and the first live event. ## Why this is the root cause, not a symptom patch The defect is a joining problem between two ordered sources, and the join point is the client — the server can't fix it without a protocol change (per-subscriber cursor resume), because Redis pub/sub fan-out has no per-subscriber replay. Given the transport, the correct fix is to make the reducer's input **seq-exact**: apply strictly in server order, dedup anything already applied, buffer early arrivals until the gap fills. Escalation is bounded and degrades gracefully: a stalled gap triggers one refetch (the full-list catchup replay doubles as gap-fill, no new endpoint), a second stall flushes the buffer in order — so even an expired chunk list degrades to slightly-lossy instead of wedging. The catchup path now replays the full list (the sequencer dedups overlap), which also closes the dropped-chunk window. Server-side cursor resume remains the nicer long-term protocol (would simplify this client), but it's a subscription protocol change; this fixes the user-facing defect with zero server change and is forward-compatible with it. ## User impact Reloading (or losing the connection) mid-answer currently scrambles or duplicates the streaming text until the turn completes. With this, the answer renders identically no matter when you reload or how the two delivery paths race. ## Test plan - [x] Sequencer unit suite (fake timers): in-order apply, out-of-order buffering, catchup/live overlap dedup, gap-fill via replay, stall→refetch escalation, second-stall in-order flush, high-water-mark continuation, reset - [ ] CI green - [ ] Manual: reload mid-stream repeatedly; text never reorders https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22484?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
310742519b |
fix(ai): tell members without billing permission why AI stopped at the usage cap (#22487)
## Rationale When a workspace hits its AI usage cap, members **without** the `BILLING` permission flag get zero explanation: `AIChatNoMoreBillingCreditsBanner` returns `null` for them, and `AiChatErrorRenderer` also returns `null` for `BILLING_CREDITS_EXHAUSTED` (deliberately delegating to that same banner). Net effect — for most seats in a workspace, AI chat just silently stops working. Sentry shows the cap is hit constantly: 290 users / 90 days on `Billing Credits Exhausted`. ## Why this is the root cause, not a symptom patch The permission gate exists to hide *billing actions* (upgrade/subscribe modals) from members who can't act on them — but it was written as "hide everything", conflating the action with the information. The fix keeps the gate exactly where it belongs (no upgrade button, no modals for non-billing members) and renders the information-only banner: "Your workspace hit its AI usage limit. Ask an admin to upgrade the plan." Fixing it in the error renderer instead would be the wrong altitude: the banner mounts *before* a send is attempted (gated on `hasReachedCurrentBillingPeriodCap`), so members are informed proactively rather than after a failed send. ## User impact Non-admin members — the majority of seats — stop experiencing "AI is broken" and instead see what happened and who can fix it. ## Test plan - [ ] CI green - [ ] Manual: member without billing permission at cap → informational banner, no upgrade button; admin → unchanged upgrade flow https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22487?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
1feb41eb64 |
perf(sse): drop the per-workspace lock around atomic activeStreams set operations (#22476)
## Rationale Every event-stream create/destroy in a workspace serializes on a single cache lock (`workspace:<id>:activeStreams`) just to run `setAdd`/`setRemove`. On Redis those are native `SADD`/`SREM` — already atomic (`cache-storage.service.ts:79-109`) — so the lock adds zero correctness. What it does add: 100ms lock-retry polling under concurrency, and a hard 5s ceiling (50 retries × 100ms, `cache-lock.service.ts:36`) after which stream creation **fails** with `Failed to acquire lock`. **Production evidence (Sentry):** [TWENTY-SERVER-H3W](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-H3W) (125 events, 16 users) server-side and [TWENTY-FRONT-6MM](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-6MM) (65 users) client-side — spiky, consistent with deploy-driven reconnect storms in large workspaces where every tab reconnects at once and queues on one lock. ## Why this is the root cause, not a symptom patch The failure isn't "the lock timeout is too short" (raising it would just trade errors for latency) — it's that the critical section doesn't exist. A set-membership add/remove of a single element has no read-modify-write window on Redis. The lock that **is** legitimate stays untouched: `@WithLock` on `addQuery`/`removeQuery`, which genuinely read-modify-write the JSON queries map. The non-Redis fallback of `setAdd` is read-modify-write, but that path only serves single-node dev/test setups where the worst case is a transiently miscounted metrics gauge (`twenty_event_streams_live_total`), not a correctness issue — the authoritative per-stream state lives in its own key. ## User impact During reconnect storms (deploys, network blips) in busy workspaces, tabs no longer randomly fail to establish their event stream — which previously meant no live updates for that tab and, through the strict client error path, a crash of the listener sync loop (fixed separately in #22475). Also removes up-to-5s of serialized queueing latency per workspace on every connect wave. ## Test plan - [x] Verified `setAdd`/`setRemove` are native `SADD`/`SREM` + `EXPIRE` on the Redis path - [x] No callers depend on the lock's ordering (checked `engine/subscriptions` call sites) - [ ] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22476?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
3b76ec528f |
fix(ai): route missing-workspace stream failures through the standard error path (#22480)
## Rationale When `StreamAgentChatJob` can't find the workspace, it publishes a transient `stream-error` event and **returns before the try/finally exists** (`stream-agent-chat.job.ts`). Consequences on main: - `activeStreamId` is never cleared → every subsequent send in that thread queues behind a dead claim, forever; - no `lastStreamError` is persisted → nothing renders after a reload, and Retry has nothing to retry; - nothing throws → **zero telemetry**. Sentry confirms: the "Workspace not found" issues that exist are all auth/Stripe paths — this path fails in complete silence. ## Why this is the root cause, not a symptom patch The job's catch/finally already implement the correct failure contract for *every other* error: persist a typed `lastStreamError`, publish the typed event, release the claim guarded on the observed streamId. The bug is that one code path bypasses that contract via an early return. The fix removes the bypass — the lookup moves inside the `try` and throws a typed `AiException(WORKSPACE_NOT_FOUND)` — rather than duplicating cleanup in the early-return branch (which would be the symptom patch, and would drift the next time the contract changes). The alternative "prevent the job from existing when the workspace is gone" isn't achievable: workspace deletion between enqueue and pickup is an inherent race, so the job must handle it regardless. ## User impact A workspace deleted/deactivated mid-flight currently bricks the thread silently (the user just sees sends vanish into a queue). With this, the failure is visible (typed error message), recoverable (standard failed-turn state), and observable (real exception in monitoring). ## Stack Based on #22479 (spec harness) — it extends the same spec file with the regression test. `WORKSPACE_NOT_FOUND` is a TypeScript enum member, not a GraphQL schema change: no client-sdk regeneration needed. ## Test plan - [x] Regression test: missing workspace → typed rejection, `lastStreamError` persisted, terminal event published, claim released - [ ] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22480?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
ca8ab32253 |
fix(ai): delete the Redis chunk list on credits-exhausted terminal events (#22477)
## Rationale The AI chat stream keeps every published chunk in a Redis list (`agent-chat-stream-chunks:<threadId>`, 1h TTL) so late subscribers can catch up. On `message-persisted` the list is deleted. On `credits-exhausted` — the *other* successful terminal event — it wasn't. Any reload/refetch within the TTL replayed the orphaned chunks, flipping the thread into a "streaming" state that no terminal event ever closes: an endless spinner until the user sends another message. **Production evidence (Sentry):** `Billing Credits Exhausted` fired for **290 users / 937 events in 90 days**, ongoing ([TWENTY-SERVER-G42](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-G42)) — every one of those users who reloads the chat within an hour hits this. ## Why this is the root cause, not a symptom patch The chunk list's lifecycle contract is "cleared when the turn settles". `credits-exhausted` resolves the job successfully **without** persisting a `lastStreamError`, so unlike `stream-error` there is no persisted terminator for catchup to replay after the chunks — the replay is unconditionally un-closeable. Deleting on both settle events restores the contract exactly where it's already enforced for `message-persisted`. `stream-error` deliberately keeps the list: the persisted error acts as the replay terminator, letting a reloading client still see the failed turn's partial output. ## User impact Users who hit their billing cap mid-answer (~100/month) no longer come back to a permanently spinning thread after a reload — they see the settled conversation and the billing state. ## Test plan - [x] Unit spec: chunk accumulation with 1-based seq, deletion on both terminal events (`it.each`), retention on `stream-error` - [ ] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22477?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
6ac8ebcd18 |
test(ai): pin StreamAgentChatJob stream lifecycle with a reusable spec harness (#22479)
## Rationale `StreamAgentChatJob` is the most failure-sensitive path in the AI chat stack — it coordinates the model stream, Redis event publishing, message persistence, the thread's stream claim, and the queued-message flush — and it had **zero unit coverage**. Both historical hangs lived here: - a throw before the model stream merges bypassed `onFinish` entirely and hung the job until the **10-minute BullMQ lock** expired (thread stuck the whole time); - a throw inside `onFinish` (persistence failure) left trailing chunks published with **no terminal event** — the client spinner ran forever. Production still shows this class is live: `Query read timeout` thrown from inside `handleStreamFinish` ([TWENTY-SERVER-GV7](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-GV7)). ## Why this shape, not something else Tests-only PR, zero production risk. The fake chat stream mirrors the one AI SDK contract the job's coordination depends on — verified against the installed `ai@6.0.97` dist: `toUIMessageStream` converts mid-stream errors into error parts and **always** fires `onFinish` when the stream ends (`handleUIMessageStreamFinish` invokes it from both `flush()` and `cancel()`). Pinning that contract in the fake means a future SDK upgrade that breaks it fails these tests instead of production. Six tests pin current behavior: chunk ordering with `message-persisted` last, opaque error-chunk suppression, mid-stream failure persisting `lastStreamError` + releasing the claim, the two hang regressions above, and cancel skipping the queued flush. Three sibling PRs extend this exact spec file (missing-workspace routing, halted queue, and — later — auto-retry), which is why the harness lands first. ## User impact None directly; it makes the two worst historical user-facing hangs (10-minute dead thread, infinite spinner) regression-proof before the stuck-state fix series touches this code. ## Test plan - [x] 6 unit tests, no production code changed - [ ] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22479?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
a75414a05e |
fix(sse): treat expected event-stream coordination errors as 403s and never crash the sync loop (#22475)
## Rationale `NOT_AUTHORIZED` and `EVENT_STREAM_ALREADY_EXISTS` on the event-stream mutations are **expected coordination outcomes** — the frontend explicitly recognizes both (`isGracefullyHandledEventStreamError`) and recovers by recreating its stream. But `EventStreamExceptionFilter` rethrows them as `InternalServerError`, which (a) Sentry captures on every single occurrence, and (b) counts as a 500 in operation metrics. **Production evidence (Sentry):** this turned a March client-regression into a 119,510-event / 2,607-user flood ([TWENTY-SERVER-FP3](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-FP3), plus FP0 at ~29k) that buried real errors. The trigger was fixed back then, but the amplifier — error-level capture of an expected signal — is still in place, and a residual trickle still fires today. Second defect, client side: for any *non-graceful* server error (e.g. a lock-acquisition timeout), `SSEQuerySubscribeEffect.handleError` **threw** from inside a debounced callback — an unhandled rejection ([TWENTY-FRONT-62M](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-62M), 233 users; [6MM](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-6MM), 65 users) that left the tab's query listeners permanently out of sync with the server (no more live updates until reload). ## Why this is the root cause, not a symptom patch The protocol design already says these are recoverable client-coordination signals — the bug is purely that the server encodes them with 500 semantics and the client punishes unexpected errors by giving up instead of resetting. This PR aligns both ends with the existing design rather than adding new machinery: - Server: `ForbiddenError` (403) with the same `subCode` — the client's graceful check already accepts `code === 'FORBIDDEN'`, so this is compatible by construction; `FORBIDDEN` is already in `graphQLErrorCodesToFilter`, so monitoring capture stops with no new filtering logic. - Client: the non-graceful path now does exactly what the graceful path does (reset listeners + recreate stream) and *additionally* reports the unexpected error — visibility without a crash. ## User impact Tabs that hit any event-stream error now always self-heal back to live updates instead of silently going stale until reload (~300 users hit the crash path over 90d). On the ops side: expected coordination noise leaves error monitoring, and 403/500 metrics become truthful. ## Test plan - [x] Behavior preserved for graceful codes (same reset path, client check already includes FORBIDDEN) - [ ] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22475?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
a445974cff |
fix(ai): offer Retry when the failed turn persisted partial assistant output (#22478)
## Rationale When a turn fails mid-stream *after* emitting some text, the failed turn's partial assistant message is persisted — so the last message in the thread is the assistant's, and `AiChatErrorUnderMessageList` (which owns the Retry button, gated on the last message being the user's) never renders. The error surfaces through `AiChatMessage` → `AiChatErrorRenderer` instead, and that path never passed `onRetry`. Result: an error banner with no action for the most common failure shape (mid-stream provider errors), most visibly after a reload. ## Why this is the root cause, not a symptom patch This is a wiring omission, not a designed gate. `AiChatErrorRenderer` already accepts `onRetry`, and the server's `retryLastFailedTurn` already deletes the failed turn's assistant messages before re-streaming — the entire retry path for partial-output turns exists and works; only the prop was never threaded. Verified there's no hidden protective reason: retrying with partial output cannot duplicate content, because regeneration is delete-then-restream by design. ## User impact A mid-stream failure currently strands the user: their only options are re-typing the message or reloading. With this, the same Retry affordance appears whether the turn died before or after the first token (Sentry shows 126 users/30d hitting zero-output failures alone — the with-output shape shares the same recovery need). ## Test plan - [x] `AiChatErrorRenderer` retry behavior already covered by existing rendering; change is prop threading only (~10 lines) - [ ] CI green - [ ] Manual: fail a turn mid-stream (kill provider), observe Retry on the in-message error banner https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22478?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
7c33280465 |
Fire front component window error handlers (#22462)
## What
Front components run in a Web Worker with the remote-dom polyfill, which
installs a fake `window`. Native `error` and `unhandledrejection` events
only fire on the real worker scope, so a component's `window.onerror`,
`window.addEventListener('error', ...)`, or
`window.onunhandledrejection` handler is a **silent no-op** today —
error-tracking libraries (Sentry-style) never see anything.
This adds `installErrorEventBridge` to the worker bootstrap: it listens
for the native `error`/`unhandledrejection` events and re-dispatches
equivalent events onto the fake `window`, so component-registered
handlers fire as they would on the web. It is guarded to no-op outside
the worker (when the fake window is the global scope) and swallows
errors thrown by a component's own handler.
## Scope
Worker-side only, no host, SDK, or RPC changes. Uncaught synchronous
errors already reach the host error panel via the native worker
`onerror`; surfacing unhandled promise rejections to the host panel is a
separate follow-up.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22462?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
bc1a4cb3fb |
feat(call-recorder): cap media file size during ingestion to avoid OOM (#22463)
Media ingestion buffered whole Recall files in memory; long recordings OOM'd the logic function. - Caps the size of ingested media files, configurable via the `CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB` app variable (default 80 MB). - Downloads stream chunk by chunk and stop at the cap; a Content-Length above the cap skips the download without reading the body. - A skipped file is recorded as `video_file_too_large` / `audio_file_too_large` in `callRecorderFailureReason`; the completion gate treats a marked file as resolved, so the recording still completes and bills with its remaining artifacts. - A real failure reason always wins over the size markers when the recording fails. Deferred: the cap is a stopgap until core supports streaming uploads (TODO in code). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22463?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
6f64be5751 |
Gate and meter email group: enterprise license (self-host) + credits (cloud) (#22390)
Email group (marketing email) was gated only by the `IS_EMAIL_GROUP_ENABLED` feature flag with no server-side enforcement. This adds real gating, split by deployment: - **Self-hosted** (`IS_BILLING_ENABLED=false`): requires a valid Enterprise plan. - **Cloud** (billing enabled): metered by credits, mirroring the existing AI credit system. Priced on AWS SES cost ($0.10/1,000 outbound) × 3 margin = $0.30/1,000 (300 micro-credits/email). Pre-flight blocks sends when out of credits; each email is charged after SES accepts it, in the async send job — matching how AWS bills us (no refund on bounce). Enforcement is applied at every email group resolver, and denials surface as proper client errors through a dedicated GraphQL exception filter. |
||
|
|
a28887bba6 |
feat(server): workspace opt-out of root-domain directory listing (#22423)
## What Lets a workspace opt out of being surfaced in the multi-workspace root-domain (app.twenty.com) picker via **email-domain discovery**. Adds `isDirectoryListingEnabled` (default `true`) on the workspace. When `false`, the workspace is filtered out of the approved-access-domain branch of `findAvailableWorkspacesByEmail`, so a user whose email domain matches an approved access domain no longer sees the workspace in the sign-up picker. ## Scope of the opt-out (deliberately narrow) The filter is applied **only** to the approved-access-domain discovery source: - **Members** (`availableWorkspacesForSignIn`) — never filtered; they keep access. - **Explicit invitations** — never filtered; the intent is one-to-one. - **Approved-access-domain discovery** — the only "listing" source, gated by the flag. A hidden workspace stays fully reachable by members and invited users via the direct workspace subdomain; it just isn't advertised in the global picker. > Open question for review: do we also want a stronger mode that hides the workspace from the root-domain picker even for existing members (forcing them to use the subdomain directly)? That would additionally filter the member/invitation sources and is a larger behavior change — not included here. ## Changes **Backend** - `workspace.entity.ts` — new `isDirectoryListingEnabled` column (`@Field`, default `true`). - `user-workspace.service.ts` — filter the approved-access-domain branch on the flag. - `update-workspace-input.ts` — expose the field on `updateWorkspace`. - `workspace.service.ts` — `PermissionFlagType.SECURITY` (same as the other discovery/security toggles). - Fast instance command adding the column (default `true`, so no existing workspace is hidden). **Frontend** - Settings > Security: a **"List in workspace directory"** toggle (shown only in multi-workspace mode) that flips the flag via `updateWorkspace`, mirroring the existing `isInternalMessagesImportEnabled` toggle. - Threaded the field through the current-user fragment, `CurrentWorkspace` type, and mock data. - Regenerated the metadata + client-sdk GraphQL types (`generated-metadata`, `twenty-client-sdk/.../generated`) — generated against a server booted from this branch. ## Verification - `tsgo` typecheck: 0 errors. `oxlint`: 0/0. `oxfmt`: clean. - Codegen diff verified to contain **only** the new field (no unrelated drift). - Tests not run locally; CI covers unit/integration + the codegen/migration freshness checks. --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> |
||
|
|
4f44131901 |
perf(front): query only rendered fields in application settings pages (#22454)
Part of the application settings architecture work: https://github.com/twentyhq/core-team-issues/issues/2456 Application settings pages pulled far more data than they render: - **`FindOneApplicationByUniversalIdentifier`** fetched the full `ApplicationFields` fragment (all nested agents, objects, logicFunctions, frontComponents, commandMenuItems) just so `SettingsAvailableApplicationDetails` could check whether an app is installed. Slimmed to `id, universalIdentifier, name, version` (its only caller; every field usage audited). - **`FindManyApplicationRegistrations`** (developer tab list) fetched the 17-field registration fragment including `isConfigured`, which triggers a per-row DataLoader resolve. The list renders only `id, name, universalIdentifier, sourceType` — new lean `ApplicationRegistrationListItem` fragment. The detail page and admin list, which actually render `isConfigured`, keep the full fragment. - **`SidePanelEditOwnerSection`** pulled the full `FindOneApplication` payload to render a name — new `FindOneApplicationName` (`id, name`) query. Regenerated `generated-metadata/graphql.ts` (document-level changes only, zero schema drift; data/admin outputs byte-identical). Deliberately untouched: the installed-app detail page query (renders its nested collections across tabs), the sub-detail pages that share its cache entry, and the marketplace manifest usage (needs backend fields — later PR). Verified: typecheck, oxlint/oxfmt on touched files, jest (applications 19/19, navigation-menu-item 86/86). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei --- _Generated by [Claude Code](https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22454?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
49095dcfe0 |
Drop unsafe props from front component elements (#22458)
## What Front components are third-party React components rendered into the host page through a restricted element allow-list. `filterProps` (where their props become real DOM attributes) used to forward unrecognized values as-is, which left two ways to run script in the host origin: - an `on*` attribute with a string value, which React renders as an inline event handler; - a dangerous-scheme URL (`javascript:`, `data:`, `vbscript:`) on a link, which executes on navigation. ## Change `filterProps` now drops both: - `on*` props are kept only when the value is a real function (still wrapped as before); any non-function `on*` is dropped. - `javascript:` / `data:` / `vbscript:` URLs are dropped, but only on **navigation targets** (`<a>`/`<area>` `href`/`xlink:href`, `<form>` `action`, `<button>`/`<input>` `formaction`), after normalizing away control-character obfuscation (e.g. `java\tscript:`). Resource-loading attributes are left alone, so `<img src="data:image/...">` keeps working. Well-behaved components are unaffected: function handlers are still wrapped and normal URLs pass through. Host-side only, no worker or SDK changes. ## Scope: the actual behavior change is small The diff looks large, but most of it is **not** a behavior change. `createHtmlHostWrapper.ts` (~460 lines) was split into one-export-per-file utils (`filterProps`, `serializeEvent`, `parseCssString`, `hasDangerousUrlScheme`, etc.), each with its own unit test, leaving `createHtmlHostWrapper.ts` as a thin orchestrator. Those helpers were **moved unchanged** — the only real logic change is the `filterProps` hardening described above. The pre-existing render-based integration test passes untouched, which confirms the split is behavior-neutral; the rest of the new files are extractions plus added test coverage. ## Why these schemes, and only on navigation targets Per MDN, `javascript:` (and `data:`) URLs are dangerous specifically where a URL is a *navigation target*, not where it is a *resource location* (like an image `src`) — which is exactly how the check is scoped: - [`javascript:` URLs (MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascript) - [`data:` URLs (MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data) - [URI schemes overview (MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes) This is a prerequisite for later work that widens the raw-attribute surface (innerHTML rendering). |
||
|
|
29e48e16ba |
[Breaking change] fix: make pageLayout type field required (#22450)
fixes https://github.com/twentyhq/twenty/issues/22251 **Summary** - Fixes #22251 — NavigationMenuItem with type PAGE_LAYOUT returns 404 "Off track" for custom standalone pages - Makes type a required field in PageLayoutManifest instead of relying on a fallback default to RECORD_PAGE - Adds PageLayoutType enum to twenty-shared and exports it from the SDK for app developers - Adds build-time validation in definePageLayout to reject manifests missing type - Updates the CLI add command to prompt users to select a page layout type interactively **Root cause** When definePageLayout was called without type, the manifest converter defaulted to RECORD_PAGE. The frontend route guard at /page/:id then rejected it (only STANDALONE_PAGE is allowed), producing a 404. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22450?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
7cf8b58f1b |
feat(emailing): local unsubscribe URL + full-content log driver (#22412)
In LOG mode, emit a working http://unsubscribe.<subdomain>.localhost/emailing/unsubscribe link (from SERVER_URL + workspace subdomain) instead of empty content, and log the full text/html body + List-Unsubscribe header so the flow is inspectable locally. buildUnsubscribeUrls now takes a full base URL (field renamed httpsUrl -> webUrl). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22412?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
4aaf171d63 |
feat(ai): add ask_questions interactive clarifying-question tool (#22346)
## What & why Adds an `ask_questions` tool that lets the in-app **Ask AI** assistant **pause a turn to ask the user one or more multiple-choice questions** (per the [Figma design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=105959-117153)) and resume once answered — instead of guessing on ambiguous/consequential decisions. The tool is **harness-only**: an interactive question UI is meaningless without a user to answer it, so it must be absent from MCP and from head-less workflow agents. ## Design — true tool-result resume (not a synthetic user message) The user's answer is a **structured tool result bound to the `toolCallId`**, and the **same agent turn resumes** — exactly how Anthropic (`tool_result` by `tool_use_id`) and OpenAI (`function_call_output`) model human-in-the-loop. The naive form of this (leave the tool call in `input-available` to mean "pending") is **impossible** here: `finalizeDanglingToolParts` rewrites `input-available` → `output-error` ("Tool execution was interrupted") on both the persist path (`addMessage`) and the model-reload path (`chat-execution.service.ts`). That util is a load-bearing safety net, so weakening it is the wrong move. Instead: - `ask_questions` is an **inline, chat-only tool with an `execute` that returns a `status: 'pending'` result immediately**, so the tool part is always `output-available` and **immune to `finalizeDanglingToolParts`**. `stopWhen(hasToolCall('ask_questions'))` halts the turn right after the call (the model never sees the placeholder). - A nullable **`thread.pendingQuestionMessageId`** marker records that a turn is awaiting an answer. - The new **`answerAgentChatQuestion`** mutation atomically *claims* the question (clears the marker, marks the thread streaming), **writes the answer onto the same tool part** (`status: 'answered'`), and **re-enqueues the turn via the existing `existingTurnId` plumbing** (`isResume` bypasses the per-turn dedup guard). On resume `finalizeDanglingToolParts` leaves the `output-available` part untouched and `convertToModelMessages` emits `assistant(tool_use)` + `tool_result(answers)`, so the model continues. This achieves the platform-aligned semantics **without** weakening the finalize safety net or inventing a fragile new part state. ### Meets the two requirements - **Survives refresh, scoped per-thread** — the pending state is a normal persisted `output-available` part + the thread marker; the frontend card is derived per-thread from the loaded messages, so it re-appears on reload and only on its own thread. - **Takes priority over the queue** — a unified `isBlocked = activeStreamId || pendingQuestionMessageId` gate is applied in both `sendChatMessage` (new messages queue) and `flushNextQueuedMessage` (the drain). The queue cannot unpile until the question is answered and the resumed turn completes. ### Harness-only by construction `ask_questions` is added **only** to the chat's inline `activeTools` (like `learn_tools`/`execute_tool`/`load_skills`). It never enters the tool registry/catalog, so it is invisible to MCP and to workflow agents — no `MCP_EXCLUDED_TOOL_NAMES` entry needed. ## UX While a question is pending, the **composer is replaced by the question card** (matching the Figma): question title + pager (`1/2`), numbered option rows (`IconSquareNumber*`) with per-option info-icon descriptions and a "Recommended" badge, and the normal composer as the free-text fallback ("Type anything to do differently."). The transcript shows a compact "Asking questions…" status line that becomes an answered summary. ## Changes **twenty-shared** - `ai/types/AskQuestionsToolTypes.ts` — `AskQuestionItem/Option/Answer/Result`, `ASK_QUESTIONS_TOOL_NAME`. **twenty-server** - `ai-chat/tools/ask-questions.tool.ts` — inline tool factory (pending-result `execute`, zod schema, 1–4 questions × 2–4 options). - `chat-execution.service.ts` — add to `activeTools` + `preloadedToolNames`; `hasToolCall` in `stopWhen`. - `chat-system-prompts.const.ts` — when-to-use guidance. - `entities/agent-chat-thread.entity.ts` — `pendingQuestionMessageId` column. - `stream-agent-chat.job.ts` — set the marker on a question pause; bypass the dedup guard on resume; suppress the no-text warning for question pauses. - `agent-chat-streaming.service.ts` — gate `flushNextQueuedMessage`; `enqueueResumeStream`. - `agent-chat.resolver.ts` — gate `sendChatMessage`; `answerAgentChatQuestion` mutation. - `agent-chat.service.ts` — `resolvePendingQuestion` (atomic claim + write answer). - `dtos/agent-chat-question-answer.input.ts`, `ai.exception.ts` (`QUESTION_NOT_PENDING`), `utils/find-pending-question-part.util.ts`. **twenty-front** - `components/AiChatQuestionCard.tsx` — the interactive card (matches Figma tokens) + `__stories__/AiChatQuestionCard.stories.tsx`. - `components/AiChatEditorSection.tsx` — swap the composer for the card while pending. - `components/AiChatQuestionStatusRenderer.tsx` + branch in `AiChatAssistantMessageRenderer.tsx`. - `states/selectors/agentChatPendingQuestionComponentSelector.ts`, `types/AgentChatPendingQuestion.ts`. - `hooks/useSubmitQuestionAnswer.ts` + `utils/markQuestionAnswered.ts` (optimistic) + `graphql/mutations/answerAgentChatQuestion.ts`. A design doc lives at `packages/twenty-server/docs/ASK_USER_QUESTION_TOOL_PLAN.md`. ## Migration Adds a nullable `pendingQuestionMessageId` (uuid) column to `core.agentChatThread`. Needs a generated **fast instance command** (`database:migrate:generate --name addThreadPendingQuestion --type fast`) — see "Verification status". ## Tests - Server: `ask-questions.tool.spec.ts` (pending echo + schema bounds), `find-pending-question-part.util.spec.ts`. - Front: `markQuestionAnswered.test.ts`, plus the Storybook story. ## Verification status (please read) This branch was authored in an environment where the monorepo `yarn install` repeatedly failed on transient TLS resets from the package registry, so I could **not** locally run the mechanical gates. The logic was reviewed by hand and the `ai@6.0.97` exports used (`hasToolCall`, `stepCountIs`, `generateId`) were confirmed against the package's type defs. Still **TODO** (will rely on CI / a follow-up once deps install): - [ ] `nx run twenty-shared:generateBarrels` (the `ai/index.ts` export was added by hand; regen to reconcile) - [ ] `nx run twenty-front:graphql:generate` (new mutation + input type) - [ ] generate the fast instance command (migration) for the new column - [ ] `typecheck` + `lint:diff-with-main` (front + server) — expect minor import-ordering autofixes - [ ] run the unit tests **Screenshots:** reproducing the live flow needs an AI provider API key (to get the model to actually call `ask_questions`), which isn't available here. The card can be screenshotted from its **Storybook story** (`AiChatQuestionCard.stories.tsx`) with no API key — I'll add that image once deps install, or a reviewer can run `nx storybook twenty-front`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB --- _Generated by [Claude Code](https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22346?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
198f1e4916 |
feat(emailing): forward SES events to Tatami Monitor (#22407)
Add AwsSesObservabilityService, which adds an SNS event destination to each workspace's SES configuration set (gated on TATAMI_SNS_TOPIC_ARN) so deliverability events reach Tatami. Tag sends with tenant_id for per-workspace breakdowns. Requires to be merged https://github.com/twentyhq/twenty-infra/pull/765 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22407?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
ff6a0c6e69 |
Fix front component crash on unknown elements (#22455)
## What Front components are third-party React components rendered on the host via remote-dom against an allow-list of elements. Today the host renderer throws on any element tag it has no component for (e.g. a raw tag produced by `innerHTML`), and there is no error boundary, so a single unknown element crashes the whole widget. This wraps the component registry with a fallback: - a raw tag that has an allow-listed `html-*` equivalent is routed to that safe wrapper (so a raw `iframe` renders through the existing sandbox-forcing renderer instead of being dropped), - tags with no safe renderer (`script`, `object`, `embed`, `link`, `meta`, `base`, `noscript`, `style`) render nothing, - any other unknown tag renders children only. `RemoteRootRenderer` is also wrapped in an error boundary that fails closed to the existing error panel, so a render error can no longer take down the host. ## Notes The host allow-list remains the single rendering gate. This is the first hardening step of a broader effort to widen the DOM/Web API surface available to front components; it is self-contained and does not change behavior for components that only use allow-listed elements. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22455?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
11b2990dd6 |
fix(twenty-sdk): stop dev-mode OOM by caching compiled manifest modules (#22435)
## Context Closes twentyhq/core-team-issues#2601 `twenty dev` crashed with a Node.js heap OOM (`FATAL ERROR: Reached heap limit — JavaScript heap out of memory`) after a while of editing. ## Root cause `loadModule()` in `manifest-extract-config-from-file.ts` compiled every manifest-defining file with **`vm.compileFunction`** on every manifest rebuild. V8 pins every function compiled through the `vm` module and never releases it ([nodejs/node#35375](https://github.com/nodejs/node/issues/35375)). In the dev loop this is on the hottest path and heavily amplified: - `runSyncPipeline` → `buildManifest` re-globs **all** `.ts/.tsx` files and recompiles every entity file on **every** sync — not just the edited one. - A single save triggers 2+ full rebuilds (the manifest watcher change → `scheduleSync`, then the esbuild watcher's `handleFileBuilt` → `scheduleSync` again). - Each compiled unit is the full esbuild bundle — hundreds of KB, up to MBs for front components (React/JSX inlined). So over an hour of editing, thousands of `vm.compileFunction` calls × large source, all permanently retained → multi-GB heap → crash. This matches the reported profile exactly. Investigation ruled out (with evidence): chokidar watchers (disposed on restart), ts-morph/`createProgram` (dead code, not in the dev loop — typecheck runs in child `tsc` processes), the event log (hard-capped at 200), Ink timers/subscriptions (all cleaned up), and graphql-sse (only used by `logs`). ## Change Keep only the **latest build per file**, keyed by file path. Each cache entry stores the file's last bundled-output hash and its compiled wrapper: - Rebuild with **unchanged** output → reuse the existing wrapper (no recompile). - Output **changed** → overwrite the entry, so the file's previous build is dropped instead of accumulating. This bounds the cache to one entry per file rather than one per rebuild, so old builds no longer pile up in the heap. The wrapper is still executed fresh into a new module shim on every call, so extraction behavior is unchanged. One file changed: `packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts`. ## Test - Behavior of `extractManifestFromFile` is unchanged (fresh execution per call); only redundant recompilation is eliminated and stale builds are dropped. - Note: `yarn install` could not complete in the authoring sandbox (a git-based transitive dep of `twenty-desktop` is blocked by the proxy), so lint/typecheck/tests were not run locally — relying on CI. ## Follow-ups (not in this PR) - Redundant **double-sync per save** (`start-watchers-orchestrator-step.ts`) triggers two full rebuilds per edit. - Latent **event-log display bug**: new events stop appearing once the 200-event cap is reached. |