96ea1e1ffc85d7b16cea989a15d4a2ad13f3175a
1316 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
96ea1e1ffc |
Track connected account webhook subscription lifecycle metrics (#23710)
Emits created/renewed/deleted counters and their failure counterparts from the messaging and calendar webhook subscription services. Each counter carries channel_type and provider attributes so the Grafana panels can break them down. Infra side: twentyhq/twenty-infra#841 Co-authored-by: neo773 <huzef@twenty.com> |
||
|
|
495bd193a3 |
Messaging archived account fix (#23553)
Fix case where workspace admin wants to reconnect inherited connected account Case: - workspace admin inherits team accounts from other workspace member who left the workspace - admin wants to reconnect inherited channels but it's not possible as there's no path to make archived connected account active Expected outcome: admin, who has credentials to archived connected accounts, can reconnect said accounts <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23553?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: neo773 <huzef@twenty.com> Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> |
||
|
|
e81fdbcc7a |
feat(workflow): variable pickers for Search Records limit, offset and date filters (#23696)
## Summary <img width="491" height="390" alt="Capture d’écran 2026-08-03 à 11 30 40" src="https://github.com/user-attachments/assets/6db59b8a-3e8e-41b8-80b3-c736e56b7f7f" /> Adds workflow variable pickers to the **Search Records** action for fields that previously only accepted static values: - **Limit** and **Offset** number inputs now expose the `WorkflowVariablePicker`, so they can be bound to a variable from a previous step. The stored value can be a standalone variable string; the backend coerces the resolved value back to a number. - **Date filters** using the `Is before` (`IS_BEFORE`) and `Is after or equal` (`IS_AFTER`) operands now expose the variable picker in the advanced filter side panel (previously disabled for all date filters). The backend already resolves these inputs via `resolveInput`; the only backend change is a small numeric coercion of the resolved limit/offset. ## Changes - `WorkflowEditActionFindRecords.tsx` — pass `WorkflowVariablePicker` to the Limit/Offset inputs; make `onChange` and form state variable-aware (`number | string`). - `AdvancedFilterSidePanelValueFormInput.tsx` — enable the date `VariablePicker` only for `IS_BEFORE` / `IS_AFTER`. - `useGetRecordFilterDisplayValue.ts` — return the raw variable for a standalone `{{variable}}` value so date filters don't crash `Temporal.*.from`. - `find-records-action-settings-schema.ts` — allow a string (variable) for `limit` / `offset`. - `find-records.workflow-action.ts` — coerce resolved `limit` / `offset` to numbers before querying. ## Testing Built a workflow locally (Manual trigger → Code step returning `{ limit: 2, offset: 1, sinceDate }` → Search Records) with all three fields bound to those variables. The run completed successfully; the Search Records step returned exactly 2 records (limit applied) filtered by `createdAt >= sinceDate`, confirming the backend resolves each variable. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23696?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
00ad1544d8 |
Classify OAuth refresh errors by reason instead of status code (#23705)
Both provider parsers treated unrecognised failures as permanent, so a single transient error marked a working account as needing reconnection and it never recovered on its own. Permanence is now decided by the provider's OAuth error code, everything else is temporary and retries. Checked against prod: 15 connected accounts currently flagged auth-failed still return a valid token when refreshed, and 12 of those were flagged in bursts across unrelated workspaces (five within 90 seconds on 2026-01-13), which points at a transient blip rather than users revoking access. --------- Co-authored-by: neo773 <huzef@twenty.com> |
||
|
|
7c797ff0c9 |
Filter connected account webhook renewal to exclude accounts with failed authentication errors (#23694)
/closes TWENTY-SERVER-J1F <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23694?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: neo773 <huzef@twenty.com> |
||
|
|
ad8830ecbf |
fix(server): stop FIND_RECORDS from silently ignoring its filter (#23640)
## Problem
A FIND_RECORDS workflow step with a filter configured could silently
return every record (and thus the first row of the table) instead of
applying the filter. Ways to hit it:
- `recordFilters` set but `recordFilterGroups` omitted (e.g. an
API/agent caller, or any non-UI config).
- A grouped filter (carrying `recordFilterGroupId`) whose
`recordFilterGroups` is missing.
- A filter referencing an unknown `fieldMetadataId` or an unresolvable
relation (`turnRecordFilterIntoRecordGqlOperationFilter` returns
`undefined`, silently dropped).
- `gqlOperationFilter` set, which passed validation but was never read.
In every case the computed filter collapses to `{}`,
`FindRecordsService` returns all records ordered by `id ASC`, and
`records[0]` is the first row. This is fail-open: the step reports
success and returns wrong records rather than erroring.
## Fix
- Compute the filter whenever `recordFilters` is non-empty, defaulting
`recordFilterGroups` to `[]`. `computeRecordGqlOperationFilter` handles
ungrouped filters independently of groups.
- **Fail closed**: if `recordFilters` is non-empty but the computed
`gqlOperationFilter` is empty, throw `INVALID_STEP_INPUT` instead of
running an unfiltered query. This covers grouped-without-groups and
unknown-field/unresolvable-relation cases raised in review. An absent or
empty `recordFilters` still legitimately means "find all".
- Remove the unused `gqlOperationFilter` field from the find-records
input type and settings schema so it is no longer advertised as a filter
option (it has been dead since #16147, when the action moved to
computing the filter at runtime from `recordFilters`).
|
||
|
|
f663cd3c68 |
Move open-record-in to object metadata and member preference (#23614)
Replaces the per-view "Open in" setting with a two-level model, following up on #23422 / #23424 and superseding the closed #23446 and #23457: - `objectMetadata.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` | `USER_CHOICE` (default `USER_CHOICE`) - `workspaceMember.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` (default `SIDE_PANEL`), editable in Settings > Experience The rule: records open where the member prefers, unless the object pins them, and never in a panel there is no room for (mobile always resolves to the record page). ## Why Having the setting on views, objects and members at once was heavy, and view-level resolution was fragile: a chip rendered outside a view (notes, front components, kanban cards pointing at another object) had no view to read from, which is the class of bug behind #23422. Resolution is now context-free: it needs only the object, the current member and the viewport, so chips behave identically everywhere by construction. ## Changes **Object level** - New `openRecordIn` enum column on `objectMetadata`, editable through `updateOneObject` and surfaced in Settings > Data model > Object > Layout ("Open records in": Member preference / Side Panel / Record Page) - Standard definitions pin `workflow`, `workflowVersion`, `dashboard` and `messageCampaign` to the record page (matching the previously hardcoded list) and `calendarEvent` to the side panel (it has no curated record page); everything else, including `workflowRun`, follows the member preference - Apps can set it in `defineObject()` via the object manifest **Member level** - New `openRecordIn` standard field on `workspaceMember`, persisted through the existing settings path (same as `colorScheme`) and exposed in Settings > Experience **View level (deprecated)** - `view.openRecordIn` is no longer read or written by the frontend; the "Open in" entry is gone from the view options dropdown - The column, DTO field and inputs are kept for one release for API compatibility: the output field carries a `deprecationReason`, the inputs keep accepting the value with a `Deprecated:` description (NestJS silently drops input fields that have a `deprecationReason`, which would have been a breaking change) **Upgrade (2.27)** - Fast instance command adds the `objectMetadata.openRecordIn` column defaulting to `USER_CHOICE` - Workspace command adds the `workspaceMember.openRecordIn` field - Workspace command seeds the object column from the standard definitions (any non-`USER_CHOICE` value), then lifts deliberate per-view record page choices onto objects the definitions don't pin **Debt removed** - `canOpenObjectInSidePanel` hardcoded object list and its test - `ObjectOptionsDropdownLayoutOpenInContent` and the `layoutOpenIn` dropdown wiring - `DefaultViewOpenRecordIn` - Context-store/view-based resolution in `useResolveOpenRecordIn` (now reads object metadata + member + viewport) - Front components no longer guess from the current view: an explicit side-panel call honours a pinned object and the viewport, nothing else ## Verification - Ran the three upgrade commands against a live database: column created, the pinned standard objects seeded per workspace (record page pins plus calendarEvent to side panel), member field backfilled to `SIDE_PANEL`; seed rerun is a no-op - Seed command verified on a simulated pre-upgrade workspace (index view set to record page on company): pins the standard objects plus company, idempotent on rerun - Both packages typecheck and lint clean; affected unit suites and the application sync, view creation and metadata cache integration specs pass --------- Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com> |
||
|
|
079e9b8e56 |
feat(dashboard): extend number format option to bar, line and pie charts (#23505)
https://discord.com/channels/1130383047699738754/1509604545381142649 Extends the Format option (Short/Full) added for the Number widget in #21521 to bar, line and pie charts. Format controls the numbers printed on the chart face: data labels and the pie center metric. Axis ticks stay abbreviated and tooltips always show the full value. Defaults to Short, so existing charts render unchanged. Server: nullable `numberFormat` on the bar/line/pie configuration DTOs, exposed in the dashboard AI tool schema. No migration, configuration is jsonb. Deferred: - The Format row has no visible effect while data labels are off, since tooltips are always full. - Number widget format defaults differ by field type (CURRENCY defaults to Short, NUMBER to Full). Pre-existing, untouched here. https://github.com/user-attachments/assets/0778f08a-6681-4e7a-8716-fb3026d1e01f <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23505?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
72322a4d72 |
feat: Slack conversational assistant (#22984)
## Summary Lets workspace members talk to the Twenty CRM agent from Slack — `@mention` the bot in a channel or DM it, and it answers in-thread using the `slack-assistant` agent and its assigned role. ## How it works Slack Events webhook → app route verifies signature → **ack in <3s** and enqueue a `slackAssistantRequest` → worker posts a placeholder immediately, then fetches recent thread/DM history (excluding the current message and placeholder), runs `runAgent`, and updates the placeholder with the answer. After a successful reply, the thread stays subscribed (24h TTL, renewed on each reply) so follow-ups work without re-mentioning. ## App-owned orchestration Protocol + orchestration live in `twenty-apps/public/twenty-slack` (events resolver, enqueue, worker, team claim KV, thread subscription). The server provides shared primitives (app routes, `runAgent`, app KV, connection OAuth). ## Notes - Agent role is bound via `roleUniversalIdentifier` on install. Default **Slack Assistant** role: read/create/update/soft-delete on people, companies, opportunities, notes, and tasks; **workspace members stay read-only**; hard destroy stays off. Admins can tighten the role in Settings. - Setup (signing secret, event subscriptions, scopes) is in the app README. - Long-lived Slack bot tokens (no refresh token) are treated as non-expiring. - Multi-turn: recent Slack thread/DM messages are prepended into the agent prompt. - Replies are non-streaming for now (placeholder + final `chat.update`); progressive streaming is a follow-up. ## Follow-ups - **Streaming replies** — progressive edits while the agent runs. - **Per-user / per-channel permissions** — Slack→Twenty user mapping and optional channel rules (open by default; admins can narrow). - **Other platforms** — Discord/Teams can reuse the same patterns; only Slack protocol is in this PR. ## Screenshots https://github.com/user-attachments/assets/3a72770a-93fa-411d-b4aa-2f741afbcee1 <img width="426" height="686" alt="Screenshot 2026-07-27 at 3 58 38 PM" src="https://github.com/user-attachments/assets/b0a62e7c-c5e4-4c96-9389-5e47d7ef8c77" /> <img width="1053" height="726" alt="Screenshot 2026-07-29 at 12 54 45 AM" src="https://github.com/user-attachments/assets/4e14b3fb-fbe5-4f4d-a380-cc45cc60a01a" /> |
||
|
|
a276f3277f |
feat(workflow): pin concrete model on AI agent node creation and exclude interactive tools from workflow runs (#23447)
## Context The AI Agent workflow node's model dropdown could show a model that was not the one used at run time (e.g. the node displayed "Claude Haiku 4.5" while the run log showed `openai/gpt-5.6-sol`). Root cause: workflow agents were created with `modelId: AUTO_SELECT_SMART_MODEL_ID`. The builder's model `Select` cannot represent that value — auto-select ids are filtered out of the options (`useWorkspaceAiModelAvailability`) and the pinned "default" option remaps its value to the resolved concrete model id (`useAiModelOptions`) — so `Select` silently fell back to `options[0]`, the alphabetically first enabled model. Meanwhile the runtime correctly resolved auto-select to the instance's default smart model. ## What this PR does ### 1. New workflow agents store a concrete model id `WorkflowVersionStepOperationsWorkspaceService` now reads the workspace's `fastModel` setting, expands it through `AiModelRegistryService.getEffectiveModelConfig`, validates it with `validateModelAvailability`, and stores the concrete model id — so the dropdown displays the model that will actually run, and workflow agents default to the cheaper fast tier instead of the smart one. Falls back to `AUTO_SELECT_FAST_MODEL_ID` if the lookup or validation fails (workspace missing, no AI provider configured, model disabled), so node creation never breaks. ### 2. Exclude `search_help_center` and `navigate_app` from workflow agent runs `ActionToolProvider` adds both tools unconditionally, but they only make sense in an interactive chat session (navigation targets the user's browser; help-center search is a support tool). They are now excluded via `WORKFLOW_AGENT_EXCLUDED_TOOL_NAMES` in `AgentAsyncExecutorService`, alongside the existing output-navigation exclusions. Chat agents are unaffected. ## Test coverage - Existing specs for `WorkflowVersionStepOperationsWorkspaceService` and `AgentAsyncExecutorService` updated/passing (new constructor deps mocked). - `nx typecheck twenty-server` and `lint:diff-with-main` pass. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23447?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
742f76e318 |
[BREAKING-CHANGE] Add NOT_RECORDED call recording status (#23478)
Adds NOT_RECORDED to the CallRecording status select, for meetings where nothing was captured (bot never admitted, meeting not started, nobody joined). First part of twentyhq/core-team-issues#2706, split out so existing workspaces are upgraded before the call-recorder app starts writing the new status. - NOT_RECORDED enum value + standard select option - 2-26 workspace upgrade command adding the option to existing workspaces (idempotent, same option id as the standard definition) App-side classification from Recall sub codes comes in a follow-up PR. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23478?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
198e3969df |
feat(workflow): read workflow version content from core in the engine, behind a flag (#23403)
## Scope: engine only Behind the existing `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` flag (**off by default**), `getWorkflowVersionOrFail` sources a version's `trigger` / `steps` / `status` from `core.workflowVersion` instead of the workspace row. That covers its 11 call sites: workflow build, validation, schema, run, and trigger dispatch. **The UI is not switched here.** `useWorkflowVersion` and the content fetch in `useWorkflowWithCurrentVersion` still go through generic object CRUD against the workspace columns. That work needs the client to stop treating the workspace record as the home for content, and is planned separately around `flowComponentState` (the jotai state the builder already reads from). It is the read that must land before the workspace `trigger` / `steps` columns can be dropped. ## Why an overlay, not a repository swap `core.workflowVersion` is a content-only projection: its own `id` (not the workspace version id), `workflowId`, `triggers[]`, `steps[]`, `status`. No `name`, no `position`. So core cannot fully back the entity. The flip is therefore an overlay: identity, name and position stay from the workspace row, and only content comes from core (`triggers[0] -> trigger`). ## Reversibility Falls back to workspace content when the flag is off (default), when the version has no `coreWorkflowVersionId` soft-ref, or when the core row is missing. Merging changes nothing until the flag is enabled per workspace, and it can be flipped back at any time. ## Verification - `nx typecheck twenty-server` green, `oxfmt` + `oxlint --type-aware` green - Unit test covering four branches: flag off, flag on with the core row present (overlays content **and** preserves `id` / `name` from the workspace row), flag on with the core row missing (falls back on `trigger`, `steps` and `status`), flag on with no soft-ref (skips the core read) ## Enablement gate Do not enable the flag in any workspace until the drift dashboard reports zero drift for `core.workflowVersion` and legacy drift is repaired. Enabling before that turns latent drift into live dispatch behaviour. |
||
|
|
6c66d4c862 |
fix(ai-agent): use a non-workflow base system prompt for programmatic agent runs (#23394)
`AgentAsyncExecutorService` hardcoded `WORKFLOW_SYSTEM_PROMPTS.BASE`, so every caller was told "You are executing as part of a workflow automation" and "your output may be used by downstream workflow nodes". That is only true for the workflow AI-agent action. The `runAgent` API (used by apps such as the call recorder) and agent evaluations got the same framing, which does not describe how they run or where their output goes. The executor no longer asserts its own execution context: `executeAgent` now takes a required `baseSystemPrompt` and each caller supplies its own. - Workflow AI-agent action passes `WORKFLOW_SYSTEM_PROMPTS.BASE` (unchanged behavior) - `runAgent` and evaluations pass the new `AGENT_RUN_BASE_SYSTEM_PROMPT` The param is required rather than defaulted so every call site states its context and no future caller silently inherits the wrong one. Prompt constants are also split one export per file, with the shared tool-usage guidance extracted into `TOOL_USAGE_STRATEGY` so both bases compose it. No GraphQL schema, SDK, or database changes. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23394?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
1e58c3073c |
Feat/email composer improvements (#23188)
- Move composer to dedicated page - Add test email option - Auto saved as draft can be revisited from `objects/messageCampaigns` later - Campaign stats component https://github.com/user-attachments/assets/9e523116-e79b-496d-9c9d-3887e0c9213f <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23188?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
74260a161d |
fix(ai): stop double-counting cache-creation tokens in reported token totals (#23405)
## Context Under AI SDK v6 usage normalization, `usage.inputTokens` is the **full prompt**: fresh (noCache) + cache-read + cache-creation tokens. Our `totalTokens` formulas still added `cacheCreationTokens` (extracted from provider metadata) on top of `inputTokens` — a leftover from the pre-v6 SDK generation, where flat `inputTokens` excluded cache tokens. The v6 upgrade changed the semantics under the formula's feet, so every Claude run using prompt caching reported a `totalTokens` inflated by exactly `cacheCreationTokens`. ## Evidence, traced through AI SDK source **1. The Anthropic provider folds cache tokens into `inputTokens`.** The raw Anthropic API reports `input_tokens` *excluding* cache tokens; the provider sums all three components — [`convertAnthropicMessagesUsage`, `@ai-sdk/anthropic@3.0.84`](https://github.com/vercel/ai/blob/%40ai-sdk/anthropic%403.0.84/packages/anthropic/src/convert-anthropic-messages-usage.ts): ```ts inputTokens: { total: inputTokens + cacheCreationTokens + cacheReadTokens, noCache: inputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheCreationTokens, } ``` **2. ai core surfaces that total as the app-visible `usage.inputTokens`** — [`asLanguageModelUsage`, `ai@6.0.97`](https://github.com/vercel/ai/blob/ai%406.0.97/packages/ai/src/types/usage.ts): ```ts inputTokens: usage.inputTokens.total, ... totalTokens: addTokenCounts(usage.inputTokens.total, usage.outputTokens.total), ``` So the SDK's own `totalTokens` is already "full prompt (incl. cache read + creation) + output". **3. The value we were adding on top is the same one already inside `inputTokens`.** The provider also exposes the raw API field in metadata (`@ai-sdk/anthropic` dist): ```ts const anthropicMetadata = { usage: response.usage, cacheCreationInputTokens: response.usage.cache_creation_input_tokens ?? null, ... ``` `extract-cache-creation-tokens.util.ts` reads exactly `providerMetadata.anthropic.cacheCreationInputTokens` — the same `cache_creation_input_tokens` that step 1 already folded into `inputTokens.total`. Adding it again counts it twice. **Worked example** (matches the new pinning test): API returns `input_tokens: 400, cache_read_input_tokens: 600, cache_creation_input_tokens: 200, output_tokens: 500` → app sees `usage.inputTokens = 1200`, `providerMetadata.anthropic.cacheCreationInputTokens = 200` → old formula reported `1200 + 500 + 200 = 1900`; actual tokens processed: `1700`. All snippets are verbatim from the version tags in `vercel/ai` and match the installed `node_modules` dists. ## Provider independence `inputTokens + outputTokens` is correct for every provider Twenty routes through, not just Anthropic: - The v3 provider spec (`@ai-sdk/provider`) defines `inputTokens.total` as "the total number of input (prompt) tokens used", with `noCache`/`cacheRead`/`cacheWrite` as its components — and all 8 installed provider packages comply (verified in dists): `anthropic` and `amazon-bedrock` sum the components explicitly ([`convertBedrockUsage`, `@ai-sdk/amazon-bedrock@4.0.117`](https://github.com/vercel/ai/blob/%40ai-sdk/amazon-bedrock%404.0.117/packages/amazon-bedrock/src/convert-bedrock-usage.ts): `total: inputTokens + cacheReadTokens + cacheWriteTokens`); `openai`, `azure`, `google`, `mistral`, and `openai-compatible` pass through wire values that already include cached tokens; `xai` even detects which wire convention the API used and normalizes either way. - The removed `cacheCreationTokens` term was already 0 for every provider except Anthropic/Bedrock (`extract-cache-creation-tokens.util.ts` only reads those two metadata namespaces), so this PR is a strict no-op for OpenAI-style providers and only removes the double-count where it existed. Caveat: a custom `AI_PROVIDERS` entry pointing at a legacy V2-spec provider package bypasses this normalization (ai core's shim passes flat usage through verbatim); that path could misreport under any formula, and none of the built-in providers use it. ## What changed Four sites computed the inflated total: - `ai-billing.service.ts` — `quantity` on the emitted AI token usage event - `chat-execution.service.ts` — chat-turn usage event - `agent-async-executor.service.ts` — workflow-agent usage event - `build-ai-agent-step-log.util.ts` — workflow step log (display) The first three now compute `totalTokens = inputTokens + outputTokens`; the step-log util uses the SDK's `usage.totalTokens` directly (it receives the `generateText` usage object, where the field is guaranteed). The explicit sum is used where usage objects are hand-assembled or merged — e.g. the streaming path in `stream-agent-chat.job.ts` builds usage literals with no `totalTokens` field at all, so `usage.totalTokens ?? 0` would silently emit 0. Both forms are definitionally identical where the SDK object exists, since ai core computes `totalTokens` as `input + output` (see evidence above). **Impact: reported/analytics quantities only.** Billed credits (`creditsUsedMicro`) come from `computeCostBreakdown`, which already handles the cache-inclusive convention correctly and is unchanged. **Ops note:** `usageEvent.quantity` for cache-heavy workspaces steps down on deploy — dashboards trending this metric may want an annotation. Historical rows are not backfilled (per-row component fields aren't stored, so mixed-era rows can't be reliably corrected). ## How tested - Updated `build-ai-agent-step-log.util.spec.ts` expectation (155 → 150 with `cacheCreationTokens: 5` still present) - New pinning test in `ai-billing.service.spec.ts`: emitted `quantity` is 1700 (not 1900) for inclusive Anthropic usage with `cacheCreationTokens: 200` - New pinning test in `agent-async-executor.service.spec.ts`: emitted total is 150 (not 180) when steps carry `providerMetadata.anthropic.cacheCreationInputTokens` - 3 suites / 20 tests pass; oxlint, oxfmt, and `nx typecheck twenty-server` clean <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23405?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
2c49c4169c |
feat(workflow): remove async workflowVersion core dual-write listener (#23374)
## What Removes `WorkflowVersionCoreDualWriteListener` (and its now-empty module), replacing the last async best-effort core writes for workflow versions with synchronous mirrors. Adds one missing synchronous funnel so nothing is left uncovered. ## Why After #23356, the listener's `handleRestored` / `handleDeleted` / `handleDestroyed` handlers are redundant with the synchronous lifecycle mirror, so the async path (which can silently drift on failure) can go. While removing it I found one path the listener was **not** redundant on: **direct `deleteOneWorkflowVersion` (discard draft)** is an allowed operation (discard a DRAFT version that isn't the only version, via the `DISCARD_DRAFT_WORKFLOW` command) and had **no** synchronous post-hook. The async listener was the sole thing deleting its core row. Removing the listener without a replacement would have drifted on every draft discard. So this PR also adds a `workflowVersion.deleteOne` post-hook that mirrors the deletion to core. ## Coverage after this change | version lifecycle path | synchronous coverage | | --- | --- | | `deleteOneWorkflowVersion` (discard draft) | **new** `workflowVersion.deleteOne` post-hook | | delete via workflow cascade | `handleWorkflowSubEntities` -> `deleteCoreVersionsByWorkflowIds` (#23356) | | restore via workflow cascade | `handleWorkflowSubEntities` -> `recreateCoreVersionsByWorkflowId` (#23356) | | destroy via workflow | `workflow.destroy*` post-hooks (#23356) | | `deleteMany` / `destroyOne|Many` / `restoreOne|Many` version | blocked by pre-hooks ("Method not allowed") | ## Notes - The new post-hook re-fetches the version (`withDeleted`) to resolve its `coreWorkflowVersionId`, because the delete post-hook payload only carries the columns the client selected (the delete `RETURNING` set is built from `selectedFieldsResult.select`), so `coreWorkflowVersionId` is not reliably present. - `deleteCoreVersionsByWorkspaceVersionIds` deletes precisely by `coreWorkflowVersionId` (not by `workflowId`), so discarding one draft does not touch the core rows of the workflow's other versions. - The workflow-side `WorkflowCoreSyncModule` listener is intentionally left in place (separate migration track). ## Verification - `nx typecheck twenty-server` green - `nx lint:diff-with-main twenty-server` green - Added integration test `workflow-version-discard-draft-core-mirror`: activate v1, create a draft, discard it, assert only the draft's core row is removed and the active version's core row remains. - Live run on a dev instance still pending. ## Merge gate Per the migration plan, removing the async backstop should land only after the drift cron reports zero drift over a soak period. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23374?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
79bf20515d |
feat(workflow): mirror version delete/restore/destroy to core transactionally (#23356)
Next step in the workflowVersion -> core soft-ref migration. The transactional mirror (#23243) made **content** writes (create/update) drift-free. This does the same for the **lifecycle** events (delete / restore / destroy), which were still handled only by the async best-effort listener. It's the prerequisite for dropping that listener. ## What changed `delete` and `restore` already soft-delete / restore the workflow's versions inside `handleWorkflowSubEntities` (twenty doesn't cascade soft-deletes, so it does each sub-entity explicitly). So the core delete/recreate just sits next to the existing version write: - **delete** — after `workflowVersionRepository.softDelete({ workflowId })`, `deleteCoreVersionsByWorkflowIds` removes the `core.workflowVersion` rows (`workflowId IN (...)`). (`deactivateVersionOnDelete` no longer re-mirrors the deactivated version — that was recreating the core row it just deleted; it only flips the workspace status to `DEACTIVATED` so a restore comes back deactivated.) - **restore** — after `workflowVersionRepository.restore({ workflowId })`, `recreateCoreVersionsByWorkflowId` re-reads the restored versions and reuses the existing `upsertToCore` (which reuses the stored `coreWorkflowVersionId` soft-ref, so rows come back with their original ids and current status). - **destroy** — version destroy isn't done in `handleWorkflowSubEntities` (it happens via the generic cascade), so there's no existing place to hang the core delete. New `workflow.destroyOne`/`destroyMany` **post**-hooks call `deleteCoreVersionsByWorkflowIds` (batched `IN`) only after the destroy commits, so a rejected destroy can't remove core rows while the workspace versions survive. No new transactional wrappers or raw SQL — the delete/recreate reuse the existing `WorkflowVersionCoreSyncService` methods (`deleteFromCore`-style delete, `upsertToCore`). The async listener stays as an idempotent backstop until the cron soaks zero drift. ## Async listener kept as backstop `handleRestored` / `handleDeleted` / `handleDestroyed` stay for now. Both paths are idempotent (delete-of-deleted is a no-op; upsert converges), so they don't conflict. Those handlers come out in a follow-up once the consistency cron soaks zero drift - which this PR unblocks. ## Verification Lifecycle integration test — creates a workflow, **activates** the version (the active path is where the delete re-mirror bug bit), then asserts the core row: present -> gone after delete -> back after restore (as `DEACTIVATED`, same id) -> gone after destroy. Plus the existing `workflow-resolver` delete/restore suite (regression, since `handleWorkflowSubEntities` is shared). Also verified **live** on a running instance against the real DB: the full active-version lifecycle above, plus a batched `destroyWorkflows` on two workflows removing both core rows in one `IN` delete. `nx typecheck` + oxlint + oxfmt clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23356?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
0c57d7c108 |
perf: reuse Google webhook OAuth client (#23361)
## Context A new client currently downloads signing certificates for every webhook ## Fix reuse same OAuth client instance ## Impact Probably small but not really risky to merge imho |
||
|
|
46a3a83866 |
feat(workflow): mirror all workflowVersion writes to core in-transaction, drop async create/update dual-write (#23243)
Switches the workflowVersion -> core dual-write from the async,
best-effort listener to a **transactional mirror**, wires every
content-write funnel through it, and drops the async create/update
handlers. Core can no longer drift from the workspace on any covered
path: the core copy commits or rolls back atomically with the workspace
write.
## Helper (`WorkflowVersionCoreSyncService`)
Two entry points, both writing `core.workflowVersion` and stamping the
`coreWorkflowVersionId` soft-ref on the **caller's transaction
manager**:
- `writeWorkflowVersionAndMirror(workspaceId, write)` - for funnels that
don't own a transaction. Opens a workspace queryRunner, runs the
caller's workspace write on that manager, re-reads the row, mirrors to
core in the same tx, commits, then invalidates the trigger-map cache
post-commit.
- `mirrorWorkflowVersionWrite({ workspaceId, entityManager,
workflowVersion })` - for funnels that already own a queryRunner tx
(activation / deactivation / delete-cascade); they just gain this one
call on their existing manager before commit.
`invalidateAutomatedTriggerMaps` is public so tx-owning callers run it
post-commit.
## Funnels wired (all content writes now mirror in-transaction)
- `updateWorkflowVersionStepsAndTrigger` (central builder step/trigger
edit)
- edge create/delete (4 trigger/step writes)
- `createDraftFromWorkflowVersion` (update + insert),
`duplicateWorkflow` (content update), `updateWorkflowVersionPositions`
- iterator / if-else empty-node step writes
- `workflow.createOne` / `createMany` post-hooks (v1 draft insert)
- AI `create_complete_workflow` tool (v1 insert)
- activation / deactivation status writes (ACTIVE / ARCHIVED /
DEACTIVATED) on their existing tx
- `deactivateVersionOnDelete` cascade (ACTIVE -> DEACTIVATED) on its
existing tx
Direct `workflowVersion.createOne/createMany` is forbidden by a
pre-hook, so there is no un-funneled create path.
## Async listener trimmed
`handleCreated` and `handleUpdated` are removed: every create/update now
mirrors in-transaction, so the post-commit handlers were redundant and
were the source of the rollback-drift (an edit that rolls back still
emitted an `UPDATED` event carrying the uncommitted payload, which the
async listener wrote to core).
`handleRestored`, `handleDeleted`, `handleDestroyed` are **kept**.
Soft-delete/restore go through the generic ORM (not a funnel):
`handleDeleted` drops the core row on soft-delete, and `handleRestored`
recreates it on restore (the restore path just calls
`workflowVersionRepository.restore()`). Removing the restore handler
would leave restored versions with no core row, so the delete/restore
pair stays async.
## Why the core write is raw SQL
`core.workflowVersion` is on the core DataSource, not the workspace
DataSource, so a repository can't be pointed at it from the workspace
queryRunner's manager. But both schemas are one Postgres DB and a
queryRunner is a single connection, so a schema-qualified `INSERT INTO
core."workflowVersion" ... ON CONFLICT` on that manager participates in
the workspace transaction (the prefill util's pattern). The workspace
write and soft-ref write-back go through the ORM's
`repository.update(criteria, data, undefined, queryRunner.manager)`, so
the source-of-truth workspace write keeps its ORM machinery (actor
stamping, search vector, events); only the dumb core mirror is raw,
confined to the helper.
**jsonb:** the raw insert has no entity transformer, so
`triggers`/`steps` are passed as JSON strings (Postgres parses them) -
the inverse of the prefill core insert (the #23204 double-encode trap),
covered by the test. `universalIdentifier` is minted only for a new
link; on conflict only `triggers`/`steps`/`status` are updated.
## Test
In-process integration test: opens a workspace queryRunner, calls the
helper, asserts the core row is visible inside the tx with correct
native jsonb, rolls back, asserts the core row is gone - empirically
confirming one workspace queryRunner writes `core.*` in the same tx.
## Review feedback addressed
- **Duplicate atomicity:** `duplicateWorkflow` now inserts the draft
version with its final steps/trigger inside
`writeWorkflowVersionAndMirror`, so a mirror failure rolls back the
whole version instead of leaving an unmirrored empty draft.
- **Delete cascade:** `deactivateVersionOnDelete` moved its command-menu
cleanup after commit, so a rolled-back deactivation can no longer strip
the menu item from a still-active version.
- **Rollback drift / unlinked rows:** resolved structurally by the
in-transaction mirror + removal of the async CREATED/UPDATED handlers,
and by the soft-ref-field guard that skips mirroring (returns null) on
workspaces without `coreWorkflowVersionId`.
- **Unit suites:** the step-helpers, step-operations and edge suites now
provide a `WorkflowVersionCoreSyncService` mock whose
`writeWorkflowVersionAndMirror` runs the write callback against the test
repo; all three pass (35 tests).
## Verification
Rebased onto `origin/main`. `nx typecheck twenty-server` is green, the
three affected unit suites pass (35 tests), and every changed file
passes type-aware oxlint + oxfmt. Integration suites were failing only
on behind-main DB-init drift (`core.keyValuePair` / data-migration),
which the rebase resolves. Live end-to-end test on a running instance
still pending.
|
||
|
|
42c598a33a |
Filter stale sync workspaces by active channels (#23321)
## Context The hourly calendar and messaging stale-sync crons currently load every active workspace and enqueue one recovery job per workspace. Each recovery job then enters the workspace context and queries its channels, even when that workspace has no stale sync. As the number of workspaces grows, the cost of this check grows with every workspace rather than with the number of syncs that actually need recovery. Because both crons run on the hour, this also creates a synchronized burst of mostly unnecessary queue, database, and workspace-context work. ## What changes The crons now use TypeORM repository `find` operations on `calendarChannel` and `messageChannel` before enqueueing recovery jobs: - Select only `workspaceId` from matching channels. - Keep only active, non-deleted workspaces. - Keep only the scheduled and ongoing stages handled by the existing recovery jobs. - Treat a sync as stale when `syncStageStartedAt` is null or older than the existing 30-minute timeout. - Deduplicate workspace IDs in memory before enqueueing. - Enqueue the existing recovery job only for matching workspaces. - Share the stage definitions between candidate selection and recovery so they cannot drift independently. ## Before and after Before: 1. Load every active workspace. 2. Enqueue a calendar job and a messaging job for each workspace. 3. Enter every workspace context. 4. Query its channels. 5. Usually find nothing to recover. After: 1. Run one candidate lookup for calendar channels and one for message channels. 2. Return only the workspace ID for each potentially stale channel. 3. Deduplicate those IDs and enqueue one job per affected workspace. 4. Let the existing recovery jobs recheck and recover those channels. Database reads now scale with stale channel candidates, while queue and workspace-context work scale with affected workspaces rather than the total workspace count. ## Why deduplicate in memory TypeORM repository find options do not provide `DISTINCT`, so these lookups can return the same workspace ID once per stale channel. A `Set` removes those duplicates before jobs are created. This is a deliberate tradeoff: - The database returns only UUIDs, not full channel records. - The scans run hourly against channel tables, and stale candidates should remain sparse. - It keeps the query expressed through the typed repository API. - It avoids adding a database sort or hash aggregation for `DISTINCT`. If candidate volume becomes large enough for result transfer or in-process deduplication to matter, database-side deduplication can be moved into a dedicated repository query. A practical signal would be tens of thousands of candidates per run, a high duplicate ratio, or measurable lookup and event-loop latency. ## Safety - The recovery jobs and sync-state transitions are unchanged. - The candidate lookup uses the same stage lists and timeout constants as the recovery jobs. - Recovery jobs still recheck staleness after dequeueing, which protects against a channel recovering between candidate selection and execution. - Jobs are still enqueued sequentially with per-workspace exception handling. - Disabled and group channels are not newly excluded, preserving the previous recovery behavior. ## Scope This only changes hourly stale-sync detection. It does not change normal calendar or messaging scheduling, imports, retry policies, or recovery state transitions. ## Testing Added unit coverage for: - active and non-deleted workspace filtering, - selecting only workspace IDs, - scheduled and ongoing stage filtering, - null or expired `syncStageStartedAt`, - the configured stale timeout, - application-side workspace deduplication, - enqueueing one recovery job per affected workspace. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23321?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
3fb29db28a |
Feat/email settings v2 (#23180)
Settings pages changes - Add `displayName` - Unsubscribers Page <img width="1496" height="844" alt="Screenshot 2026-07-22 at 8 52 15 PM" src="https://github.com/user-attachments/assets/69bc1993-4547-4a64-83a6-b47fef1a4e40" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23180?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
cbcfba0de2 |
feat(workflow): dedicated updateWorkflowVersionTrigger mutation + close version CRUD holes (#23207)
Prerequisite for the workflow-core soft-ref migration: every
`workflowVersion` content write must go through a dedicated,
draft-guarded server mutation so it can later be wrapped in a
transactional core mirror. This closes the generic-CRUD holes that let
writes bypass that path.
## Part A - dedicated `updateWorkflowVersionTrigger` mutation
The builder saved a version's trigger through generic
`updateOneWorkflowVersion` - the only content write not going through a
dedicated mutation. Added:
- Server: `updateWorkflowVersionTrigger(input: { workflowVersionId,
trigger })` resolver +
`WorkflowVersionStepWorkspaceService.updateWorkflowVersionTrigger`,
draft-guarded via `getValidatedDraftWorkflowVersion` then
`updateWorkflowVersionStepsAndTrigger` (reuses existing write logic).
- Front: `useUpdateWorkflowVersionTrigger` now calls the dedicated
mutation instead of `useUpdateOneRecord`.
## Part B - restrict generic `updateOneWorkflowVersion`
`validateWorkflowVersionForUpdateOne` previously allowed writing
`trigger`, `position`, `workflowId` (re-parenting),
`coreWorkflowVersionId`, and let `steps: null` slip through on a draft.
It now rejects any update that sets `steps`, `trigger`, `status`,
`workflowId`, or `coreWorkflowVersionId`, or that clears the `name`,
while still allowing a plain rename. (A name-only allowlist was tried
first but blocked legitimate renames - at the pre-hook the generic
update payload is not single-key - so it was replaced by this denylist,
verified live.)
## Part C - close the destroy/restore hole
`workflowVersion` had no `destroyOne/destroyMany/restoreOne/restoreMany`
query hooks, so a caller with object permission could hard-destroy any
version (including active) or resurrect one with no validation. Added
pre-hooks that forbid all four via the API ("Method not allowed"),
matching the existing forbidden generic mutations (`createOne`,
`deleteMany`, ...). Rationale: there is no legitimate API use for
standalone version destroy/restore - retention purging happens through
the trash-cleanup cron (internal, not hook-gated) and restore happens
through the workflow-restore cascade or create-draft-from-version.
## Tests
Integration specs that set a trigger through the generic mutation were
migrated to the new `updateWorkflowVersionTrigger` mutation (new
`update-workflow-version-trigger.util.ts`). Unit test for the front hook
updated.
## Verification
- `twenty-server` + `twenty-front` typecheck: clean.
- oxlint + oxfmt on all changed files: clean.
- `graphql.ts` regenerated for the new mutation; its types match the
server DTOs exactly. Local `graphql:generate` introspects a running
server, so it only succeeds against a server built from this branch - CI
regenerates against the PR server and verifies.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23207?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: prastoin <paul@twenty.com>
|
||
|
|
d1c6b8ee72 |
Show relation record labels instead of UUIDs in dashboard charts (#23163)
https://github.com/user-attachments/assets/d012a013-2c90-49a1-a27e-b8e4b684a84f Charts grouped by a relation without a sub-field rendered raw FK UUIDs on axis ticks, legends and tooltips. The server now batch-resolves the grouped record ids to their label identifier through a permission-scoped query and formats every bucket with the record's display name. Unresolvable records (deleted or not readable) render as Unknown and their ids are stripped from the response payload. Same-named records get an ordinal suffix so their buckets don't merge. Covers bar, line and pie, plain and morph relations. ```mermaid flowchart TD A["Dashboard widget load"] --> B["Chart data service<br/>(bar / line / pie)"] B --> C["executeGroupByQuery:<br/>group by relation FK id,<br/>ORDER BY target label identifier,<br/>scoped to source object permissions"] C --> D["filterOutEmptyChartBuckets"] D --> E{"Bare relation axis?<br/>(no sub-field)"} subgraph RL["ChartRelationLabelService.resolveRelationLabels"] direction TB G1["Collect distinct record ids<br/>per target object"] --> G2["Batch SELECT label identifier columns,<br/>scoped to TARGET object permissions"] G2 --> G3["buildRawLabelByRecordId:<br/>display name per record"] G3 --> G4["buildUniqueRelationLabels:<br/>suffix duplicates, Unknown for unresolved"] end E -- No --> H["formatDimensionValue per bucket"] E -- Yes --> G1 G4 --> H H --> I["Strip unresolved ids from<br/>formattedToRawLookup"] I --> J["Chart DTO to frontend"] ``` The chart settings sub-field dropdown gains a Record option to group by the related record itself, and now only offers sub-fields the backend accepts (system fields like a workspace member's updatedBy were selectable but rejected at query time). Chart-data errors are now logged server-side. Also fixes two latent bugs on this path: sorting a bare-relation chart by field threw `Cannot orderBy unknown field: agentId`, and the pie chart truncated slices before sorting. The AI dashboard tool guidance and the seeded dashboards no longer force the sub-field workaround. The group-by query orders buckets by the related record's label identifier at the database level (the engine now accepts ordering by a target field when grouping by its id), so with more than 100 distinct related records the surviving buckets match the label order. |
||
|
|
96a2456367 |
feat(workflow): periodic core-consistency check for workflows, versions and triggers (#23103)
Monitoring for the soft-ref migration. The `workflow` /
`workflowVersion` dual-write into core is **best-effort** (async, not
transactional; failures only go to Sentry), so `core.workflow` /
`core.workflowVersion` can silently drift from the workspace source of
truth. This adds a periodic job that detects that drift across **all
three workflow entities** and emits it as metrics.
Supersedes the earlier inline shadow-parity approach that lived on this
branch — that only covered trigger dispatch and added a cache read +
diff to every cron tick and every DB-event batch (too much hot-path
overhead). This is broader and fully off the dispatch path.
## What
A cron (`cron:workflow:core-consistency-check`, every 3 hours, wired
into `cron:register:all`). Per run:
- **Bounded**: `SELECT DISTINCT "workspaceId" FROM core."workflow"` —
only workspaces that actually use workflows (skips the large majority).
- Per such workspace, emit a drift metric per `(entity, driftType)` —
detect-only, plus a triage log:
- **workflow** and **workflowVersion** — `unlinked` / `missingCore` /
`orphanCore` / `fieldMismatch`, via cross-schema `COUNT` aggregates
(core + workspace are the same DB, so indexed joins — no rows pulled
into JS).
- **automated triggers** — the `workflowAutomatedTrigger` table vs the
`workflowAutomatedTriggerMaps` cache: `inTableNotCache` /
`inCacheNotTable` / settings `mismatch`.
- Per-workspace failures are isolated (caught → Sentry) so one bad
workspace does not stop the sweep.
## Why it is efficient
Two central `core.*` queries + a few `COUNT` queries per
*workflow-using* workspace, on a relaxed cadence, off the dispatch path.
Shardable across ticks later if needed.
## Metrics
`workflow-core-consistency/{workflow,version,automated-trigger}/drift`
counters, attribute `driftType`. Dashboards: twentyhq/twenty-infra#800.
## Not in scope
Detect-only — no auto-heal (the existing backfill/rebuild command can
heal). No dispatch or flag changes.
## Test
- The consistency SQL (workflow/version sync counts, orphan counts,
trigger read) validated against a live workspace (it surfaced real drift
there — unlinked versions + an orphan core version). The whole
cron→service→SQL→metric pipeline is proven live: the cron is already
emitting real drift counters on a running server.
- Unit specs for the service (clean → no metric; per-entity drift per
dimension; per-workspace error isolation).
- Command boots and registers via `cron:register:all` (verified).
Typecheck + lint clean.
|
||
|
|
0489d39f2b |
fix(messaging): stop group and bulk emails from creating contacts (#23137)
Group addresses were only checked against the message sender, so anything arriving as reply-to or cc slipped through and became a contact. Now checked per participant at contact creation. The group word list can't keep up with real senders (posts-recap@, showinfo@, follow-suggestions@), so bulk-mail headers back it up: `List-Unsubscribe, List-Id, Precedence, Auto-Submitted` (Industry standard headers) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23137?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
578d69e4fb |
Fix update events reporting untouched columns as changed (#22797)
## Before https://github.com/user-attachments/assets/b02dbd50-87c9-4699-a6a9-254a4c8b9182 The image uploaded during the onboarding is deleted and doesn't appear in the animation ## After https://github.com/user-attachments/assets/9443b837-044e-47c6-b2df-c392c238e935 The Image appears in the animation ## Description TwentyORM's `.save()` emits UPDATE events whose `after` record carries default (empty) values for columns that weren't written: TypeORM null-injects untouched nullable columns on the entity in place, and `formatResult` turns those into empty strings. So a partial update (e.g. renaming a workspace member) reports untouched fields like `avatarUrl` and `userEmail` as changed, which is what made the avatar-file-deletion listener delete the picture uploaded during onboarding. The same stale data also reaches webhooks, workflow/logic-function triggers and record subscriptions. Fix: `save()` was the only write path building its event `after` from the in-memory payload instead of re-reading the row. `update()`, `upsert()` and `softDelete()` all re-SELECT after the write, so `save()` now does the same. `withDeleted` is on both the before and the after find, since `save({ id, deletedAt })` and `save({ id, deletedAt: null })` are valid soft-delete and restore, and an asymmetric find would leave a restored row with no matching before-record. Worth noting: a genuinely no-op save now emits no update event, where it previously emitted one with a bogus diff. |
||
|
|
3ad3e8bd1a |
feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context Dashboard view widgets previously only rendered flat tables. This PR ships the full feature: **Table with group-by**, **Kanban**, and **Calendar** layouts for dashboard view widgets — server API + frontend, end-to-end. (Originally staged as a 4-PR stack — #22966, #22967, #22968 — consolidated here per review.) ## Server / API - **View typing.** Adds `KANBAN_WIDGET` and `CALENDAR_WIDGET` to `ViewType` (following the `TABLE_WIDGET` precedent) so widget-backing views keep their layout in `view.type` while staying excluded from record-index pickers. Shared `getViewLayoutFromViewType()` maps widget types to their base layout; `isWidgetViewType()` centralizes the exclusions that were previously hardcoded per-site. - **Migrations.** Two fast instance commands (**2.23**): `ALTER TYPE core.view_type_enum ADD VALUE` for both values, and a widened `CHK_VIEW_CALENDAR_INTEGRITY` constraint covering `CALENDAR_WIDGET` (entity `@Check` updated for fresh installs). - **Validation.** `FlatViewValidatorService` keys kanban/calendar validation on the mapped layout, so widget views get the same invariants as index views (kanban needs a groupable group-by field; calendar needs a date field + layout). Calendar widget views default to month; a non-month (DAY/WEEK) layout is rejected at the API level **unless** the `IS_CALENDAR_WEEK_VIEW_ENABLED` feature flag is enabled for the workspace — the same flag that gates day/week on index calendars. - **API.** `upsertViewWidget` (LAYOUTS permission) accepts a nested `view` settings input (`type`, `mainGroupByFieldMetadataId`, `shouldHideEmptyGroups`, kanban aggregate/column-width, calendar layout/fields). Routes through the standard update path, so `viewGroups` auto-generate from SELECT options exactly like index views. Only widget view types accepted; only `RECORD_TABLE` widgets can change view settings. - **AI tools.** `create-complete-dashboard` + `create_view` now use/allow the `*_WIDGET` types (previously they created plain `TABLE` views that leak into index pickers). ## Frontend **Settings panel.** The **Source** (object) row comes first, since which layouts are available depends on it. The **Layout** row below is a working dropdown (Table / Kanban / Calendar); layouts the source object can't support are **disabled with a hint** ("Needs a Select field" / "Needs a Date field") rather than hidden. Group-by row (select fields; searchable) with a **Hide empty groups** toggle while grouped; **Date field** row replaces Group by while Calendar is active, and — when the `IS_CALENDAR_WEEK_VIEW_ENABLED` flag is on — a **Calendar view** row (Day / Week / Month) appears beside it; **Limit** row hidden while grouped (only the flat virtualized loader enforces it). Kanban keeps its group-by locked (no `None` option). **Instant edit-mode preview.** Draft snapshots carry `viewGroups`; picking a group-by synthesizes them client-side (`buildDraftViewGroupsForFieldMetadataItem`, mirroring the server's generation), so grouped tables/boards preview immediately before dashboard save. On save, `upsertViewWidget` responses hand back the server-generated groups, which replace the client-generated ones in the persisted snapshot. **Renderers.** `RecordTableWidgetRendererContent` branches on the backing view's layout: `RecordBoardWidget` (wraps the standard `RecordBoardContainer`) and `RecordCalendarWidget` (mounts the existing `RecordCalendar`, which renders month / day / week) inside the same per-widget provider sandbox the table uses. **Read-only semantics.** Two flags with distinct scopes, each documented on its state: - `isRecordBoardViewSettingsReadOnlyComponentState` — locks the board chrome that edits view settings (add group, column reorder/resize/menu, aggregates); **card drag still updates records** under object permissions. - `isRecordCalendarReadOnlyComponentState` — widget calendars are read-only by default (no drag, no add-new, no in-calendar layout switch); cards open the side panel. The one exception, behind `IS_CALENDAR_WEEK_VIEW_ENABLED`: a **live (non edit-mode) day/week** widget calendar allows drag-to-reschedule and record creation under object permissions. Month calendars and edit-mode previews stay read-only. **Calendar state componentization.** The calendar module's three settings move from global atoms to component states keyed on `RecordCalendarComponentInstanceContext` (same pattern as record-board), so several calendar widgets and an index-page calendar can coexist without leaking state. All readers resolve the ambient instance; calendar unit tests updated. **Multi-instance fixes that also fix index pages:** record drag states were written against a different instance than every reader resolves (now use the ambient instance); the board sticky-header DOM id is namespaced per board; dragged board cards portal to `document.body` while dragging so react-grid-layout's transforms can't offset the clone from the pointer. ## Scope (v1) - Widget calendars are month-only and read-only by default. With `IS_CALENDAR_WEEK_VIEW_ENABLED` enabled, day/week layouts become selectable (UI + API) and live day/week widget calendars support drag-to-reschedule and record creation under object permissions. - Widget group-by offers SELECT fields only (server auto-generates groups from options; widgets have no per-record add-group flow). ## Tests - Integration: `upsert-view-widget-view-settings.integration-spec.ts` (9 tests — group auto-creation, invalid type/field rejections, non-month calendar widget rejected while the week/day flag is off and accepted once it's enabled, combined settings+fields call); pre-existing `upsert-view-widget` suite (20) green. - Front: new suites for draft view-group generation and snapshot clone/build utils; calendar suites componentized; full `twenty-front` jest, typecheck, oxlint green; `twenty-server` typecheck + lint green. - Browser-verified end-to-end (real dev server + seeded workspace): configure → live edit-mode preview → save → reload for all three layouts; measured drag with pointer inside the card; index-page calendar re-verified (with the week/day flag enabled). https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf |
||
|
|
98c71d7b3d |
fix(server): recover workflow runs whose queue job was lost - monitoring only (#22995)
## Context A workflow run can get permanently stuck in RUNNING when the queue job executing a step dies without failing (worker crash/restart, lost BullMQ job). The step stays `RUNNING` in the persisted state, nothing ever recomputes the run status, and the run never terminates. If a user clicks Stop, it wedges in STOPPING instead (the stuck-STOPPING sweeper from #22900 then catches it after 1h, but only because of the manual stop). Self-hosters also have no way to tell that a job was lost, or when. ## What this PR does ### Detect runs stuck in RUNNING (monitoring only, no finalization yet) New `handleStuckRunningRunsForWorkspace` in the staled-runs sweeper (same cron/job/CLI wiring as the stuck-STOPPING recovery): - Targets RUNNING runs with `updatedAt` older than 1h (`updatedAt` refreshes on every step-info write, so staleness means zero progress). - Skips any run that still has a job in the queue: new `getInFlightJobs` on the message queue driver (active/waiting/waiting-children/paused/prioritized/delayed), matched by run-id-prefixed job id with a `job.data.workflowRunId` fallback for jobs enqueued before this deploys. - A truly orphaned run (orphaned RUNNING step, lost between two steps, failed branch, or finished-but-never-finalized) is **flagged, not finalized**: warn log + `WorkflowRunStuckRunningDetected` metric + entry in a per-workspace cache. - On every subsequent sweep, flagged runs are re-checked. One that ended or got a new queue job on its own is recorded as `WorkflowRunStuckRunningFalsePositive` (warn log with the status it reached) and unflagged. The cron keeps sweeping a workspace as long as it has flagged runs. This validates the detection before it is allowed to act: if flagged runs never resolve on their own (no false positives) while `Detected` counts real incidents, a follow-up PR can turn the flag into an actual finalization (fail with a clear "job lost" error so Retry works). Runs waiting on PENDING steps (delay, form) are never flagged. ### Make queue jobs traceable to their run All RunWorkflowJob dispatches now set the job id prefix to the workflow run id, so BullMQ job ids become `<workflowRunId>-<uuid>`. Worker logs (`Processing job <id>` / `processed`) and Redis job keys are now greppable by run id. A new opt-in `allowDuplicatedPrefixes` queue option bypasses the one-waiting-job-per-id dedup (which would otherwise drop parallel-branch continuations); existing `id` users keep dedup by default. ### Observability - `stalled` worker event listener: warn log + new `JobStalled` metric — emitted when BullMQ detects a job whose worker stopped renewing its lock (i.e. died mid-job). - `WorkflowRunStuckRunningDetected` / `WorkflowRunStuckRunningFalsePositive` metrics as described above. ## Out of scope (follow-ups) - Actually finalizing flagged runs once monitoring shows no false positives. - Recovering lost *delayed* resume jobs (PENDING delay step whose scheduled job vanished). - Persisting job ids on the run entity — unnecessary given derived ids. ## Testing - 11 unit tests for the monitoring sweeper (flagging, id-prefix + data fallback in-flight guards, pending skip, failed-branch precedence, false-positive tracking, still-stuck retention, error isolation, never-finalizes) plus find-options specs; 613 tests pass across workflow and message-queue modules. - Not covered: end-to-end kill-the-worker scenario against a real queue. |
||
|
|
87729a2822 |
feat(workflow): backfill + dual-write for core workflow entity (#22776)
Workflow-side soft-ref sync — the `coreWorkflowId` mirror of the merged version side (#22821 / #22940 / #22944 / #22961). Rebased onto current main; supersedes the original shared-UUID version of this PR. ## What Gives `core.workflow` a per-workspace copy of each workflow (`name`, `lastPublishedVersionId`), soft-reffed from the workspace record via `coreWorkflowId`, so app-shipped workflows have a core home. Does not touch reads/dispatch (that's Phase B). - **Sync service** (`WorkflowCoreSyncService`): core rows get their own id (`uuidv5(workspaceId:recordId)`), the workspace record links via `coreWorkflowId` (written back after the upsert), and the write-back is **guarded** on the `coreWorkflowId` field being present (skips with a warning otherwise — mirrors #22940). Injected repo renamed `coreWorkflowRepository`. - **Dual-write listener** on the `workflow` object: CREATED/UPDATED/RESTORED upsert, DELETED/DESTROYED delete by `coreWorkflowId`. Always-on; failures routed to Sentry so they never break the user write. - **2-20 backfill** (`backfill-workflow-to-core`): reads via the provided `RunOnWorkspaceArgs.dataSource`, upserts each workspace workflow into core. - **2-22 provisioning** (mirrors #22944/#22961): - `add-workflow-core-soft-ref-field`: adds the `coreWorkflowId` system field on existing workspaces (flat-entity legacy migration). - `backfill-workflow-core-links`: full rebuild — per workspace, in one raw-SQL transaction, wipes all `core.workflow` rows, inserts a fresh own-id row per workflow, and re-links every record. (No trigger-map cache to invalidate on `core.workflow`.) Simpler than the version side: `core.workflow` has no one-active-per-workflow index and no trigger-map cache, and it was never backfilled in prod, so there are no legacy shared-id rows. ## Test Fresh `database:reset` + full sequence (2-20 backfill → 2-22 add-field → 2-22 rebuild link): 4/4 workspace records linked via `coreWorkflowId` (id != coreWorkflowId), links resolve, **0 dangling**, no duplicate core rows, names populated. Typecheck + lint + oxfmt clean. |
||
|
|
c04714b9e0 |
fix(messaging): register and harden webhook subscription renewal cron (#23006)
The renewal cron was never wired into cron:register:all, so Gmail/Calendar/Graph watches were never renewed and went dark ~7 days after connect (the max watch lifetime all three providers allow). Register it, fan renewals out as per-channel queue jobs scoped to active workspaces, retry FAILED channels, and recreate Google Calendar watches before stopping the old one. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23006?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
7e133a4930 |
Converge email recipient fields on existing patterns: shared parser/formatter, search-index members, one display-name rule (#22997)
# Why Follow-up to #22668, addressing @charlesBochet's five post-merge review comments. They all point the same direction: the recipient fields rebuilt things the codebase already had. This PR converges on the existing patterns where that holds up, and answers on the threads where it deliberately does not. # What changed, per comment **Parser duplication ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064242))**: `parseEmailAddressList` now lives in twenty-shared (addressparser, group flattening, try/catch). The server's `safeParseEmailAddresses` delegates to it, the front wrapper keeps only paste normalization (newlines to commas) and invalid-token preservation for red chips. The `addressparser` dependency moves from twenty-front to twenty-shared. Side effect worth knowing: RFC 5322 group members in inbound To/Cc headers were previously dropped entirely (group entries have no top-level address, so the filter removed them); flattening now imports those participants. Covered by a new regression test. **Formatter duplication ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064243))**: `formatEmailAddress` (quote only when specials require it) lives in twenty-shared. The composer chips and the server's `formatMessageFromHeader` both delegate to it. The Gmail From header output is byte-identical: the name is mime-encoded first and encoded words never contain characters that trigger quoting. CodeQL then caught that the quoting (ported from the original front util) escaped quotes but not backslashes, letting a crafted name close the quoted string early; escaping now covers both as RFC 5322 quoted-pairs, with a containment test proving a hostile name cannot split into extra recipients on reparse. **Member search divergence ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064231))**: suggestions now search WorkspaceMember through the search index in the same `useObjectRecordSearchRecords` call as Person (one ranked query), and enrich hits from `currentWorkspaceMembersState`, exactly like `SettingsRoleAssignmentWorkspaceMemberPickerDropdown`. The client-side `filterBySearchQuery` pass is gone. The hook is now what the comment described: the merge of context people, searched people, and members into one ranked list, rendered with the same `SelectableList`/`MenuItemAvatar` primitives the pickers use. Also fixed while in there: searched person ids are sliced to the suggestion limit before hydration, so top-ranked people can no longer be crowded out of the hydration page. **Chip resolution duplication ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064236))**: the display-name preference is now one rule, `getEmailIdentityDisplayName`, used by both `getDisplayNameFromParticipant` (threads) and the composer chip/menu, so the same address renders identically everywhere. The order is workspace member, then person, then display name, then handle: when an address belongs to both a teammate and a Person record, the internal identity wins (product call from Felix). `BaseChip.maxLabelWidth` is renamed `maxWidth` to match the twenty-ui `Chip` API. `ParticipantChip` itself is not used inside the field: it renders a navigating `RecordChip` when a person is linked, and navigation from the composer destroys the draft (no draft persistence yet), plus the field chips need remove/selected/danger/edit affordances it does not have. **Rebuilding on MultiItemFieldInput ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064221))**: answered on the thread rather than in code, deliberately. `MultiItemFieldInput` is a dropdown-panel list editor (vertical rows, one input at a time, bound to record-field contexts and `FieldMetadataType`), and its own TODO says the API should be refactored into a hook before growing. The inline wrapping chip row commits batches (paste), dedupes with a flash, and keeps a persistent inline input with suggestions; layering that through `renderItem`/`renderInput` would strain both components. On the menu overlap: after comparing side by side, the shared surface between `MultiItemFieldMenuItem`'s dropdown and the chip menu is three `MenuItem` rows with different copy, order, and neighbors; `MenuItem` is already the shared primitive, and a config-driven fragment would be indirection without deduplication. If deeper convergence is wanted, the honest path is the existing TODO (extract the multi-item state machine into a hook, rebase both editors on it); that touches the Links/Phones/Emails/Array/Files cell editors and deserves its own PR. # Verification - New twenty-shared suites for the parser and formatter (16 tests), including parse/format round-trips, the encoded-word case, and the backslash-escaping containment case. - Server messaging util specs all pass (70 tests), including new group-flattening regression tests; From-header spec output unchanged. - Front email module suites all pass (59 tests) with the slimmed wrappers. - Typecheck and lint green on twenty-shared, twenty-front, twenty-server; oxfmt clean on all three. - Playwright smoke against the seeded dev stack passes end to end: context suggestions on the Google company, typed search showing people and the workspace member row (now served by the search index), Enter picking the top suggestion, duplicate merge, keyboard delete, chip menu with clipboard copy, Ctrl+Enter committing the buffer then triggering send. |
||
|
|
f6612e5a85 |
fix(server): stop treating Microsoft Graph 401 as a permanent failure (#22989)
In production we are seeing some accounts being occasionally marked as permanent failure even though when you check with their refresh token it never actually failed this PR stops treating 401 as permanent failure and treats them as Transient error. We already have token refresh stage that runs before the actual import stage, so if it's an actual revoke token error, it should catch it. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22989?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
52b7aebddf |
fix(server): key connected-account lookup on handle and provider (#22964)
Connect flows (Google, Microsoft, IMAP/SMTP/CalDAV) looked up an existing connectedAccount by `handle` alone, so connecting a second account with the same handle but a different provider overwrote the first instead of inserting a new row (e.g. IMAP inbox clobbering a calendar-only Google account). Fix: add the `provider` discriminator to the lookup. Same provider+handle still updates; a different provider gets its own row. Integration test covers the Google-then-IMAP case. |
||
|
|
f4b2968a74 |
fix(server): finalize workflow runs stuck in STOPPING (#22900)
## Context A workflow run only reaches STOPPED via the in-flight worker execution: `stopWorkflowRun` just flips a RUNNING run to `STOPPING` (records intent), and the `STOPPING -> STOPPED` transition is done later inside `computeWorkflowRunStatus`, which only runs while a worker is executing the run's steps. If no worker is executing the run at that point, nothing ever finalizes it: - the worker that owned the run crashed / was killed mid-step (e.g. under heavy load), or - a step legitimately sits in RUNNING awaiting an external event that never arrives (the user stopped it). Only `ENQUEUED` runs had a staleness sweep, so `STOPPING` (and `RUNNING`) had no recovery path and would stay stuck indefinitely. This has been observed in production (~150 runs stuck in `STOPPING` after manual stops during a migration). ## Change Extend the existing staled-runs machinery to also finalize runs left in `STOPPING`: - New `stuck-stopping-runs-threshold` (1h) + `getStuckStoppingRunsFindOptions` matching `status = STOPPING AND updatedAt < now - 1h`. `updatedAt` is a TypeORM update-date column, so it reliably marks when the run entered `STOPPING`, and 1h stays above any legitimate in-flight step. - `handleStuckStoppingRunsForWorkspace` finalizes each match to `STOPPED` via `endWorkflowRun`, so `endedAt`, step infos and the `WorkflowRunStopped` metric stay consistent. It pages the backlog with keyset pagination on `(createdAt, id)`, so a page whose finalizations all fail can't stay at the front of the query and starve later runs (failed ones are retried on the next sweep). - Wired into the same cron (`WorkflowHandleStaledRunsCronJob`, every 10 min), per-workspace job, and the manual `workflow:handle-staled-runs` command — so ops can also clear an existing backlog immediately. The staled-ENQUEUED and stuck-STOPPING handlers run independently (`Promise.allSettled` in the job, separate try/catch in the command), so a failure in one doesn't block the other. Stop remains manual and unchanged; this only guarantees a stopped run eventually reaches `STOPPED`. ## Notes / scope - No schema change (reuses `updatedAt`), so no migration. - `RUNNING` runs orphaned by a worker crash have the same missing-net problem; left out of scope here (this covers the user-triggered STOPPING case). - The new detection query scans `status`/`updatedAt` like the existing ENQUEUED sweep; at very high `workflowRun` volumes an index on `(status, updatedAt)` would help — same pre-existing consideration as the ENQUEUED path. ## Tests Unit tests for `handleStuckStoppingRunsForWorkspace`: no-op when none, finalizes each match to STOPPED, pages through a multi-page backlog, and advances past a fully-failed page instead of starving later runs. Plus a unit test for the `(createdAt, id)` keyset condition in `getStuckStoppingRunsFindOptions`. Full suite green, lint + typecheck clean on changed files. Manually verified on a real instance (Postgres): seeded a `STOPPING` run aged 2h and ran `workflow:handle-staled-runs` -> transitioned to `STOPPED` with `endedAt` set; a freshly-`STOPPING` run (updatedAt now) was correctly left untouched by the 1h threshold. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22900?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
f67eb60c57 |
feat(workflow): soft-ref core workflow/version (backfill + dual-write) (#22821)
Replaces the shared-UUID model (core row reuses the workspace record id) with a **soft-ref**: the workspace `workflow`/`workflowVersion` records carry a nullable `coreWorkflowId`/`coreWorkflowVersionId` pointing to their **own-id** core rows. This removes the assumption that workspace record ids are globally unique - which is false, since prefilled/seeded workflows share ids across workspaces. Supersedes #22776. ## In this PR **Soft-ref columns (foundation):** - **twenty-shared** `STANDARD_OBJECTS`: `workflowVersion.coreWorkflowVersionId` + `workflow.coreWorkflowId` (+ snapshot test). - **compute utils**: both as system, nullable UUID fields. - **entity classes**: the bare fields. **Version soft-ref sync:** - Core `workflowVersion` rows get their own id, derived deterministically from `workspaceId + record id` (uuidv5). Deterministic so the upsert is idempotent: a failed write-back re-derives the same id and self-heals instead of orphaning rows or colliding on the one-active-per-workflow index. - Sync = find-or-create keyed on the workspace record's `coreWorkflowVersionId`, then write the core id back onto the workspace record. - Migrating over pre-soft-ref data: purges any core row whose id equals the workspace record id before recreating, so old shared-UUID rows aren't orphaned. - Version dual-write listener reworked: delete is keyed by the core id read off `before.coreWorkflowVersionId`. Verified on a fresh `database:reset` (columns materialize, backfill produces deterministic own-id rows linked back, idempotent re-run), a simulated old shared-UUID state (stale rows purged, records re-linked), and a simulated write-back failure (retry re-links to the same id, no orphan, active-version index intact). ## Next steps (follow-up work, not in this PR) 1. Workflow-side soft-ref sync mirroring the version side (service, module, dual-write listener, backfill command). 2. Workspace command to add the two columns to existing workspaces. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22821?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
8e03921372 |
Add CREATED workspace activation status (read path + enum migration) (#22904)
## Context Since v2 onboarding (#22303), workspaces are activated **before** the billing plan step (now the last onboarding step). Users abandoning at the plan step leave ACTIVE workspaces with a Stripe customer but no subscription (~60–110/day on cloud, 935+ so far), and no cleanup mechanism ever touches them: billing webhooks never fire (no subscription), the suspended-workspaces cron only handles SUSPENDED, the onboarding cron only handles PENDING_CREATION/ONGOING_CREATION. Target lifecycle (across two PRs): `PENDING_CREATION → ONGOING_CREATION → CREATED → ACTIVE → SUSPENDED → deleted`. **`CREATED`** = the workspace schema is provisioned but onboarding is not complete — no billing subscription yet. It is **not** considered active: | Concern | CREATED behavior | |---|---| | Sign-in / invited teammates joining | allowed (invite-team step precedes the plan step) | | Member + metadata loading (app shell) | allowed (user must finish onboarding) | | Permissions | real permission checks (no PENDING-style bypass) | | Version upgrades / workspace migrations | **included** (schema must not drift) | | Messaging/calendar/workflow/etc. crons | **excluded** — no background processing until a plan is chosen | | PLAN_REQUIRED onboarding lock | unchanged (still derived from subscription existence) | ## What this PR does (read path only) The enum addition ships as a **slow** instance command, which can run after deploy — so nothing in this PR ever **writes** `CREATED`. The write path (setting it at activation, the cleanup sweep, the backfill of the existing zombie cohort) is a follow-up PR that ships once this migration has run everywhere. - **twenty-shared**: `CREATED` enum value; `PROVISIONED_WORKSPACE_ACTIVATION_STATUSES` + `isWorkspaceProvisioned` ("schema exists": CREATED | ACTIVE | SUSPENDED), replacing `isWorkspaceActiveOrSuspended` — all call sites (server member loading, access-token workspace-member lookup, front metadata-store gates) meant "has schema/members". - **Slow instance command** (2.22.0): swaps `core.workspace_activationStatus_enum` using the rename→recreate→alter-column idiom. The CHECK constraints on `core.workspace` embed casts to the enum type and would break the swap — the command captures them from `pg_constraint`, drops them, swaps the type, and restores them. - **Pre-migration-safe queries**: Postgres rejects `IN ('CREATED', ...)` when the enum value does not exist yet — even for reads, and the instance-command runner itself queries provisioned workspaces before migrating (a fresh database could never initialize). All provisioned-status filters go through a new `activationStatusIn` util comparing on `"activationStatus"::text`, valid before and after the migration. - **Upgrade path**: workspace iterator, command runner, upgrade-status and workspace-version services iterate CREATED workspaces. Since they now cover more than ACTIVE/SUSPENDED, the stale names were renamed to `ProvisionedWorkspaceCommandRunner`, `hasProvisionedWorkspaces`, `getProvisionedWorkspaceIds`, `loadProvisionedWorkspaces` (the mechanical import rename in old version-command dirs is why this PR carries the `ci:allow-previous-version-upgrade-mutation` label). - **Sign-in**: `throwIfWorkspaceIsNotReadyForSignInUp` accepts CREATED so invited members can join during onboarding (join authorization itself is unchanged — enforced upstream in `checkAccessForSignIn`); `activateWorkspace` idempotent-retry accepts CREATED as a terminal state. - **Transitions out of CREATED** (only write ACTIVE — safe to ship now, dead until the write path lands): the Stripe webhook reactivation branch also promotes CREATED, and `syncSubscriptionToDatabase` promotes synchronously; both gated on `WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES` (Active/Trialing — extracted from `shouldReactivateWorkspace`, behavior-preserving) so an `incomplete` subscription created by the payment-intent flow before payment never promotes the workspace. - Deliberately untouched: all background crons, permission guards, JWT strategy, PLAN_REQUIRED logic, admin panel (renders the raw status string). ## Follow-up PR (after this migration has run) 1. `activateWorkspace` sets `hasWorkspaceAnySubscription ? ACTIVE : CREATED` (billing disabled → always ACTIVE, self-hosted unchanged). 2. Cleanup: suspend CREATED workspaces older than N days (config var), handing them to the existing suspended pipeline (warn → soft-delete → destroy). 3. Backfill: cloud-only slow command moving ACTIVE workspaces with no billingSubscription row (created since Jul 1) to CREATED. ## Verification - Migration exercised against a real database via the command class: up → down → up; `enum_range` and `pg_get_constraintdef` checked after each step (constraints restored against the new type, `DEFAULT 'INACTIVE'` preserved). - Pre-migration safety exercised for real: with the migration rolled back (enum without CREATED), `run-instance-commands` — the exact fresh-database CI path that failed before the `::text` fix — completes cleanly. - End-to-end with a workspace manually set to CREATED and the branch server+front running: sign-in issues tokens, `currentUser` loads workspaceMember(s), the full app loads with no console errors; GraphQL returns `activationStatus: CREATED`. - Workspace creation ran end-to-end locally in **both billing modes** on this branch: - billing disabled: signup → workspace creation → ACTIVE immediately → onboarding completes with no plan step → app loads (unchanged behavior); - billing enabled (Stripe test mode): signup creates the Stripe customer eagerly → activation ends ACTIVE → subscription-less workspace is pinned to the plan-required page → no-card trial checkout creates a `trialing` subscription via `createDirectSubscription`/`syncSubscriptionToDatabase` → app loads. - `twenty-shared` unit tests, server specs on touched services, `lint:diff-with-main` and `typecheck` for shared/server/front all green; full CI green. |
||
|
|
9f75506896 |
feat(workflow-tools): add get_logic_function_source tool (#22835)
## What / why
The workflow agent tools let an AI **write** a CODE step's logic
function (`update_logic_function_source`) and **list** logic functions
(`list_logic_function_tools`), but there is no tool to **read** an
existing function's source.
That's fine for greenfield generation — the agent already has in context
whatever code it just wrote. But it's a real gap when editing a function
the agent did **not** author: to safely modify an existing CODE step it
has to see the current source first, and today the only ways to get it
are the frontend (`getLogicFunctionSourceCode` query) or the DB. So the
agent is forced to either guess or ask a human to paste the code.
This adds a small read tool that closes the loop, mirroring the existing
`update_logic_function_source` tool.
## How
- New `get_logic_function_source` tool that calls the existing
`LogicFunctionFromSourceService.getSourceCode({ id, workspaceId })` —
the same service method backing the `getLogicFunctionSourceCode` GraphQL
resolver the frontend already uses. No new service logic.
- Registered in `workflow-tool.workspace-service.ts` alongside
`update_logic_function_source` (the dependency
`logicFunctionFromSourceService` is already injected).
- Unit test covering the success and error paths, matching the `get-*`
tool test convention.
## Notes
- Read-only, additive; no schema or API changes.
- Naturally pairs with `update_logic_function_source`: read → edit →
write.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22835?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
9f5d17d1f0 |
Add receipt metrics and logs to connected account sync webhooks (#22853)
Webhook deliveries from Google and Microsoft were invisible at the app level: successful notifications produced no logs and no metrics, so webhook-triggered syncs could not be told apart from cron polling. Add two counters, connected-account-sync-webhook/received/messaging and /received/calendar, mirroring the sync-job metric umbrellas, and log a line whenever a notification triggers a sync. Unmatched subscriptions keep their existing warn logs. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22853?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
f547da4ee9 |
Gate connected-account webhook subscriptions behind config (#22761)
Now gated behind IS_CONNECTED_ACCOUNT_WEBHOOK_SUBSCRIPTION_ENABLED (default false). Merge + deploy twentyhq/twenty-infra#780 first. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22761?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com> |
||
|
|
a360f9bdec |
migrate 10 modules off NestjsQueryTypeOrmModule wiring (#22763)
## Summary Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates ten modules from `NestjsQueryTypeOrmModule.forFeature` to the standard `TypeOrmModule.forFeature`. These modules only used `nestjs-query` for repository registration — none register `NestjsQueryGraphQLModule` / auto-generated resolvers — so this is a pure module-wiring swap with no behavior or schema change. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22763?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Weiko <corentin@twenty.com> |
||
|
|
3a5545c753 |
chore: remove IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED flag (#22680)
Messaging/calendar webhook subscriptions are now always on; drop the feature flag gate and its enum/public-flag registration. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22680?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
ca90a9358f |
feat(workflow): backfill workspace workflowVersion into core (phase A) (#22663)
## workflowVersion -> core, Phase A Follows #21674 (Phase 0, merged). Base: `main`. Populates core `workflowVersion` and keeps it in sync with the workspace object, so a later phase can switch reads to core. Reads stay on the workspace object in this PR. ### 1. Backfill (upgrade command) `BackfillWorkflowVersionToCoreCommand`, a `@RegisteredWorkspaceCommand('2.20.0', ...)`. Per workspace, reads all workspace `workflowVersion` records and upserts them into core, preserving ids (idempotent), dry-run aware. ### 2. Dual-write (always on, not flag-gated) `WorkflowVersionCoreDualWriteListener` hooks `@OnDatabaseBatchEvent('workflowVersion', CREATED/UPDATED/DELETED)` (same mechanism as the existing workflow-version status listener) and mirrors every mutation into core. Sync failures are logged, never break the user's write; drift is repaired by re-running the backfill command. Dual-write is deliberately not behind a flag: reading from core (next phase) is only safe if core has been continuously in sync since the backfill. An always-on mirror makes "core is fresh" an invariant, so the read switch becomes a plain flag flip. The cost is one extra upsert on infrequent workflowVersion writes. `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` is reserved for the read switch (Phase B): dispatch from the `workflowAutomatedTriggerMaps` cache, runner and builder reading trigger/steps from core. Until then it gates nothing. Both the backfill and the listener go through a single `WorkflowVersionCoreSyncService` (`upsertToCore`/`deleteFromCore`): the workspace-to-core mapping (`trigger` -> `triggers[]`, plus `steps`, `status`, `workflowId`) and `workflowAutomatedTriggerMaps` invalidation live in one place. ### Rollout plan (following phases) - **B, read switch (flag per workspace):** reads move to core; writes keep flowing workspace -> listener -> core. Rollback = flip the flag back, workspace never stopped being source of truth. - **C, contract (code change):** write paths write trigger/steps to core directly; workspace `workflowVersion` stays as a thin shell (nav/relations/search) but drops the trigger/steps columns; listener and flag removed. ### Not in this PR - Reconciliation tooling beyond re-running the backfill. - The read switch (Phase B). |
||
|
|
b5a73ad86a |
Chore/remove messaging mock specs (#22665)
<!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22665?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
674de0056b |
feat(messaging): message campaign delivery stats + views (#22661)
Re-land of #22452 (reverted in #22627). Rebuilt on fresh main with upgrade commands isolated to 2-20 only; no other version's commands touched. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22661?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
72d728ea8e |
fix(messaging): add sender name to IMAP/SMTP From headers (#22603)
Manual IMAP/SMTP outbound emails were being composed with a bare email address in the From header, so recipients did not see the sender's display name. Gmail already built a proper sender header from Google profile data, but manually configured IMAP/SMTP accounts had no equivalent path and fell back to the raw address only. Fix this by storing the optional sender display name in the IMAP/SMTP/CALDAV connection parameters, exposing it through the settings flow and metadata API, and reusing a shared From-header formatter when composing outbound messages. The formatter now builds a properly encoded sender header when a name is available and falls back to the bare email address when it is not, keeping the behavior safe for blank or missing names. Gmail keeps using its existing Google-derived display name source; this change only brings manual accounts up to the same header formatting standard and removes duplicated formatting logic between outbound drivers. After this change, manual SMTP sends and IMAP draft creation include the configured sender name in the From header, while blank names are normalized away instead of producing malformed headers. Existing manual account names are preserved when updates omit the field, and edited accounts can still explicitly clear it through the settings flow. Add focused utility coverage for the shared From-header formatter. Fixes: #22608 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22603?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: neo773 <neo773@protonmail.com> |
||
|
|
6bc4d8efac |
fix(workflow): batch staled run reset to avoid Postgres param limit (#22654)
## Problem
Self-hosters with a large backlog of workflow runs stuck in `ENQUEUED`
see the recovery job (`WorkflowHandleStaledRunsJob`) fail with:
```
Error: Data validation error.
at computeTwentyORMException ...
at WorkspaceSelectQueryBuilder.getMany ...
at WorkspaceUpdateQueryBuilder.execute ...
at WorkflowHandleStaledRunsWorkspaceService.handleStaledRunsForWorkspace ...
```
So the very job meant to unblock enqueued runs can never complete, and
runs stay stuck.
## Root cause
`handleStaledRunsForWorkspace` fetched **every** staled run unbounded,
then called `repository.update(allIds, ...)`. That builds a `WHERE id IN
($1, $2, ... $N)`. Inside `WorkspaceUpdateQueryBuilder.execute`, a
"before" `SELECT` runs with that same huge `IN` list; with a big enough
backlog the bind-parameter count exceeds Postgres' limit, the `getMany`
throws a `QueryFailedError`, and `computeTwentyORMException` maps the
resulting PG error code to the generic `PostgresException('Data
validation error.')`.
There's also a secondary `before.length > QUERY_MAX_RECORDS` (200) guard
in the update path that would reject anything over 200 rows even if the
param limit weren't hit.
## Fix
Process staled runs in batches of `QUERY_MAX_RECORDS` (200), looping
until a pass finds none left — the same batching pattern the sibling
clean-runs job already uses. Each update flips the batch from `ENQUEUED`
to `NOT_STARTED`, so the find criteria stops matching them and the loop
terminates. The throttling recompute now runs once at the end, and only
if at least one batch was reset.
## Tests
New unit spec covering:
- no staled runs -> no update, no recompute
- single batch -> correct ids/payload, recompute once
- exactly 200 -> `take: 200`, 200 ids per update
- 450 backlog -> 3 update calls (200/200/50), loops until empty,
recompute exactly once
All 4 pass locally.
## Note
This fixes the recovery job. If runs keep re-accumulating as `ENQUEUED`,
there may be a separate producer-side issue worth investigating.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22654?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
bfeaaa56a3 |
fix(workflow): handle IS/IS_NOT operand in text and array filters (#22640)
## Problem Sentry `TWENTY-SERVER-G4F` — `Error: Operand IS not supported for this filter type` (30k+ occurrences, 15 workspaces, ongoing). A workflow **Filter** step throws when a step filter carries an `IS`/`IS_NOT` operand on a text/array field type (`TEXT`, `MULTI_SELECT`, `EMAILS`, `PHONES`, `ADDRESS`, `LINKS`, `FULL_NAME`, `ARRAY`, `RAW_JSON`). `evaluateTextAndArrayFilter` only handled `CONTAINS`/`DOES_NOT_CONTAIN`/`IS_EMPTY`/`IS_NOT_EMPTY` and hit `default:` → `throw`. The throw propagates out of `FilterWorkflowAction` and **fails the entire workflow run**. The current frontend no longer offers `IS`/`IS_NOT` for these types, so these are **legacy persisted step filters** in older (immutable) workflow versions that keep executing. ## Fix Handle `IS`/`IS_NOT` in `evaluateTextAndArrayFilter` as `contains`/`!contains`, consistent with `evaluateSelectFilter` (chosen over strict equality because the routed types include arrays/composites where `==` would silently never match). No existing operand behavior changes. ## Tests Added coverage for legacy `IS`/`IS_NOT` on `TEXT` and `MULTI_SELECT`. Note: the pre-existing `date operands` test failures are timezone-dependent and unrelated to this change (they fail on `main` too). Fixes TWENTY-SERVER-G4F <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22640?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
bd8bf89653 |
Revert "feat(messaging): message campaign delivery stats + views" (#22452) (#22627)
Revert "feat(messaging): message campaign delivery stats + views
(#22452)"
This reverts commit
|
||
|
|
2e1117d442 |
feat(messaging): message campaign delivery stats + views (#22452)
<!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22452?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
6c40c7b91a |
Deterministic system field universal identifier (#22565)
# Introduction Close twentyhq/core-team-issues#2641 Auto-provisioned field metadata used to get its `universalIdentifier` from three unrelated sources: random `v4()` on the server when creating custom objects, hardcoded values in `STANDARD_OBJECTS`, and an ad-hoc `v5` derivation in the SDK manifest build. This PR unifies all of them behind the shared `getFieldUniversalIdentifier` derivation: ``` universalIdentifier = f(applicationUniversalIdentifier, objectUniversalIdentifier, fieldName) ``` ## Ownership model The rollout is built on an explicit split of who owns a field's universal identifier: - **The 8 system fields** (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`) are **server-owned**. Their universal identifiers are always the deterministic derivation, on **every** application (standard, workspace-custom, installed). Clients cannot provide custom values: a temporary check in `validateObjectMetadataSystemFieldsIntegrity` rejects any non-derived system field identifier at migration build time. This check stands in until system fields are generated exclusively server side by the metadata side-effect engine and stripped from client inputs — at which point it becomes structurally impossible to send one. - **`name` is a default field, not a system field**: it is auto-provisioned when absent (server side for custom objects, SDK side for application objects) but authors can define their own. It is only derived where it is guaranteed to be auto-provisioned. In particular, standard objects keep their **historical hardcoded** `name` identifiers: the standard app authors its `name` fields like any installed app would, and moving those identifiers would break every installed application referencing them (e.g. views on `opportunity.name`). - **User-created and author-provided fields** keep random / explicit identifiers, untouched. ## Server - `validateObjectMetadataSystemFieldsIntegrity` now validates, on top of the existing type/`isSystem` checks, that each system field's `universalIdentifier` equals the deterministic derivation. Runs for every object creation going through the migration orchestrator: app sync, custom object creation, standard provisioning - `build-default-flat-field-metadatas-for-custom-object.util.ts` derives the system field identifiers (and the auto-provisioned `name`) with `getFieldUniversalIdentifier` instead of `v4()` - `build-default-relation-flat-field-metadatas-for-custom-object.util.ts` derives both the forward and the reverse default relation field identifiers deterministically - `generateMorphOrRelationFlatFieldMetadataPair` accepts optional `sourceFieldUniversalIdentifier` / `targetFieldUniversalIdentifier` so callers can inject deterministic values; user-created relations still default to `v4()` ## twenty-shared - `STANDARD_OBJECTS` system field identifiers (the 8) are now computed at module load via `buildStandardObjectSystemFields`; `name` and every other identifier keep their hardcoded values - New snapshot test pinning **every** universal identifier of `STANDARD_OBJECTS`: any identifier change now requires an explicit snapshot update and should ship with a coordinated backfill ## SDK (breaking, pre-GA) - `generateDefaultFieldUniversalIdentifier` delegates to `getFieldUniversalIdentifier` and now requires `applicationUniversalIdentifier` - Reverse default relation field identifiers are derived from the field's real coordinates (standard object UID + actual field name, e.g. `targetRocket` on `attachment`) instead of the legacy custom-object UID + synthetic `${fieldName}Inverse` hash input. Field *names* are unchanged - The manifest build threads the application universal identifier through default field injection (two-pass over object configs) - `twenty dev:add` now resolves the application universal identifier upfront and refuses to scaffold anything until `defineApplication` declares one — no more `fill-later` placeholder for the app UID in generated files ## Upgrade A 2.19 **workspace command** backfills existing `fieldMetadata.universalIdentifier` rows to the deterministic derivation. Coverage follows the ownership model: - **The 8 system fields**: taken over for **every application**, whatever value they currently hold. This is both safe and required now that sync rejects non-derived values — leaving a row unconverged would make its application unsyncable - **`name`**: workspace-custom app → always taken over (server-generated, no author to clobber); installed applications → only rows still carrying the legacy SDK derivation are recomputed, author-provided identifiers are never touched; standard app → never touched (hardcoded in `STANDARD_OBJECTS`) - **Default relation fields**: workspace-custom app → forward fields on custom objects and reverse fields on the standard relation objects; installed applications → legacy-derivation probe only All identifiers of a workspace are updated inside a single transaction, then the command flushes the field-metadata-related workspace caches and bumps the metadata version. Stored `applicationRegistration.manifest` snapshots are intentionally **not** rewritten: installs and upgrades always sync from the `manifest.json` inside the resolved package (npm/tarball), the stored column is only used for display/marketplace purposes. ## Breaking behavior for old packages (fail closed) Packages built with an older SDK carry legacy system field identifiers in their tarball `manifest.json`. Installing or upgrading such a package now fails with an explicit `INVALID_SYSTEM_FIELD` validation error ("universal identifier is not deterministic") instead of silently mismatching against the backfilled rows and triggering a destructive delete+create. The remediation is to rebuild the package with the new SDK; the backfill has already converged the installed rows, so the rebuilt manifest syncs cleanly. ## Test plan - [x] `twenty-sdk` unit tests (526 tests) and typecheck - [x] `twenty-shared` unit tests (1635 tests) including the `STANDARD_OBJECTS` snapshot; `name` identifiers verified byte-for-byte identical to `main` - [x] Lint and typecheck clean on all touched packages - [x] Integration: create a custom object and verify system + default relation field identifiers match the deterministic derivation (`create-one-object-metadata-deterministic-field-universal-identifiers`, 13 assertions passing) - [x] Integration: `failing-sync-application-object-system-fields` extended with a non-derived system field identifier case; all identifiers in the spec pinned deterministically so snapshots embedding expected/actual values are stable across runs (verified with a double run) - [x] Integration: all application sync suites pass with the derived system field identifiers now required by the `buildDefaultObjectManifest` test helper (9 suites, 20 tests) - [x] Full test-database reset: standard app provisioning and seeded workspaces pass the new validation - [x] SDK manifest build verified on the postcard example app: all auto-generated default field identifiers match the derivation - [ ] Run `upgrade:2-19:backfill-deterministic-field-universal-identifiers` (dry-run then real) on a seeded workspace and verify identifier convergence with a rebuilt app manifest |