5f94ee3e0276f7b3464e4a2ee97da2b4994bda48
551 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c171c62099 |
chore(twenty-server): upgrade typeorm to 0.3.29 (#21957)
## Summary Upgrades **typeorm `0.3.26` → `0.3.29`** and adapts the twenty-orm `update`/`upsert` overrides to typeorm's newly-added `options.returning`. Upgrading to resolve [this](https://github.com/twentyhq/twenty/security/dependabot/1573) alert. ## Why `0.3.29` is the latest release compatible with `@ptc-org/nestjs-query-typeorm` (peers `typeorm@^0.3.15`; the `1.x` line has no compatible release, so it's blocked until that dependency moves). ## Changes **`chore` — bump** - `typeorm` patch descriptor `0.3.26 → 0.3.29` + `yarn.lock`. - Local patch carried over **unchanged** (pure rename) — both hunks (`PickKeysByType` nullable-awareness, `DeleteResult.generatedMaps`) are still absent upstream in `0.3.29`, so it remains load-bearing. **`refactor` — adapt overrides** - `0.3.29` adds `options?: UpdateOptions` (carrying `returning`) to `EntityManager`/`Repository` `update()`. The override must accept it at the base-mandated position, so it's added as its **own dedicated parameter** (not hidden inside `permissionOptions`), honoring `options.returning` with a fallback to Twenty's permission-aware `selectedColumns` (`'*'` default). - The same merge is applied to `upsert()`, which already received `UpsertOptions` but was dropping its `returning` field — so both write methods now treat the option identically. - Internal call sites + specs updated for the new parameter slot. ## Verification - `nx typecheck twenty-server` — **0 errors** - twenty-orm unit tests — **191 / 191 pass** - `oxlint` / `oxfmt` — clean |
||
|
|
2abf9c2930 |
feat(workflow): Pick Record load balanced strategy (3/3) (#21902)
## Overview Final PR in the Pick Record stack. Adds the **Load Balanced** strategy: pick the candidate that currently has the *fewest related records*. This is the "fair assignment" mode — e.g. assign a new company to the account owner who currently owns the fewest companies, or route a lead to the rep with the fewest open opportunities. **Stacked on #21900** (which is stacked on #21899) — merge in order. This PR's diff against `main` includes PRs 1 & 2 until they merge. ## What changed - Widened the `strategy` enum to add `LOAD_BALANCED`, and added an optional `loadBalance: { objectNameSingular, fieldName }` to the action input. - Editor: selecting **Load balanced** reveals a **Balance by** object picker and a **Count by** field picker (the related object's many-to-one relation fields). - Executor: for each candidate, counts records of the chosen related object whose chosen relation points at that candidate, then selects the least-loaded one. ## How it works Given pool = workspace members and config `{ objectNameSingular: "opportunity", fieldName: "pointOfContact" }`, the executor counts, per member, the opportunities whose `pointOfContact` is that member, and picks the member with the lowest count. ## Design decisions & tradeoffs 1. **No persistent state — computed live each run.** Unlike round robin, load balancing reads current data, so there's no cursor to store. Correct by construction even under concurrency (each run recomputes counts); the only caveat is two simultaneous runs can both see the same "least loaded" candidate before either assignment lands (a small, self-correcting skew), which is inherent to load-balancing and acceptable. 2. **Count via per-candidate queries.** One filtered count per candidate (`{ [relationField]: { id: { eq: candidateId } } }`), run in parallel. For the realistic pool sizes this targets (a team), this is simple and clear. A single `group_by` aggregate would scale better for very large pools — noted as a future optimization, deliberately not done to keep the logic obvious. 3. **Deterministic tie-break.** Candidates are pre-sorted by id (shared with round robin), and the first minimum wins — so equal-load ties resolve deterministically rather than arbitrarily. 4. **`Count by` lists all many-to-one relations of the chosen object** (not filtered to those targeting the pool object). Keeps the editor simple; picking an unrelated field just yields zero counts, which is visibly wrong. Filtering options to relations that target the pool object is a nice follow-up. 5. **Filter on the counted set** (e.g. only *open* opportunities) is intentionally out of scope for this first cut — documented as a follow-up. ## Testing Added `pick-record-load-balanced-workflow.integration-spec.ts`: creates two fresh companies (0 related opportunities each), attaches one opportunity to the second, configures `LOAD_BALANCED` counting opportunities by `company`, and asserts the step picks the **first** company (0 < 1). Passes locally alongside the random and round-robin tests (3 suites / 4 tests). `typecheck` + `lint:diff-with-main` green for shared/server/front. ## The full stack 1. #21899 — Random (the action + the whole scaffold) 2. #21900 — Round robin (atomic Redis cursor) 3. this — Load balanced Together these enable round-robin / load-balanced / random **assignment workflows** in Twenty, composed via the standard variable picker (assign the chosen record downstream with `{{step.<id>.id}}`). https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8 --- _Generated by [Claude Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21902?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. --> |
||
|
|
fa6d1394af |
feat(workflow): Pick Record round robin strategy (2/3) (#21900)
## Overview Second PR in the Pick Record stack. Adds a **Round Robin** selection strategy alongside Random, so an assignment workflow can distribute records *evenly* across a candidate pool (e.g. rotate company ownership across a set of workspace members) rather than just randomly. **Stacked on #21899** — review/merge that one first. This PR's diff against `main` includes PR 1's commits until #21899 merges. ## What changed - Widened the `strategy` enum (`RANDOM` → `RANDOM | ROUND_ROBIN`) in the shared schema and the server input type. - Editor now shows a **Strategy** selector (Random / Round robin). The candidate-pool label changed from "Pick at random from" to the neutral "Pick from" since random is no longer the only mode. - Executor implements round robin. ## Design decisions & tradeoffs 1. **State store: Redis `incrBy` (atomic), keyed `pick-record:round-robin:{workspaceId}:{stepId}`.** Round robin needs a persistent cursor, and workflow runs are **not** serialized — two runs can execute the same step concurrently — so the increment must be atomic. `CacheStorageService.incrBy` (workflow cache namespace) is a single atomic Redis op, needs no schema change, and is already injectable. Index = `(cursor - 1) % poolSize`. **Tradeoff — durability:** a Redis flush/eviction resets the cursor, which restarts the cycle from an offset. That causes a one-time *fairness drift*, never a *correctness* bug (no double-assignment, since each increment is atomic). If strict durability is ever required, the cursor can move to a Postgres counter table with `INSERT … ON CONFLICT … DO UPDATE SET cursor = cursor + 1 RETURNING cursor` (atomic + durable) — deliberately **not** done here to avoid a migration for what is, in practice, an acceptable reset. 2. **Deterministic pool ordering.** The resolved pool is sorted by `id` before the cursor is applied, so position→record mapping is stable run-to-run regardless of fetch order. Without this, round robin wouldn't reliably cycle. 3. **Cursor key uses `stepId`.** Stable across runs of a published version. Republishing a version may mint new step ids, which resets the cursor — acceptable and documented here. 4. **Slot-on-increment.** The cursor increments when the step runs (reserving a position); if a later step in the run fails, that position is effectively skipped. Minor, acceptable unfairness — flagged rather than adding cross-step compensation. ## Testing Added `pick-record-round-robin-workflow.integration-spec.ts`: builds a workflow with a 3-record pool and `ROUND_ROBIN`, runs it 4 times sequentially, and asserts the picks are exactly `[p0, p1, p2, p0]` (full cycle + wraparound) against the deterministically-ordered pool. Passes locally alongside PR 1's random test (2 suites / 3 tests). `typecheck` + `lint:diff-with-main` green for shared/server/front. ## Follow-up - PR 3: `LOAD_BALANCED` (fewest related records wins). https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8 --- _Generated by [Claude Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21900?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. --> |
||
|
|
573fd00ea7 |
feat(workflow): add Pick Record action (1/3 — random selection) (#21899)
## Overview
Adds a new workflow action, **Pick Record**, that selects **one** record
from a configured candidate pool and exposes the chosen record as the
step's output. Downstream steps can then reference it through the normal
variable picker — e.g. assign an owner in an _Update Record_ step by
setting **Account Owner = `{{step.<pickRecordId>.id}}`**.
This is the foundation for building **assignment workflows**
(round-robin / load-balanced owner assignment, reviewer rotation, etc.)
in Twenty.
## This is PR 1 of a 3-PR stack
| PR | Strategy | Adds |
|----|----------|------|
| **1 (this one)** | `RANDOM` | The whole `PICK_RECORD` action,
end-to-end, stateless |
| 2 | `ROUND_ROBIN` | A persistent, atomically-incremented per-step
cursor + the strategy selector UI |
| 3 | `LOAD_BALANCED` | "fewest related records wins" via an aggregate
count |
Each PR widens the `strategy` enum (a backward-compatible change), so no
data migration is needed between them.
## How it works
- **Editor**: pick an Object, then pick the candidate records (a
multi-record selector). A random record is selected from that pool at
run time.
- **Output**: a single record of the chosen object — the same output
shape as `CREATE_RECORD`/`UPDATE_RECORD` — so it drills into
`{{step.x.id}}`, `{{step.x.name}}`, … in the variable picker.
- **Execution**: reuses `FindRecordsService` to fetch the pool (`id IN
(recordIds)`, which also transparently drops any deleted candidates),
then returns one at random.
## Design decisions & tradeoffs
1. **Standalone step that outputs a variable, not an inline "random"
mode on the relation field.** This mirrors Attio's round-robin block.
The decisive reason is composition: the chosen record is almost always
reused (assign owner **and** create a follow-up task for them **and**
email them). A variable is chosen once and reused everywhere; an inline
per-field value would re-roll independently in each place. It also keeps
the (stateful) round-robin/load-balanced logic out of the field inputs.
Tradeoff: one extra step to wire up vs. an inline control — accepted for
the composability win. An inline "Assign automatically" entry point can
still be layered on later as sugar that inserts this step.
2. **Co-located in the `record-crud` action module and reuses
`FindRecordsService`.** Avoids duplicating module wiring (auth context,
permissions, object-metadata resolution) and the data-access path.
Tradeoff: "Pick" is a selection rather than a CRUD op, so the folder
name is slightly broad; chose reuse + low risk over a separate module.
Can be extracted if the family grows.
3. **`strategy` exists in the schema (defaulted `RANDOM`) but the
selector is hidden in this PR.** A dropdown with a single option would
be UX slop, and adding the field only in PR 2 would force a data
backfill for any `PICK_RECORD` steps created in between. Keeping the
field now (hidden) avoids both. PR 2 introduces the selector once
there's a real choice.
4. **Pool is an explicit static list (`recordIds`) for v1.** Matches the
most common assignment case ("rotate among these N people") and reuses
the existing `FormMultiRecordPicker`. A filter-based pool (reusing the
Find Records filter UI) and a list-from-a-previous-step pool are natural
follow-ups, intentionally out of scope here to keep the stack focused on
the three strategies.
5. **Output schema is computed on the frontend** (like `CREATE_RECORD`),
derived from `input.objectName` — so it is **not** added to
`PERSISTED_OUTPUT_SCHEMA_TYPES` and needs no server-side schema
computation.
6. **Validation**: `PICK_RECORD` is added to object-name metadata
validation (so a deleted/invalid target object is flagged) via a
dedicated `OBJECT_TARGETING_ACTION_TYPES` set — deliberately **not** to
`VARIABLE_CONSUMING_ACTION_TYPES`, because a static pool legitimately
references no upstream variable and would otherwise raise a spurious "no
variable reference" warning.
7. **Empty pool → step error** at run time (respecting the step's
error-handling options) rather than a silent no-op, since an empty pool
is a misconfiguration or fully-deleted set.
8. **`Math.random`** is used for selection — no cryptographic guarantee
is needed for assignment fairness.
## Testing
Per our testing convention (integration test over service/`.spec`
tests): added `pick-record-workflow.integration-spec.ts`, which builds a
workflow with a manual trigger + a `PICK_RECORD` step, configures a
known two-record pool, runs it, and asserts the run completes and the
picked record is **always** within the configured pool (verifying the
pool filter) across repeated runs.
Local verification (typecheck + lint for shared/server/front) is green;
running the integration suite and attaching editor screenshots in a
follow-up comment.
## Follow-ups
- PR 2: `ROUND_ROBIN` + persistent atomic cursor (Redis `incrBy` vs. a
Postgres counter table — tradeoff to be documented on that PR) +
strategy selector.
- PR 3: `LOAD_BALANCED`.
- Later (not in this stack): filter-based / variable-list pools, an
inline "Assign automatically" entry point on relation fields, OOO-skip /
weighting.
https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21899?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. -->
|
||
|
|
a0689d1577 |
feat(workflow): condition filter on database-event triggers (#21868)
## Problem
Connecting a mailbox bulk-creates contacts via the email/calendar sync,
and each `person.upserted` fires the seeded **"Create company when
adding a new person"** workflow. The trigger enqueues one run per record
(no batching) and each run bills several `WORKFLOW_NODE_RUN` events — so
a single mailbox connect can rack up tens of thousands of runs and
exhaust credits on a brand-new workspace. The workflow is also redundant
on that path: the sync already creates the company from the email domain
and links the person to it.
## What this does
Adds an optional, user-defined **filter** to database-event (listener)
triggers, evaluated in the listener **before a run is enqueued**.
Non-matching events never create a run, so they consume zero execution
credits. This is the Filter node's capability, lifted to the trigger
level, and available for all event types (created / updated / upserted /
deleted).
The seeded "Create company when adding a new person" workflow now
carries a visible trigger filter — `Created by → Source is not Email`
**and** `is not Calendar` — so it no longer runs for sync-created
contacts, while still running for manually / API / CSV-added people.
## How (reuse)
- **Backend:** extracted `evaluateStepFilters()`, shared by the Filter
action and the trigger listener's new `eventMatchesRecordFilter` gate.
The record is exposed under the `trigger` key so filters reference it
exactly like steps do (`{{trigger.properties.after.…}}`).
- **Shared:** one optional `filter` added to the database-event trigger
zod schema; the front-end type derives from it (settings stay JSON — no
codegen).
- **Frontend:** extracted `WorkflowStepFilterBuilder` from the Filter
action's body; both the Filter action and the trigger editor render it.
The field picker needed no changes — at the trigger it already resolves
to the record's own fields via `TRIGGER_STEP_ID`.
## Scope / decisions
- **No migration for existing workspaces** (by request) — only newly
created workspaces get the filtered default; already-created workspaces
keep the always-on workflow.
- Deliberately did **not** add relation-enrichment to the upsert path
(it would add a DB lookup to the very bulk-sync path we're relieving).
Trigger filters work on the record's own scalar/composite fields (e.g.
`createdBy.source`); relation-based filters work on created/updated
where enrichment already runs.
## Verification
- Typecheck: `twenty-shared`, `twenty-server`, `twenty-front` all green.
- Lint (diff, autofix): 0 warnings / 0 errors across all three.
- Unit tests: a new `evaluate-step-filters` spec exercising the exact
`createdBy.source IS_NOT` seed mechanism, plus new listener specs
proving non-matching events are not enqueued. All backend
filter/listener suites pass.
- Not run here: integration tests (need a DB) and Storybook.
https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De
---
_Generated by [Claude
Code](https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21868?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: Claude <noreply@anthropic.com>
|
||
|
|
4075018834 |
fix(workflow): label manual trigger record output as Record/Records (#21832)
## What
The manual trigger output schema exposed the triggering record(s) under
a node labeled **Payload**. Relabels it to match what the node actually
contains:
- **Single-record** availability → **Record**
- **Bulk-records** availability → **Records**
## Why
"Payload" was a misnomer — the node holds the record(s) that triggered
the workflow. This is a display-label-only change.
## Notes for reviewers
- **No migration.** The persisted output schema key stays `payload`, so
existing variable references (`{{trigger.payload.x}}`) are unaffected.
- The front recomputes the output schema on the fly
(`computeStepOutputSchema`), so the variable picker shows the new labels
immediately, including for existing triggers.
- The backend (`workflow-schema.workspace-service`) is updated to match
for newly persisted/re-saved schemas. Previously persisted schemas keep
"Payload" until re-saved.
- Added `WORKFLOW_TRIGGER_RECORD_LABEL` /
`WORKFLOW_TRIGGER_RECORDS_LABEL` and removed the now-unused
`WORKFLOW_TRIGGER_PAYLOAD_LABEL`.
- Unit tests updated for both single and bulk cases (55/55 passing).
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21832?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. -->
|
||
|
|
3675f264f1 |
Infer record pickers for record-typed logic function workflow inputs (#21494)
## Context Logic functions can declare workflow inputs typed as records or arrays of records (e.g. the People Data Labs enrichment functions), but the workflow builder rendered those as a plain text input with a variable picker, which is not usable. ## What this does - Adds an `objectUniversalIdentifier` link on input schema properties, so a record-typed input is tied to a workspace object. - The SDK build infers it from a `TwentyRecord<'objectUniversalIdentifier'>` marker type in the handler signature, reading the object's universal identifier straight from the source; explicit input schemas can still set the field directly. - The workflow builder renders these inputs as a single record picker or a record multi-select with the variable picker on the right. Selected records are stored as record ids; `TwentyRecord<UID>` is a branded `string`, so the handler signature reflects that it receives ids (a bound variable resolves to whatever the referenced step produced). - The multi-select collapses overflowing chips into a `+N` badge (reusing `ExpandableList`) and its variable picker offers both record objects and fields. - Updates the People Data Labs enrichment inputs as the reference implementation. <img width="802" height="824" alt="CleanShot 2026-06-12 at 16 54 10@2x" src="https://github.com/user-attachments/assets/a0896d74-0aab-49bd-a173-14c578a2e533" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21494?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. --> |
||
|
|
c6309fd92b |
feat(workflow): auto-layout steps on AI workflow creation via shared tidy-up (#21756)
## Context
The workflow builder has a "Tidy up" action that auto-positions steps
using a
Dagre layout. However, this lived entirely in the frontend and depended
on node
dimensions measured by React Flow after rendering in the browser.
As a result, workflows (and steps) created through AI Chat / MCP tools
were never
laid out: `create_complete_workflow` accepted optional `stepPositions`
that the
LLM had to invent, and `create_workflow_version_step` stored an optional
position
verbatim. In practice this produced overlapping / poorly positioned
steps.
## What this does
Extracts the tidy-up layout into a pure, frontend-free util in
`twenty-shared` and
reuses it from both the frontend tidy-up and the server, so
AI/MCP-created
workflows are auto-laid out at creation time.
### twenty-shared
- New `computeWorkflowLayout({ nodes, edges, options? })` — a pure Dagre
layout over
a minimal `{ id, width, height }` / `{ source, target }` graph,
returning
top-left-anchored positions (matching React Flow). Ignores edges
pointing to
unknown nodes.
- New constants: `WORKFLOW_LAYOUT_DEFAULT_OPTIONS`
(ranksep/nodesep/rankdir) and
`WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS` (estimated node size for
server-side
layout, where measured sizes are unavailable).
- Added `@dagrejs/dagre` dependency.
### twenty-front
- `getOrganizedDiagram` now delegates to `computeWorkflowLayout`,
passing real
measured node sizes. No behavior change for users.
### twenty-server
- New `WorkflowVersionWorkspaceService.autoLayoutWorkflowVersion(...)`
builds the
graph topology via the existing `buildWorkflowGraph` (covers if-else
branches and
iterator loops), feeds estimated node sizes into
`computeWorkflowLayout`, and
persists through the existing `updateWorkflowVersionPositions`.
- `create_complete_workflow`: removed `stepPositions` from the tool
schema; the
server always auto-lays out after creation/edges.
- `create_workflow_version_step`: re-tidies the whole version after each
added step
(wired at the tool level so the builder UI is unaffected) and dropped
the now
redundant `position` field.
## Notes
- Server-side layout uses estimated node sizes, so it is "good enough";
opening the
workflow and running the existing FE tidy-up refines it with real
measured sizes.
- Auto-layout is wired in the MCP tools, not in the shared creation
service, so
manual step creation in the builder UI is unchanged.
## Test plan
- [x] `twenty-shared` unit tests for `computeWorkflowLayout` (linear
chain, if-else
spread, dangling-edge safety)
- [x] `twenty-shared` builds; `twenty-server` and `twenty-front`
typecheck
- [x] Lint/format clean on changed files
- [ ] Create a workflow via AI Chat / MCP and confirm steps are laid out
without
overlap
- [x] Add a step via MCP and confirm the version is re-tidied
- [ ] Frontend "Tidy up" still behaves as before
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21756?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. -->
|
||
|
|
d67aa2889b |
feat(workflow): add update_agent tool and responseFormat-aware AI Agent step schema (#21755)
## Summary
Brings the workflow AI/MCP tooling for **AI Agent steps** to parity with
the existing **CODE / logic-function** flow, and makes AI Agent output
references reliable in validation and the variable picker.
Just like a CODE step needs a logic function, an `AI_AGENT` step needs
an agent. The agent is already created as a side effect of step
creation; this PR adds the missing "configure it" tooling and fixes the
output schema so it reflects the agent's actual response format.
## Changes
### New `update_agent` MCP tool
- `update-agent.tool.ts`: lets the assistant configure the agent backing
an `AI_AGENT` step — `prompt` (system prompt), optional `modelId`,
optional `responseFormat` (text or structured json) — via
`AgentService.updateOneAgent`. Direct analog of
`update_logic_function_source`.
- Wired through: added `agentService` to `WorkflowToolDependencies`,
injected `AgentService` and registered the tool in
`workflow-tool.workspace-service.ts`, imported `AiAgentModule` in
`workflow-tools.module.ts`.
### Guided creation flow (mirrors CODE)
- `create-workflow-version-step.tool.ts`: `enrichResultWithNextStep` now
returns an `AI_AGENT` hint instructing the assistant to call
`update_agent` with the step's `settings.input.agentId` (and to set the
task prompt via `update_workflow_version_step` if needed).
- `create-complete-workflow.tool.ts`: rejects `AI_AGENT` steps (it
inserts steps directly and never runs the side effect that creates the
agent), with a description note pointing to
`create_workflow_version_step` + `update_agent`. Same treatment CODE
already gets.
### Correct output schema for AI Agent steps
- Backend `computeStepOutputSchema`
(`workflow-schema.workspace-service.ts`): the `AI_AGENT` case now
derives the output schema from the agent's `responseFormat` instead of a
hardcoded `{ response }`:
- text → `{ response: string }`
- json → one leaf per `responseFormat.schema.properties` field
This makes workflow validation resolve `{{stepId.fieldName}}` references
against the agent's real output (previously json agents validated wrong:
real fields rejected, `{{stepId.response}}` accepted but undefined at
runtime).
- Frontend `useStepsOutputSchema.ts`: when an `AI_AGENT` step has no
persisted `outputSchema`, fall back to generating it from the agent's
`responseFormat` (via `FindManyAgents` + the existing
`agentResponseSchemaToOutputSchema`) instead of the hardcoded `{
response }`. Keeps the variable picker correct for structured agents.
## Why output schema matters
Validation resolves every `{{stepId.path}}` against the referenced
step's `settings.outputSchema` (`validateWorkflowVariableReferences`).
The agent step's output schema is therefore the single source of truth
for "is the right output referenced." Because the runtime output depends
on `responseFormat` (text → `{ response }`, json → schema fields
directly), the schema must be derived from `responseFormat` to be
accurate.
## Not solved issue
We want each AI_AGENT step's settings.outputSchema to always match the
backing agent's responseFormat:
- text → { response }
- json → one field per responseFormat.schema.properties
That output schema is what everything downstream relies on: validation
(validateWorkflowVariableReferences resolves {{stepId.field}} against
it), the frontend variable picker (useStepsOutputSchema), and it's also
persisted inside workflowVersion.steps.
Agent should be unique source of truth but syncing agent -> step is not
possible
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21755?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. -->
|
||
|
|
39e00d5853 |
feat(workflow): expected output schema for runtime-output steps + validation (#21744)
## Summary Extends the workflow validation layer (introduced in #21422) and adds a new "expected output schema" capability for steps whose output structure is only known at runtime. Some workflow steps (HTTP Request, Code, Logic Function, AI Agent (coming soon), Webhook trigger) don't have a statically known output shape, so downstream steps can't resolve `{{step.x.y}}` variable paths or validate them. This PR lets users declare a **sample/expected output** for those steps, derives an output schema from it, and uses that schema both to power variable resolution and to surface validation issues at build time. ## What's included ### Expected output schema (shared schemas + types) - New `expectedOutputSchemaShape` reused across the HTTP request, code, logic function and AI agent action settings schemas, plus the webhook trigger schema (`expectedOutputSchema` optional loose object). - Mirrored on the server-side action/trigger settings types. ### Output schema computation (server) - `workflow-schema.workspace-service` now computes a step's output schema from the user-declared `expectedOutputSchema` sample (via `getOutputSchemaFromValue`) when no statically computed schema is available. ### Validation layer (server) - `STEP_HAS_NO_VARIABLE_REFERENCE` (warning): flags steps of `VARIABLE_CONSUMING_ACTION_TYPES` (HTTP_REQUEST, CODE, LOGIC_FUNCTION, SEND_EMAIL, record CRUD) that reference no upstream variable. - `LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH` / `AI_AGENT_OUTPUT_SCHEMA_MISMATCH` (warnings): compare the declared output schema against the expected sample using the new shared `getOutputSchemaMismatchIssues` util (missing keys, leaf/object mismatches, type mismatches). - Trigger is now validated alongside steps (trigger type requirements + trigger variable references). - Validation issues no longer return both `suggestions` and `availablePaths` when they are identical (avoids redundant, costly payloads). ### Shared utilities - New `getOutputSchemaMismatchIssues` (+ tests) in `twenty-shared/logic-function`. - Moved `agentResponseSchemaToOutputSchema` from `twenty-front` into `twenty-shared/ai` so it can be reused on both sides. ### Frontend - New `WorkflowExpectedOutputBodyInput` component (JSON sample editor with validation) used by HTTP request, code, logic function and AI agent step editors. - New `resolvePersistedStepOutputSchema` util + `useStepsOutputSchema` update: resolves a step's output schema from `outputSchema`, falling back to `expectedOutputSchema`, with an AI_AGENT default. - HTTP request / code / logic function editors persist `expectedOutputSchema` and derive `outputSchema` from it. - Webhook trigger default settings include `expectedOutputSchema`. BONUS : iterator loop validation <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21744?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. --> |
||
|
|
105f9565a5 |
feat(workflow): surface manual-trigger payload + metadata in variable picker (#21692)
## Summary Step 2 of the manual-trigger output schema restructuring (expand → display → migrate → contract). Builds on the now-merged #21676 (which expanded the runtime payload to serve `payload` and `metadata` siblings at the trigger root). This PR **surfaces** those in the variable picker as nested, expandable nodes: - `trigger.payload.{record fields}` — the record(s) that triggered the run - `trigger.metadata.workspaceMemberId` — who triggered it The flat root fields (`trigger.id`, etc.) remain available, so existing saved variable references keep working until a later migration phase moves them. ### Changes - **twenty-shared**: metadata/payload label constants + `build-manual-trigger-metadata-node` util + barrel exports. - **twenty-front**: `computeStepOutputSchema` MANUAL branch now nests `payload` (RecordNode for SINGLE_RECORD, array Node for BULK_RECORDS, omitted for GLOBAL) and `metadata`; `ManualTriggerOutputSchema` type updated to `{ payload?; metadata }`. - **twenty-server**: `computeTriggerOutputSchemaFromAvailability` mirrors the same nested shape for server-side validation. The key is `metadata` (not `_metadata`) — custom fields can't start with `_`, so collision risk was deemed acceptable. ## Test plan - [x] `npx nx build twenty-shared` - [x] `computeStepOutputSchema` unit tests pass (55) - [x] Manual: create a manual-trigger workflow (GLOBAL / single-record / bulk), confirm the picker shows `payload` and `metadata` as expandable folders and that selecting a field yields `{{trigger.payload.<field>}}` / `{{trigger.metadata.workspaceMemberId}}` > Note: server typecheck has pre-existing unrelated failures on main (Stripe billing mocks, gmail mocks); none touch workflow files. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21692?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. --> |
||
|
|
8130fa1c45 |
feat(workflow): use workspace member as variable sender for emails (#21582)
## Summary
Lets the email workflow node sender be driven by a variable: the
connected-account field accepts a `{{variable}}`, and the backend
resolves it to a connected account at run time.
<img width="436" height="195" alt="Capture d’écran 2026-06-16 à 11 59
27"
src="https://github.com/user-attachments/assets/18eee21e-aed6-4447-9bf4-5cb0e2cfc371"
/>
### Email sender by variable
- The connected-account field now accepts a `{{variable}}` via the
variable picker (uses `FormSelectFieldInput` with
`WorkflowVariablePicker`), with a hint to pick a connected account or
set a workspace member as a variable.
- The email workflow action resolves the stored sender value explicitly:
if it is a `workspaceMemberId` (a UUID matching a workspace member), it
resolves that member's first connected account; otherwise the value is
used directly as a `connectedAccountId`.
- Resolution lives in `EmailWorkflowActionBase` and applies to both
`SEND_EMAIL` and `DRAFT_EMAIL`. If a matching member has no connected
account, the run fails fast with a clear message (no silent fallback).
- `DRAFT_EMAIL` also fails fast when the resolved connected account is
missing the required OAuth scopes (`gmail.compose` / `Mail.Send`), via a
server-side `getMissingDraftEmailScopes` util that mirrors the front-end
check.
- Existing workflows with a hardcoded `connectedAccountId` keep working
unchanged (no migration needed).
> Note: exposing the running workspace member as a manual-trigger
variable (`_metadata.workspaceMemberId`) is split into a follow-up PR.
## Test plan
- [x] Backend unit tests for `draft-email-tool`,
`get-missing-draft-email-scopes`, and the `send-email` / `draft-email`
workflow actions (incl. workspace-member sender resolution)
- [x] Lints clean on all changed files
- [x] Manual: configure an email node with a workspace-member variable
sender and confirm it resolves and drafts/sends
- [x] Manual: confirm a member lacking compose permission fails the run
with the permission message
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21582?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. -->
---
### Update — scoped to Draft Email only
The sender variable picker is now exposed **only on the Draft Email
node**. The Send Email node keeps a plain account select (no variable
picker, no variable hint) until we enable it there in a follow-up.
Backend resolution still lives in `EmailWorkflowActionBase` and remains
generic, so enabling the picker for Send Email later requires no backend
change.
|
||
|
|
1ad919955a |
Support variables file email attachment (#21613)
<!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21613?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. --> |
||
|
|
ceb7698689 |
fix(ai) - workflow tool outputs optim + display fix (#21500)
<!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21500?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. --> |
||
|
|
4be76e3fd1 |
Support morph relations in workflow record nodes (#21403)
## Support morph (polymorphic) relations in workflow record nodes
Morph relations (e.g. a polymorphic `Owner` on `Pet` targeting `Person`
or `Company`) were not selectable in the workflow **Create / Update /
Upsert Record** nodes. This PR adds full support for setting them.
### What changed
**Frontend**
- `shouldDisplayFormField`: allow `MORPH_RELATION` (many-to-one) so
morph fields appear in record forms.
- New `FormMorphRelationToOneFieldInput`: a polymorphic record picker
across the morph's target objects, storing a self-describing value `{
targetObjectMetadataId, id }`.
- Wired the morph branch into `FormFieldInput`.
**Backend**
- New `formatWorkflowRecordMorphRelationFields` util: resolves the form
value (stored under the base field name, e.g. `owner`) into the correct
per-target join column (`ownerCompanyId`), nulling siblings to keep
exactly one target referenced.
- Wired into the create / update / upsert workflow actions (update also
expands `fieldsToUpdate` to the concrete join columns).
### Permissions handling
- The picker's search is scoped to only the morph targets the user can
read (`canReadObjectRecords`), so it no longer breaks when a target
object is inaccessible.
- If an existing value points to an object the user can't read, the
field shows the reused **"Not shared"** lock display instead of an empty
field, while remaining editable when other targets are readable.
### Notes
- No data schema / migration changes — reuses the existing per-target
morph columns and stores the selection in the existing workflow step
JSON settings.
<img width="607" height="717" alt="Screenshot 2026-06-10 at 14 57 40"
src="https://github.com/user-attachments/assets/496442a1-04a5-40f8-8b56-b28e38b00d5a"
/>
Also handles the case where the selected record is not readable
<img width="596" height="737" alt="image"
src="https://github.com/user-attachments/assets/c5ffb94e-3838-4db5-853e-f8e490331f23"
/>
|
||
|
|
9bb98fa5b5 |
fix(billing): don't crash when workspace has no active subscription (#21510)
## Problem
Sentry (high severity, SLA-breaching): `Billing Subscription Not Found:
No active subscription found for workspace …`
The `billingSubscription` workspace-cache provider
(`WorkspaceBillingSubscriptionCacheService.computeForCache`) called
`getCurrentBillingSubscriptionOrThrow`. For a workspace whose
subscription is fully canceled, `getCurrentBillingSubscription` filters
out `Canceled` and returns `undefined`, so the provider **threw**
`BILLING_SUBSCRIPTION_NOT_FOUND`.
That cache key is read on every usage-recording path:
- workflow execution
(`WorkflowExecutorWorkspaceService.sendWorkflowNodeRunEvent`)
- AI usage (`AiBillingService`)
- logic-function execution (`LogicFunctionExecutorService`)
- app charges (`AppBillingService`)
- the gate `BillingUsageService.canFeatureBeUsed` /
`hasAvailableCredits` / `decrementAvailableCreditsInCache`
- the cancellation webhook
(`invalidateAndRecompute('billingSubscription')`)
So any of these throws an unhandled exception for a
no-active-subscription workspace. The intent was clearly to tolerate
this state — `canFeatureBeUsed` already guards with
`isDefined(billingSubscription)` and the workflow runner logs *"there is
no subscription for this workspace"* — but the throwing provider made
those guards unreachable.
## Fix
- `computeForCache` now returns `FlatBillingSubscription | null` via the
non-throwing `getCurrentBillingSubscription`, and the cache type allows
`null`.
- Every consumer guards the absent case (`isDefined` / optional
chaining) and no-ops: usage events still emit with an undefined
`periodStart`, credits aren't decremented, `hasAvailableCredits` returns
`false`.
- `getCurrentBillingSubscriptionOrThrow` is **left untouched** for the
many callers (resolver, subscription-update, etc.) that genuinely
require a subscription.
## Test
Adds `workspace-billing-subscription-cache.service.spec.ts`: the
provider returns `null` when there's no active subscription (regression)
and the flattened subscription when one exists.
All 142 tests across the billing / ai-billing / workflow-executor suites
pass; `oxlint --type-aware` and `oxfmt` are clean.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21510?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. -->
|
||
|
|
ba94c3b857 |
feat(workflow): idempotent stop + retry failed runs from failing step (#21458)
https://github.com/user-attachments/assets/5a25396f-8959-4bd8-93cb-1187559ffe5f ## Summary Two workflow-run improvements, with all non-trivial logic isolated in pure, unit-tested utils. ### 1. Idempotent stop `stopWorkflowRun` no longer throws when a run is already in a terminal status (`COMPLETED` / `FAILED` / `STOPPED`) or already `STOPPING`; it returns the run unchanged. This fixes: - bulk stop aborting on the first non-stoppable run in a mixed/select-all selection, - the click-vs-processing race on a single run (run finishes between click and mutation). It also releases the cached not-started throttle slot when stopping a `NOT_STARTED` run (prevents counter drift), and ends runs with no `state` directly. ### 2. Retry a failed run from the failing step New `retryWorkflowRun` mutation (same guards/passthrough as `stopWorkflowRun`). It resets the failed step(s) to `NOT_STARTED`, flips the run to `RUNNING`, and enqueues a `RunWorkflowJob` with the steps to re-execute; downstream execution and status computation are unchanged. Logic lives in pure utils: - `build-retry-step-infos.util.ts` - decides per failed step what to reset; delegates iterator-specific logic to `build-retry-iterator-step-infos.util.ts` (an iterator that failed mid-loop is restored to `RUNNING` with cursor preserved, an iterator that failed itself restarts its whole loop). - `get-runnable-step-ids.util.ts` - reuses the executor's `shouldExecuteStep` to also resume branches that never started (avoids hangs), excluding loop-interior steps. The service method only orchestrates; the job's status check is a race guard (retriability is enforced in the service before enqueue). A "Retry" command menu item surfaces only for `FAILED` runs (`someEquals(selectedRecords, "status", "FAILED")`). ### 3. Keep the run diagram visible across regenerations The run diagram is regenerated on every run state change, producing fresh nodes without the dimensions Reactflow had measured. Reactflow hides unmeasured nodes until it re-measures them, so the diagram could flicker and disappear when the last regeneration before going idle left nodes unmeasured (reproducible after retrying a failed run). The regenerated nodes now carry over the previously measured dimensions (by id) so they stay rendered. ## Test plan - [x] Unit tests for both retry utils (9 cases: plain failed step, non-failed untouched, iterator mid-loop restore, iterator self-failure, frontier parent gating, entry steps, loop-interior exclusion, parallel branches) - [x] `twenty-server` + `twenty-front` typecheck - [x] `lint:diff-with-main` clean for both packages - [x] Manual: retry a failed run repeatedly and confirm the diagram stays visible - [ ] Manual: stop a COMPLETED/mixed selection (no error), retry a failed run and confirm it resumes from the failing step |
||
|
|
b36c0c51c3 |
fix(server): keep workflow command menu item label in sync with workflow name (#21490)
## Summary Fixes #20766 — manual-trigger workflows showed `Manual Trigger` in the command menu instead of the workflow's name. Root cause (confirmed against a live instance): the command menu item's `label` is written **only at activation** in `createOrUpdateCommandMenuItem`, from `workflow.name`, with a hardcoded `'Manual Trigger'` fallback. So: - a workflow activated while unnamed gets the misleading `Manual Trigger` label, and - renaming the workflow afterwards never updates the label (`workflow.updateOne` had no label-related hook). Changes: - Add `getWorkflowCommandMenuItemLabel` helper and use it in activation; the empty-name fallback is now `Untitled Workflow` (consistent with the rest of the UI) instead of `Manual Trigger`. - Add `WorkflowCommandMenuSyncWorkspaceService` that updates the active version's command menu item label/shortLabel from the workflow name (idempotent, no-op for non-manual / inactive workflows). - Add `workflow.updateOne` and `workflow.updateMany` post-query hooks that call the sync service, registered in `WorkflowQueryHookModule`. Out of scope (separate follow-up): the activation create path can produce duplicate command items for one `workflowVersionId`; recommend making it idempotent / adding a unique constraint. ## Test plan - [x] `oxlint --type-aware` + `oxfmt` clean on changed files - [x] Editor TS diagnostics clean (full `nx typecheck` was starved by local dev servers) - [ ] New integration test `workflow-command-menu-label.integration-spec.ts`: - labels the command menu item with the workflow name on activation - updates the label when the workflow is renamed - falls back to `Untitled Workflow` when the name is cleared <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21490?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. --> |
||
|
|
a8a8bbb2ed |
feat(workflow): add offset to Find Records node for pagination (#21484)
<img width="471" height="362" alt="Capture d’écran 2026-06-12 à 15 38 45" src="https://github.com/user-attachments/assets/9656d3a6-6f56-4587-add6-55c0a0a32482" /> ## Summary The workflow Find Records (search) node previously exposed only `objectName`, `filter`, `sort`, and `limit` (capped at `QUERY_MAX_RECORDS` = 200), with no way to page beyond the first page of results. This adds an optional **Offset** to the node so a workflow can fetch an arbitrary page (`offset = pageIndex * limit`) while keeping the same filter and sort. The underlying `FindRecordsService` already accepts `offset` (it forwards it to the query runner's `skip`, and stabilizes ordering with an `id` tiebreaker), so this change just threads `offset` through the remaining layers: - `workflowFindRecordsActionSettingsSchema` (shared zod schema) — new optional `offset` - `FindRecordsInput` type — new optional `offset?: number` - `find-records.workflow-action.ts` — forwards `offset` to `FindRecordsService.execute` - `WorkflowEditActionFindRecords.tsx` — new "Offset" number input (non-negative, defaults to 0) with form state + persistence - Default `FIND_RECORDS` step settings — `offset: 0` ### Notes / non-goals - Offset-only, single page: the node returns one page. Looping over all pages inside one run is not included (the Iterator action loops a static array and cannot re-query). The node output already returns `totalCount`, so a workflow can compute total pages as `ceil(totalCount / limit)`. - Offset on very large/changing datasets can be slow or skip/duplicate rows; cursor/keyset pagination would be a future follow-up. ## Test plan - [x] Create a Find Records node, set Limit=50, Offset=0 → returns first page - [x] Set Offset=50 with the same filter/sort → returns the second page (no overlap) - [x] Negative offset shows a validation error and is not saved - [x] Existing Find Records nodes (no offset stored) still run, defaulting to offset 0 - [x] Typecheck/lint pass in CI <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21484?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. --> |
||
|
|
fefd9d7704 |
feat(workflow) - Add validation layer (#21422)
Add workflow validation framework and consolidate output schema types/search logic into twenty-shared This PR introduces a comprehensive workflow validation system that catches configuration errors at build-time, and consolidates the fragmented output-schema type definitions and variable-search logic from the front-end into twenty-shared **Workflow validation** — A new system that checks workflows for errors before activation: graph connectivity (unreachable steps, dangling references), step parameter schemas (via Zod), variable references (typos, wrong step order), and workspace metadata (non-existent objects). Returns structured errors/warnings with "did you mean?" suggestions. Runs automatically after create_complete_workflow and update_workflow_version_step, and is also available as a standalone validate_workflow tool. **Output schema consolidation** — Moves all output schema types and the variable-search logic from scattered front-end files into twenty-shared, replacing ~800 lines of duplicated per-schema-type code with a single unified searchVariableInOutputSchema dispatcher. To do : - validation on CODE and AGENT step --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
20c83e1f86 |
fix(kanban): preserve scroll on board re-init + propagate same-column reorders via SSE (#20637)
closes https://discord.com/channels/1130383047699738754/1504130730840821860 https://github.com/user-attachments/assets/d5833031-01c6-4e46-b699-c29c42435a53 ## Summary Fixes two related issues with the kanban (board view) collaboration experience: 1. **Scroll-to-top on every data change** — `triggerRecordBoardInitialQuery` always scrolled the board to the top, even when re-initializing for a single-record data change (SSE echo of your own mutation, a collaborator's update). Scroll reset only makes sense when the dataset itself changes (filter / sort / group). 2. **Same-column reorders by other users did not propagate** — the server's diff function stripped `FieldMetadataType.POSITION`, so position-only updates produced empty `updatedFields` and short-circuited event emission entirely. SSE clients never received them. ## What's in here - **Frontend** — `useTriggerRecordBoardInitialQuery` now exposes a `triggerRecordBoardInitialQueryWithoutScrollReset` variant; data-driven re-inits in `RecordBoardDataChangedEffect` use it, while genuine filter / sort / group changes keep the scroll-resetting `triggerRecordBoardInitialQuery`. `getRecordBoardEffectsForUpdateInputs` classifies each update as `trigger-initial-query` / `reposition-records` / `none`. For position- or group-only changes we skip the re-query and reposition records in place in the store (`useRepositionRecordsOnBoard`), which avoids the flicker and preserves scroll. - **Server** — removes `POSITION` from `objectRecordChangedValues`' strip list, so position-only updates emit a non-empty diff and flow through SSE. Position is now treated as a field like any other across all event consumers (SSE, webhooks, workflows, logic functions); a trigger with an explicit field filter still excludes it. |
||
|
|
71480c3888 |
fix: gracefully handle missing logic functions during workflow destroy (#21362)
## Summary - Wraps `deleteOneWithSource` calls in `.catch()` during workflow/step destruction so that a missing logic function (valid UUID but already deleted) no longer crashes the entire destroy operation - Adds a `Logger` to `WorkflowVersionStepOperationsWorkspaceService` for the warning - Fixes test mock to return a resolved Promise and use a valid UUID ## Context When a CODE step references a `logicFunctionId` that is a valid UUID but the logic function no longer exists (e.g. deleted by a previous operation or orphaned), the destroy fails with "Logic function with id X not found". This blocks users from cleaning up workflows. ## Test plan - [x] Destroy a workflow with CODE steps whose logic functions already exist → succeeds as before - [ ] Destroy a workflow with CODE steps referencing a deleted/non-existent logic function → succeeds with a warning log instead of crashing |
||
|
|
9c66975520 |
isCustom deprecation for Objects and Fields (#21228)
## Context
`isCustom` was a legacy denormalized boolean on `ObjectMetadataEntity`
and `FieldMetadataEntity`.
Now that every metadata row carries `applicationId` (via
`SyncableEntity`), "is this custom" is fully derivable, and the stored
boolean was a redundant second source of truth that could drift.
The real meaning of `isCustom` is **"the owning application is not the
twenty-standard application"** — i.e. `!belongsToTwentyStandardApp`.
Note this is *not* "belongs to the workspace custom app" as I initially
thought: third-party-application
objects/fields are custom too.
The standard application has a globally stable `universalIdentifier`, so
the value derives with no per-workspace lookup.
## Changed
## `isCustom` checks — before → after
`isCustom` is no longer a stored column. The table below lists every
site that branched on it and how it resolves now. The unifying rule:
`isCustom ≡
!isTwentyStandardApplicationUniversalIdentifier(applicationUniversalIdentifier)`.
### Server — behavioural checks
| Location | Purpose | Before | Now |
|---|---|---|---|
| `utils/compute-object-target-table.util.ts` | Physical table name `_`
prefix | `computeTableName(nameSingular, objectMetadata.isCustom)` |
derives from `applicationUniversalIdentifier` (single source for all
table-name callers) |
| `twenty-orm/factories/entity-schema.factory.ts` +
`…/entity-schema-metadata.type.ts` | ORM table name (hot path) |
`object.isCustom` | `object.applicationId !== standardApplicationId`
(computed in `buildEntitySchemaMetadataMaps`) |
|
`twenty-orm/repository/workspace-{delete,soft-delete,update}-query-builder.ts`
| Table name for mutations | `computeTableName(nameSingular,
objectMetadata.isCustom)` | `computeObjectTargetTable(objectMetadata)` |
| `index-metadata/utils/generate-deterministic-index-name-v2.ts` | Index
name hash (must stay bit-identical) | `flatObjectMetadata.isCustom` |
derives from `applicationUniversalIdentifier` |
| `object-metadata/object-record-count.service.ts` | Table name for
record count | `computeTableName(nameSingular, isCustom)` |
`computeObjectTargetTable(flatObjectMetadata)` |
|
`workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts`
| Match seed config by table name | `computeTableName(item.nameSingular,
item.isCustom)` | `computeObjectTargetTable(item)` |
| `commands/workspace-export/workspace-export.service.ts` +
`…/utils/generate-workspace-schema-ddl.util.ts` | Export table name (raw
entity) | `objectMetadata.isCustom` |
`!isTwentyStandard…(objectMetadata.application?.universalIdentifier)` |
|
`flat-field-metadata/services/flat-field-metadata-type-validator.service.ts`
| Block users creating reserved field types |
`args.flatEntityToValidate.isCustom` |
`!args.flatEntityToValidate.isSystem` |
| `api/common/.../common-create-many-query-runner.service.ts` | Don't
let client overwrite system `createdBy` |
`createdByFieldMetadata.isCustom === false` |
`createdByFieldMetadata.isSystem === true` |
|
`field-metadata/utils/resolve-field-metadata-standard-override.util.ts`
| Skip i18n/overrides for custom fields | `if (fieldMetadata.isCustom)
return raw` | **removed** — falls through on
`isDefined(standardOverrides)` |
|
`object-metadata/utils/resolve-object-metadata-standard-override.util.ts`
| Skip i18n/overrides for custom objects | `if (objectMetadata.isCustom)
return raw` | **removed** — same fall-through |
|
`command-menu-item/utils/build-navigation-interpolation-context.util.ts`
| Override context for nav labels | passed `isCustom` into resolver |
dropped (resolver no longer needs it) |
| `api/common/.../data-arg-processor.service.ts` | `isCustom` for
record-position table name | `flatObjectMetadata.isCustom` | derives
from `applicationUniversalIdentifier` |
| `metadata-modules/minimal-metadata/minimal-metadata.service.ts` |
Minimal DTO + override context | `flatObjectMetadata.isCustom` | derives
from `applicationUniversalIdentifier` |
|
`commands/upgrade-version-command/1-23/…backfill-record-page-layouts.command.ts`
| Filter to custom objects | `objectMetadata.isCustom` |
`!isTwentyStandard…(applicationUniversalIdentifier)` |
### Server — DTO / API population
| Location | Before | Now |
|---|---|---|
|
`flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util.ts`
| passthrough `isCustom` | derives from `applicationUniversalIdentifier`
|
|
`flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util.ts`
| passthrough `isCustom` | derives from `applicationUniversalIdentifier`
|
|
`object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util.ts`
(REST) | `entity.isCustom` | `entity.applicationId !==
standardApplicationId` |
|
`field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util.ts`
(REST) | `entity.isCustom` | `entity.applicationId !==
standardApplicationId` |
| `dataloaders/dataloader.service.ts` | passed
`flatFieldMetadata.isCustom` into override resolver | dropped (resolver
no longer needs it) |
> REST controllers (`object-metadata.controller.ts`,
`field-metadata.controller.ts`) resolve `standardApplicationId` once per
request from the cached `flatApplicationMaps`.
### Frontend
| Location | Purpose | Before | Now |
|---|---|---|---|
| `settings/.../SettingsObjectFieldDisabledActionDropdown.tsx` | Whether
an inactive field is deletable | `isDeletable = isCustomField` |
`isDeletable = isCustomField && !isSystemField` |
### Unchanged (out of scope)
`isCustom` on `IndexMetadata` / `View` / `Skill` / `Agent` and their
guards still read the persisted column.
Breaking change is on the isCustom filter on field and object APIs, this
is never used in the FE and unlikely used by external consumers
|
||
|
|
2151a414f5 | Remove IS_WORKFLOW_RUN_STEP_LOGS_ENABLED feature flag (#21323) | ||
|
|
91f2f08995 |
feat(server): unify workspace-event ingestion behind one EventSink pipeline (#21197)
## Why The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`, `usageEvent`, `applicationLog`) each wrote to ClickHouse through their own fire-and-forget writer (`AuditService`, `UsageEventWriterService`, and the `application-logs` driver), with the per-type knowledge (table names, normalization, access rules) spread across several modules. Three of them reimplemented the same ClickHouse insert, and the read side, the live stream, and the producers lived in different modules under two different names. This consolidates them into one `core-modules/event-logs/` subsystem (emit, write, live, read), with the per-type config in a single registry so adding an event type is roughly one file. The base Logs settings tab and free application logs shipped separately in #21180 (merged). This PR adds the unified backend, the registry, and the viewer's live mode and entitlement gating. ## Pipeline ```mermaid flowchart TB subgraph PROD["Producers"] A["auth, billing, impersonation,<br/>webhook, custom-domain"] U["usage listener"] F["logic-function executor (app logs)"] R["record CRUD (entity events)"] end EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"] EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"]) CIE["CreateEventLogFromInternalEvent"] SINK["WorkspaceEventSinkService.ingest()"] C1["ClickHouseEventSink"] C2["ConsoleEventSink"] LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"] CH[("ClickHouse, 5 tables, async_insert")] CHAN(["WORKSPACE_EVENTS_CHANNEL"]) RS["EventLogsService (registry-driven read)"] LR["EventLogsLiveResolver"] UI["Settings > Logs"] A --> EM U --> EM F --> EM EM -->|direct| SINK R --> EQ --> CIE -->|ingest| SINK SINK --> C1 --> CH SINK --> C2 SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI CH --> RS --> UI ``` ## What it does - Producers call `EventLogEmitterService.createContext().insert*()`, which builds a typed `WorkspaceEventEnvelope` and writes it through `WorkspaceEventSinkService` to the configured sinks (ClickHouse, Console) plus a presence-gated live fan-out. Record/CRUD events reach the same sink through the existing `entityEventsToDbQueue`. There is no dedicated queue; ClickHouse `async_insert` batches server-side. Writes are best-effort, as on main today. - `EVENT_LOG_TYPES[table]` is the per-type source of truth: the ClickHouse table, the required entitlement, the free-text filter column, and the row-to-GraphQL mapping. Read row shapes derive from the write rows. - Four modules along their dependency boundaries: `EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the entitlement-gated GraphQL read, which is where billing/enterprise/permissions stay so producers stay light). - Logs viewer: per-table columns, filters (text, date, record), live mode, and an upgrade card that points to Billing on Cloud or the Admin Panel on self-hosted. Application logs are free on every plan; the other four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT` fallback to the upgrade card). - Renames `AuditService` to `EventLogEmitterService`, and the generic `Monitoring` event to a typed `Impersonation` event (`level` + `action`). - Removes `UsageEventWriterService`, the `application-logs` driver/module, and `AuditService`'s direct inserts. ## Durability Writes are best-effort, the same as main today (the old writers were fire-and-forget). A dedicated queue was tried mid-PR and removed: `async_insert` already batches server-side, so the queue only added durability, which isn't a requirement right now. The `EventSink` seam keeps a durable transport (e.g. a Redis-Streams buffer) easy to add later without touching producers. ## Out of scope S3 peer sink (seam only), Postgres or any second read path, `ReplicatedMergeTree`, ClickHouse table-schema changes, and the record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern). ## Testing Unit tests cover the registry definitions and row normalization, the entitlement gating, the envelope builders, and the producers. Integration tests cover the write paths (record create produces an `objectEvent`; the track mutation produces a `workspaceEvent`) and the read/query path across all five tables. Verified with typecheck, lint, a server boot, and GraphQL/SDK codegen. |
||
|
|
0d3c7a47af |
fix: guard against undefined logicFunctionId when destroying workflow CODE steps (#21256)
## Summary - Adds `isDefined` guard in `handleLogicFunctionSubEntities` to skip CODE steps with undefined `logicFunctionId` instead of crashing - Adds same guard in `runWorkflowVersionStepDeletionSideEffects` for consistency - Rejects CODE steps in `create_complete_workflow` AI tool at runtime to prevent creating workflows with missing logic functions in the first place Fixes `"Logic function with id undefined not found"` INTERNAL_SERVER_ERROR when destroying workflows whose CODE steps were created via `create_complete_workflow` without a proper logic function. ## Test plan - [x] Destroy a workflow that has a CODE step with undefined logicFunctionId → should succeed silently - [x] Try creating a workflow with a CODE step via `create_complete_workflow` tool → should return error message - [x] Normal workflow destroy with valid CODE steps still deletes the logic function |
||
|
|
c3dd6b25a6 |
fix: use canonical oxlint rule id in lint-disable directives (#21253)
## What Many `oxlint-disable` / `eslint-disable` directives across the repo carry a corrupted rule id — `@typescripttypescript/<rule>` — most likely a find-and-replace accident that mangled the eslint-era `@typescript-eslint/` prefix. oxlint matches disable directives **loosely by rule name**, so these still suppress in practice (not a silent no-op), but the id is malformed and misleading. ## Change Replace them with the **canonical oxlint id** `typescript/<rule>` — matching the plugin name and rule keys declared in `.oxlintrc.json` — **127 files, 262 directives**: | rule | count | | --- | ----- | | `typescript/no-explicit-any` | 250 | | `typescript/ban-ts-comment` | 6 | | `typescript/no-misused-promises` | 4 | | `typescript/no-empty-object-type` | 2 | - `twenty-server`: 122 files - `twenty-front`: 5 files Comment-only — no code or runtime changes. ## Verification `oxlint --type-aware -c .oxlintrc.json` reports **0 warnings / 0 errors** for both `twenty-server` and `twenty-front`. Every changed line is exactly the id correction inside a disable directive (262 insertions / 262 deletions, no collateral edits). > Addresses the cubic review, which flagged that the canonical oxlint id is `typescript/...` (no `@`). Worth noting the original `@typescripttypescript/` was not actually a silent no-op — oxlint matches these directives loosely by rule name — but `typescript/` is the correct, config-aligned id. |
||
|
|
3d49642d12 |
[AUDIT] Run knip over twenty-server (#21159)
# Introduction Run [knip](https://knip.dev/) over twenty-server Used config: ```json { "$schema": "https://unpkg.com/knip@5/schema.json", "workspaces": { "packages/twenty-server": { "entry": [ "src/main.ts", "src/command/command.ts", "src/queue-worker/queue-worker.ts", "src/database/scripts/setup-db.ts", "src/database/scripts/truncate-db.ts", "src/database/clickHouse/migrations/run-migrations.ts", "src/database/clickHouse/seeds/run-seeds.ts", "src/instrument.ts", "lingui.config.ts", "test/integration/graphql/codegen/index.ts", "test/integration/utils/setup-test.ts", "test/integration/utils/teardown-test.ts", "scripts/**/*.ts", "**/*.spec.ts", "**/*.integration-spec.ts" ], "project": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"], "ignore": [ "src/database/typeorm/**/migrations/**", "src/database/typeorm/**/*.entity.ts", "**/*.workspace-entity.ts", "**/logic-function-resource/constants/seed-project/**" ], "ignoreDependencies": ["@types/psl", "@types/aws-lambda"], "ignoreBinaries": ["nest", "lingui", "typeorm"] } } } ``` |
||
|
|
15eaabdbc1 |
fix(ai) - optimize crud tools (#21133)
- **Add delete many**, `delete_many_{object}` added alongside the
existing `delete_one_{object}`.
- **Uniformize naming**, crud module, type names, and MCP helper
constants renamed for consistency.
- **Optimize tool schema (learn phase)**
- `find_many(_companies)`: **7 158 → 2 700 tokens**
- `find_one(_company)`: **280 → 126 tokens**
- ....
- Main mechanism: `reused: 'ref'` (line 7 of
`to-tool-json-schema.util.ts`). Zod walks the schema tree, tracks which
Zod schema instances appear more than once, and emits each reused
instance exactly once in `$defs`, replacing all subsequent occurrences
with a `$ref`. Works because filter and value schemas are now extracted
as shared objects.
- **Optimize system prompt (tool catalog)**, DATABASE_CRUD section
restructured to list operation patterns (`find_many_{object}`, …) once +
objects once, instead of the full N×M cross-product of tool names.
- **Optimize execute_tool**, shared record-properties schema (same
`$defs` deduplication applies at call time); introduced `upsert_many`;
added `selectedFields` to `find_*` so the agent only fetches the fields
it needs.
|
||
|
|
4b15b949f3 |
Provide additional logsobservability to workflow runs (per node) (#21142)
Surfaces per-step "Logs" tabs in the workflow run side panel so users can see what each step actually did (model + tokens + tool calls for AI, console output for serverless functions, request/response for HTTP, recipients/body for Email). <img width="546" height="501" alt="ai_agent_without_websearch" src="https://github.com/user-attachments/assets/c6ca3518-9489-4484-a570-3d0569ff3b03" /> ## Storage - New `stepLogs` JSONB column on the `workflowRun` workspace entity, typed as `Record<string, WorkflowRunStepLog>` (keyed by step id). - Schema lives in `twenty-shared`: `workflowRunStepLogSchema` with a discriminated `details.type` union for `AI_AGENT | CODE | HTTP_REQUEST | EMAIL` — frontends and backends consume the same Zod-inferred type. - Field is added to existing workspaces via a workspace upgrade command (`2-9 add-workflow-run-step-logs-field`); the standard-object metadata declares it for new workspaces. - Writes happen atomically per step in `WorkflowRunStepLogWorkspaceService.setStepLog` using `jsonb_set`. That lets concurrent steps in the same run write their own keys without contending with the existing lock around `workflowRun.state`. - Per-step payload is hard-capped at 256 KB; anything larger is dropped with a `logger.warn`, so a pathological tool call can never bloat a row. See below for more information. ## How logs are produced **Aalmost everything was already being collected; this PR mostly persists and renders it.** - **AI agent** — `AgentAsyncExecutorService` already tracked token usage, model id, native web-search count, and the AI SDK's `steps[]`. We map those into the log via `mapAiStepsToToolCallLogs` (`searchVector` stripped from record outputs, per-call input/output capped at 32/64 KB, max 200 tool calls per step). The only new measurement is a wall-clock `durationMs` taken around `executeAgent`, and we now fold native web-search cost into the displayed `totalCostInDollars` (it was already billed, just not shown). - **Code / serverless function** — reuses the `console.log` output the function runner already returns (`logsByLevel`); `build-code-step-log.util` only repackages it. - **HTTP request** — built from the action's existing input/output via `build-http-request-step-log.util`. No new signals collected. - **Email (send / draft)** — added `sanitizedHtmlBody` + `plainTextBody` to the existing tool outputs (a small additive change), then `build-email-step-log.util` consumes them. No additional AI inference or external calls are made for logging — the cost is a small CPU overhead per step plus the JSONB write. ## Security The log surface intentionally shows whatever the workflow touched, which made redaction and sanitization the main design concern. - **HTTP — secrets in headers**: existing `SENSITIVE_HEADER_NAMES` set (Authorization, Cookie, …) replaced with `[redacted]` in both request and response. - **HTTP — secrets in URLs**: `SENSITIVE_URL_PARAM_NAMES` (e.g. `api_key`, `token`, `access_token`) replaced in the query string via `URL`-based parsing. - **HTTP — secrets in bodies**: `SENSITIVE_BODY_KEY_REGEX` deep-walks JSON request/response bodies (object input or stringified JSON) and redacts matching keys. Applied to the `error` field too, since transport-layer errors sometimes embed structured payloads. - **Email — XSS risk in body preview**: tool outputs now expose a server-side `sanitizedHtmlBody`; the log builder prefers it over the raw user-authored `input.body`, with `plainTextBody` as a second fallback. The original raw body is only used if sanitization didn't happen (e.g. tool failed before composing). - **AI — internal/noisy data**: `searchVector` (Postgres tsvector strings) is stripped from record outputs returned by Twenty tools to avoid leaking internal full-text-search payloads. - **DB bloat / runaway agents**: 256 KB per-step cap + 32 KB / 64 KB per-tool-call input/output cap + 200 tool calls per step. <img width="547" height="307" alt="logic_function" src="https://github.com/user-attachments/assets/dd4a3d16-67f2-434b-95b3-bdcaf9ed053d" /> ## More details on Log size & truncation Logs are stored in `workflowRun.stepLogs` (JSONB), keyed by `stepId`. ### Per-step cap Each step's log is hard-capped at **256 KB** (`MAX_STEP_LOG_BYTES` in `WorkflowRunStepLogWorkspaceService.setStepLog`). For ~99% of workflows this is roomy — typical real-world sizes: - Code / serverless function: 1–20 KB - HTTP request: 5–70 KB - Email: 5–30 KB - AI agent (a handful of tool calls): 5–50 KB ### Two layers of bounding 1. **Per-field truncation** in each builder (before writing): - **Code**: ≤ 500 entries, ≤ 4 KB per message, ≤ 8 KB stack trace - **HTTP**: ≤ 32 KB per body (request + response), UTF-8 byte-aware - **Email**: ≤ 8 KB body preview, UTF-8 byte-aware - **AI agent**: ≤ 32 KB tool input, ≤ 64 KB tool output, ≤ 200 tool calls/step 2. **Global per-step safety net** at write time: if the assembled `stepLog` still exceeds 256 KB, the write is **dropped entirely** with a `logger.warn`. The workflow itself keeps running unaffected. ### What this means in practice - **Safe**: workflow execution, step results, downstream steps — never blocked by log size. - **Safe**: iterators (each iteration overwrites the previous log for that `stepId`, so they can't accumulate). - **Safe**: step retries (same `stepId` is overwritten, not appended). - **Possible**: an AI agent step with many large tool outputs (e.g., 50+ heavy `web_search` calls) can exceed 256 KB → the **entire** step's log is dropped, side panel shows "No logs were recorded for this step". The user has no explicit signal that the log was dropped due to size (only server-side warn). - **Possible** (theoretical): a workflow with hundreds of distinct steps could push the row toward Postgres's internal ~256 MB jsonb limit. Beyond that, individual `jsonb_set` writes would error and be swallowed by the action's try/catch — workflow still completes. ### Possible future hardening (not in this PR) - Replace "drop entire log" with a stub that preserves the summary card (cost, duration, status) and marks `truncated.reason = 'size_cap'`. - Surface size-drops in the UI (similar to the existing `<StyledTruncatedNotice>`). - Emit a metric so dropped logs are observable in dashboards. |
||
|
|
58907b733c |
feat(logic-function): add LIVE / PREBUILT execution modes (#20873)
## Summary
### Why
1. Sending the code to the lambda (~1Mb usually) is heavy on network and
results to a constant traffic of ~30Mb/s on AWS which results into TB of
network data every month
2. eval(1MB of code) is not that fast, it's heavy on memory and CPU on
lambda side
### High level
Adds two execution modes for logic functions, gated behind the new
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` workspace feature flag (off
everywhere by default):
- **LIVE** (current behavior, preserved bit-for-bit): the compiled
bundle is read from object storage and shipped in every Lambda invoke
payload. Used for fast iteration in the workflow editor / Settings test
runs.
- **PREBUILT** (new): the bundle is installed onto the per-function
Lambda alongside the unified executor, and invocations carry only `{
params, env, handlerName }` — saving JSON payload egress and warm-start
`import()` cost on every call.
### Key design choices
- **Unified Lambda handler** (`constants/executor/index.mjs`) dispatches
at runtime: `event.code` present ? LIVE (write to `/tmp`, dynamic
import) : `import('./prebuilt-logic-function.mjs')`. Both code paths
always coexist on the deployment package, so the same Lambda can serve
either mode without redeploying.
- **Install runs inside the `validateBuildAndRun` migration pipeline**,
not at execute time. `Create/UpdateLogicFunctionActionHandlerService`
calls `driver.installPrebuiltBundle` when `executionMode` flips
LIVE?PREBUILT or `checksum` changes while PREBUILT, gated on
`isBuildUpToDate=true` and a fresh checksum.
- **Strict execute, no reconciliation**:
`LogicFunctionExecutorService.execute` resolves `effectiveExecutionMode`
(caller override > feature flag > entity column). For PREBUILT it asks
the driver `getInstalledBundleChecksum` (Lambda `twenty:bundle-checksum`
tag for AWS, sidecar file locally) and throws
`LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED` on mismatch.
- **Feature flag gates every side effect**: with the flag off the
executor forces LIVE, the action-handler install hooks bail before AWS,
and workflow activation does not flip the mode. Rollback is just turning
the flag off.
### Lifecycle
- New workflow CODE step ? `LIVE`, no install.
- Workflow activated ? build + activation flips `executionMode=PREBUILT`
? action-handler installs the bundle + sets the Lambda tag.
- Draft from active version ? duplicated logic function reset to `LIVE`.
- App install ? manifest converter sets `PREBUILT`, create-action
handler installs.
- Test runs (`executeOneFromSource`, workflow editor) pass
`executionMode=LIVE` explicitly.
### Observability
`[lambda-timing]` log lines now include `effectiveExecutionMode` and
`payloadBytes`; the action handler logs `install_duration_ms` for each
install.
## Test plan
- [x] `npx nx typecheck twenty-server` ? passes
- [x] `npx oxlint --type-aware` on all changed files ? 0 warnings, 0
errors
- [x] `npx nx test twenty-server` ? 588 suites / 5009 tests pass (no
regressions vs main)
- [x] New unit suite `flat-logic-function-validator.service.spec.ts` ?
9/9
- [x] Existing
`workflow-version-step-operations.workspace-service.spec.ts` ? 8/8
(verified the new token-based DI avoids a circular-import regression)
- [x] Snapshot for
`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY` updated
to include `executionMode`
- [x] Integration suite `logic-function-execution.integration-spec.ts`
extended to assert `executionMode=LIVE` on newly-created functions and
continues to exercise the LIVE happy path
- [ ] Manual staging rollout: flip
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` per workspace, observe
`[lambda-timing]` `payloadBytes` drop + `install_duration_ms`, then ramp
in prod.
|
||
|
|
627b488556 |
Fix else branches not properly skipped in nested if/else workflows (#20938)
## Summary - Extract `findParentSteps` utility that recognizes IF-ELSE steps as parents of their branch children (via `settings.input.branches[].nextStepIds`), used in all parent detection sites (`shouldSkipStepExecution`, `shouldExecuteStep`, `shouldFailSafely`, and their iterator variants) - Centralize next-step resolution in `getNextStepIdsToExecute` via extracted `getNextStepIdsForIterator` and `getNextStepIdsForIfElse` utils — Iterator now properly returns loop children as `nextStepIdsToSkip`/`nextStepIdsToFailSafely` when skipped - Refactor `skipAndFailSafelyStepsThenContinue` to delegate to `getNextStepIdsToExecute` instead of duplicating type-specific propagation logic Fixes #20934 ## Test plan - [x] New unit tests for `findParentSteps` (7 tests covering IF-ELSE branch parent detection) - [x] New IF-ELSE-specific tests added to `shouldSkipStepExecution`, `shouldExecuteStep`, `shouldFailSafely` test suites - [x] Updated Iterator skip/fail-safely tests in `workflow-executor.workspace-service.spec.ts` - [x] All 300 workflow executor tests pass - [x] `lint:ci` passes |
||
|
|
41832c8d82 |
Fix workflow creation on view filtered by status (#21027)
Creating a workflow on a table with with a filter on status (eg: status is "active") failed because it added the status to createOneWorkflow (in order to have the record belonging to the view) - while createOneWorkflow throwed a 400 exception when attempting to create a workflow with a status (does not correpsond to a valid behaviour). Silently stripping status rom create workflow endpoints. |
||
|
|
996cdaf3ff |
refactor(agents): split tool resolution into native and action rails (#20331)
## Summary
Splits AI agent tool resolution into two independent rails:
- **Native tools** — capabilities baked into the model SDK
(Anthropic/OpenAI `web_search`, xAI `web`/`x` provider options). Bound
by `NativeToolBinderService`, controlled by per-agent
`modelConfiguration` toggles. Opaque to Twenty — executed on the model
provider's servers.
- **Action tools** — registry-scoped tools from `ToolRegistryService`
(code interpreter, send email, record CRUD, etc.). Permission-gated via
the agent's role. Executed on Twenty's server.
Both rails merge into a single `ToolSet` at call time. When both
surfaces expose a search tool the model picks at runtime — coexistence
is intentional (relevant once Exa returns as an action, see below).
## Notable changes worth calling out
**Contract change: `AgentAsyncExecutorService.executeAgent` no longer
accepts `rolePermissionConfig`.** Workflow agents now scope exclusively
by the agent's own permission-tab role (`unionOf: [agentRoleId]`). The
previous role-merging path (caller role intersected with agent role) is
removed. No agent role → no registry tools (fail-closed by design).
**`NativeToolBinderService` relocated** from
`core-modules/tool-provider/native/` →
`metadata-modules/ai/ai-models/services/`. The binder needs SDK-package
knowledge, which lives in `ai-models`. Old location created a backwards
module dependency.
**`NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE` is exhaustive over
`AiSdkPackage`** (`Record<>`, not `Partial<Record<>>`). Adding a new SDK
without thinking about native tools now fails the build. SDKs without
native tools (Bedrock, Google, Mistral, Azure, OpenAI-compatible) get
explicit `{}` entries.
**Discriminated union `kind: 'sdk-tool' | 'provider-option'`** lets one
registry describe both function tools (Anthropic/OpenAI) and runtime
sources (xAI). Follows the local `tool-provider` convention from #19321.
## Deferred to follow-ups
- **Exa web search is dropped from this PR** (along with its
`WEB_SEARCH_TOOL` permission flag and the Exa-specific gating). Exa
comes back as an **action/app tool** once apps can define permission
flags through the SDK — ongoing work in #20481.
- **xAI native search currently errors.** xAI deprecated its Live Search
API (the `web`/`x` provider-option sources this rail maps to), so xAI
returns `410` when native search is actually exercised. The code path
itself is clear — it's only hit if you test xAI native tools. Fixed
separately alongside the broader xAI model fixes.
## Conscious non-decisions
- **No "twenty-native" category.** `native` is reserved for
model/provider SDK features; everything Twenty-owned is just a
tool/action.
- **Coexistence over precedence.** No rule forcing an action search tool
to override native search (or vice-versa) — when both exist, it's the
user's choice in workflow agents and the model's choice in chat.
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
|
||
|
|
f4ead89956 |
refactor(twenty-orm): migrate 23 grandfathered entities to WorkspaceScopedRepository (#20987)
## Summary Follow-up to #20953. Migrates 23 of the 30 entities that were left in `WORKSPACE_SCOPED_EXEMPTIONS` last time, so the lint rule's workspaceId-enforcement default now covers most of the core/metadata schema. ### Migrated (23 entities, 88 files, 22 commits) | Family | Entities | |---|---| | Trivial caches | `NavigationMenuItem`, `Skill`, `DataSource`, `Webhook`, `CommandMenuItem`, `IndexMetadata` | | Views | `View`, `ViewField`, `ViewFieldGroup`, `ViewFilter`, `ViewFilterGroup`, `ViewGroup`, `ViewSort` | | Layouts | `PageLayout`, `PageLayoutTab`, `PageLayoutWidget` | | Roles & permissions | `Role`, `RoleTarget`, `PermissionFlag`, `ObjectPermission`, `FieldPermission`, `RowLevelPermissionPredicate`, `RowLevelPermissionPredicateGroup` | For each entity: swap `@InjectRepository(X)` → `@InjectWorkspaceScopedRepository(X)` (and the field type → `WorkspaceScopedRepository<X>`); rewrite every call site to pass `workspaceId` as the first arg (stripped from `where`/criteria — the wrapper throws if you include it now); register `provideWorkspaceScopedRepository(X)` in every owning NestJS module; update affected spec providers to `getWorkspaceScopedRepositoryToken(X)`. ### Rule update - `ApplicationRegistrationVariableEntity` was misclassified — moved to `STRUCTURAL_EXEMPTIONS` (no `workspaceId` column; it's keyed on `applicationRegistrationId` at the instance level). - 22 of the 23 migrated entities removed from `WORKSPACE_SCOPED_EXEMPTIONS` entirely (zero remaining raw `@InjectRepository` sites). - `RoleTargetEntity` also removed; one call site in `user-workspace.service.ts` keeps a raw injection with an `eslint-disable` + reason because `softRemove(...)` is not on the wrapper API yet (the migration would require threading `workspaceId` through `deleteUserWorkspace`'s three callers). ### Still exempted (7 entities, follow-up PRs) | Entity | Why deferred | |---|---| | `ApplicationEntity` | ~50 sites with several cross-workspace lookups by id (auth, OAuth, file-storage, cleanup) | | `CalendarChannelEntity` / `MessageChannelEntity` | Use `.increment(...)` (not on wrapper) and `repository.manager.transaction(...)` — wrapper needs to grow `.increment` + the transaction sites need `withManager` or dual-inject | | `FieldMetadataEntity` / `ObjectMetadataEntity` | The metadata services `extends TypeOrmQueryService<X>` and `super(rawRepo)` — requires dual-inject or reworking the inheritance | | `KeyValuePairEntity` | Allows `workspaceId: IsNull()` for instance-level config; wrapper rejects null | | `UpgradeMigrationEntity` | Same — instance-level + cross-workspace ledger | ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx nx lint twenty-server` — clean (0/0) - [x] All 10 affected unit specs pass (115 tests) — api-key, agent-role, permissions, workspace-roles-permissions-cache, view-filter-group, workflow-version-step-operations, two-factor-authentication (service + resolver), user-workspace, file - [ ] Server integration tests in CI |
||
|
|
4797d2f270 |
feat(twenty-orm): introduce WorkspaceScopedRepository for core/metadata workspace-scoped entities (#20953)
## Summary Adds a third tenancy enforcement layer for entities that live in shared schemas (`core`, `metadata`) and carry a `workspaceId` column — previously the only safeguard at this layer was developer discipline (remembering to put `workspaceId` in every WHERE clause). ### The three layers, after this PR | Layer | Scope | How it's enforced | |---|---|---| | 1. Workspace data | per-workspace schema (companies, people, custom objects) | `twentyORMManager.getRepository(workspace, E)` — physical isolation (own data source) | | 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata, views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory map, lookups by id within it | | 3. Core (new) | shared `core` schema (agent threads/turns/messages, app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a required positional argument on every read/write | ## What's in the PR ### The wrapper (`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`) - `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a TypeORM `Repository<T>`, requires `workspaceId` on every `find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count` call, merging it into the WHERE or stamping it on the entity. `createQueryBuilder` is an explicit escape hatch (caller scopes manually). - Provided via Nest DI with `@InjectWorkspaceScopedRepository(EntityClass)` and the `provideWorkspaceScopedRepository(EntityClass)` provider factory. - 19 unit tests cover the merge behavior, override-on-conflict, and the array-where (OR) case. ### Lint enforcement (`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`) - New `twenty/prefer-workspace-scoped-repository` rule (level: **error**). - Blacklist of entity names: raw `@InjectRepository(E)` is rejected if `E` is on the list. - Initial list: `AgentTurnEntity`, `AgentMessageEntity`, `AgentMessagePartEntity`, `AgentChatThreadEntity`, `AgentTurnEvaluationEntity`, `AgentEntity`. - Designed to grow over time as more consumers are migrated. - 5 rule tests. ### Migration in this PR All consumers of the six blacklisted entities, including: - AI agent / chat / monitor resolvers, services, and jobs - `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`, `ApplicationService`, `WorkspaceFlatAgentMapCacheService` - Admin-panel chat (migrated where the lookup is workspace-known; one documented `eslint-disable` on the threadId-discovery lookup that necessarily precedes the `allowImpersonation` permission check) - `AiAgentRoleService` unit spec updated to mock the scoped wrapper ## Future work (deliberately not in this PR) A standalone audit identified ~14 additional `core`/`metadata` entities with `workspaceId` that currently use raw `@InjectRepository` and could be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42 sites), `AppTokenEntity` (10), `FileEntity` (7), `BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each should be its own PR — the migration is mechanical but the surface is wide. ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx nx lint twenty-server` — 0 warnings, 0 errors - [x] `npx jest workspace-scoped-repository` — 19/19 pass - [x] `npx nx test twenty-oxlint-rules` — 215/215 pass - [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass - [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer) - [ ] Manual smoke: AI agent monitor — list turns, run evaluation (reviewer) - [ ] Manual smoke: admin-panel chat thread inspection (reviewer) |
||
|
|
eda41b4eba |
feat(ai) - add observability (#20850)
**AI Chat - Tool Executions (counters, tagged with model)** ai-chat/tool-execution-succeeded: number of tool calls invoked by the AI that completed without error ai-chat/tool-execution-failed: number of tool calls invoked by the AI that threw an error **AI Chat - Token Usage (counters, tagged with model)** ai-chat/input-tokens: total input tokens sent to the model across all turns ai-chat/output-tokens: total output tokens generated by the model ai-chat/cache-read-tokens: input tokens served from the model's prompt cache (cheaper) ai-chat/cache-write-tokens: input tokens written into the prompt cache for future reuse **AI Chat - Latency (histograms in ms, tagged with model)** ai-chat/turn-latency-ms: total duration of a full chat turn (from stream start to stream end) ai-chat/step-latency-ms: duration of a single reasoning/tool-call step within a turn ai-chat/ttft-ms: time-to-first-token, i.e. how long until the model starts streaming output **MCP - Tool Executions (counters)** mcp/tool-execution-succeeded: number of MCP tool calls that completed successfully mcp/tool-execution-failed: number of MCP tool calls that threw an error |
||
|
|
323e66433e |
lint: migrate prettier to oxfmt (#20783)
Most changes are `implements` being unwrapped this is not a oxfmt regression Prettier in 3.7 (we're on 3.1) changed this behaviour prettier blog [post](https://prettier.io/blog/2025/11/27/3.7.0#change-18094) This unifies our linting tooling --------- Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
138eb5a74a |
Add empty operands to UUID filter type in workflow filter action (#20821)
## Summary - Adds `IS_EMPTY` and `IS_NOT_EMPTY` operands to the UUID entry in `getStepFilterOperands`, aligning the workflow filter action with the find records (search) action which already includes these operands for ID-type fields. ## Test plan - [ ] Open a workflow with a filter action, select an ID-type field, and verify the operand dropdown now includes "Is empty" and "Is not empty" - [ ] Open a workflow with a find records action, select an ID-type field, and verify the operand dropdown is consistent with the filter action --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
869f8af4f0 |
Fix workflow cron trigger cache stuck without TTL (#20812)
## Problem The cron-trigger cache key (`module:workflow:workflow-cron-triggers`) can get stuck without a TTL, silently halting **all** cron-triggered workflows for a whole tenant until the key is manually deleted from Redis. Repro path: 1. Cache miss → DB-scan branch runs. 2. Inner loop writes triggers via `hashSet` (creates the key, **no TTL yet**). 3. Worker crashes / OOMs / gets killed by a deploy between any `hashSet` and the trailing `expire(1h)` call. 4. Key now exists with TTL = `-1` and a partial set of fields. 5. Next tick: `hashGetValues` returns those fields → `cachedValues.length > 0` → **cache-hit branch** → `expire` is never called. 6. Key has no TTL, so it never auto-expires. The DB-scan branch never runs again. New / missing triggers are never picked up. Workflows go silent. Observed in production: cache key with `TTL: no limit` and 121 fields. Deleting the key restored normal behaviour (next tick rebuilt with TTL ~3600). ## Fix Set the TTL right after first value is added ## Monitoring Added a "Cache miss" log count in workflow dashboard, counted among the last 6 hours. Turns green if >= 5 <img width="1627" height="721" alt="Screenshot 2026-05-21 at 16 46 13" src="https://github.com/user-attachments/assets/8262dd5f-fbbd-43c9-aede-c0ce5d6a0f59" /> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
b454ad2aea |
fix(workflow): restore initial input fields on code step creation (#20756)
## Summary - Fixes a regression from #20208 where creating a new CODE workflow step shows no input fields - The split-triggers PR removed `SEED_LOGIC_FUNCTION_INPUT_SCHEMA` and replaced `toolInputSchema` with `workflowActionTriggerSettings`, but `CodeStepBuildService.createCodeStepLogicFunction` was not updated to pass the seed schema — causing `logicFunctionInput` to default to `{}` and no fields to render - Adds `SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS` constant (matching the seed template's `{ a: string, b: number }` params) and passes it when creating the seed logic function ## Test plan - [x] Unit test updated to assert `logicFunctionInput` contains `{ a: null, b: null }` on code step creation - [x] Create a new CODE step in the workflow builder and verify input fields `a` and `b` appear immediately Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
c800eccc65 |
Slack workflow connector (#20427)
https://github.com/user-attachments/assets/5a746414-988b-473c-9401-b8863a3e1c15 https://github.com/user-attachments/assets/0cdebdb1-f7c8-43cb-beef-f279387b6ce9 https://github.com/user-attachments/assets/df31c631-0781-42d8-8e6e-e5a16573ee3b https://github.com/user-attachments/assets/6adaeae4-f3c9-4a5f-b0df-50c1f9a78428 --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: martmull <martmull@hotmail.fr> |
||
|
|
1a9f786e42 |
refactor(filters): pass fieldMetadataItems array to dispatcher (#20737)
## Summary Alternative to #20717. Same goal (clean up the filter dispatcher API after #20670) but smaller and follows the codebase's "pass data, not behavior" style. The dispatcher takes a `fieldMetadataItems: FieldShared[]` array directly instead of a `findFieldMetadataItemById: (id) => FieldShared | undefined` callback. The util builds the id lookup internally — once per call, used for both source-field and relation-target-field lookups. No new types, no separate hydration step. ## What changes **`twenty-shared`** - `computeRecordGqlOperationFilter` / `turnRecordFilterIntoRecordGqlOperationFilter` / `turnRecordFilterGroupsIntoGqlOperationFilter`: replace `findFieldMetadataItemById` param with `fieldMetadataItems` / `fieldMetadataItemById` (internal Map). - Remove the exported `FindFieldMetadataItemById` type. - `turnAnyFieldFilterIntoRecordGqlFilter`: rename its internal `fieldById` Map for consistency. - Tests updated to pass arrays. **Frontend (15 call sites)** - Switch from `fieldMetadataItemByIdMapSelector` to `flattenedFieldMetadataItemsSelector`. - Pass `fieldMetadataItems: flattenedFieldMetadataItems` to the dispatcher. - `useFindManyRecordsSelectedInContextStore` keeps the Map selector because it still does a per-filter lookup for the soft-delete check. **Server (5 call sites)** - Pass `Object.values(flatFieldMetadataMaps.byUniversalIdentifier).filter(isDefined)`. ## Why this over #20717 #20717 moves resolution into a separate hydration step + introduces a `HydratedRecordFilter` type. The bug that #20717 originally surfaced was Sentry catching 4 critical runtime errors during review (`fieldMetadataItemByIdMap` declared but not passed). The added type and the explicit hydration boundary are extra surface area for not much benefit — the existing API was a callback wrapping a Map at every call site, and the natural simplification is to just pass the Map (or its array) directly. Net diff: **196 insertions, 203 deletions** (~7 lines net removed). 32 files. ## Test plan - [x] Shared filter unit tests pass (461 tests) - [x] Frontend filter/context-store tests pass (13 tests) - [x] Frontend typecheck passes - [x] Server typecheck passes - [x] Lint passes (frontend + server) - [ ] Integration tests on #20670 still pass — workflow find-records + chart-data with relation-traversal filter still work end-to-end through the new array param |
||
|
|
08e7e4819b |
use declared outputSchema for logic-function steps (#20679)
When a logic function declares `workflowActionTriggerSettings.outputSchema`, use it as the step's initial output schema so downstream steps can pick variables without first running the Test tab. A successful test run still overrides the schema with the inferred shape, preserving "test wins" behavior. Falls back to the existing "Generate Function Output" LINK placeholder when no schema is declared (custom code steps, older functions). https://github.com/user-attachments/assets/af9c45ed-d623-4234-be9f-46812fd06e2e |
||
|
|
291ce5ccdb |
fix(filters): make filter dispatcher own relation-target resolution (#20670)
## Summary Two relation-traversal bugs surfaced post-merge of #20533, both rooted in the same architectural smell: the GraphQL filter dispatcher took a flat `fields: FieldShared[]` array and silently dropped any filter whose `relationTargetFieldMetadataId` wasn't in that array. Callers had to remember to pre-augment the list with relation targets — and 16+ call sites did not all know this. This PR fixes both bugs and removes the smell. ### Bug 1 — Save as new view loses the relation target `useCreateViewFromCurrentView` built the create-filter input without `relationTargetFieldMetadataId`. The saved view's filter persisted without the traversal — on reload the chip showed "Company contains 'air'" instead of "Company → Name contains 'air'". Discarded at save time, not at read time. Fix: include `relationTargetFieldMetadataId` in the create input. (Commit 1.) ### Bug 2 — Workflow Search Records drops one-hop traversals `FindRecordsWorkflowAction` built its fields list from `flatObjectMetadata.fieldIds` only (source object's fields). The shared dispatcher then couldn't resolve the relation target field on the related object and silently dropped the filter — a configured "People where Company → Name Contains 'Airbnb'" came through as `{ and: [] }`. This was the same shape as bugs already fixed in 5 other call sites (chart filters, view filters, record table, etc.). The pattern was: caller forgets to augment fields → dispatcher silently drops the filter. Fix (commit 2): change the dispatcher to take a `findFieldMetadataItemById: (id) => FieldShared | undefined` resolver callback. Both source-field and relation-target-field lookups go through the same resolver, so callers no longer need to know about the augmentation requirement. Frontend callers pass a workspace-wide resolver built from `flattenedFieldMetadataItemsSelector`; server callers wrap `findFlatEntityByIdInFlatEntityMaps` on `flatFieldMetadataMaps`. In both cases relation-target lookups just work, because the resolver can see fields on related objects. ## Why this matters Before: "if you call the dispatcher, pre-augment your fields list with relation targets, or filters get silently dropped." An invariant only enforceable by code review, broken often enough to ship two user-visible bugs in one week. After: the dispatcher resolves field ids itself. There's no list to forget to augment. The failure mode (filter silently dropped) becomes structurally impossible at the dispatcher boundary. Net diff: 240 insertions, 319 deletions. Removed `augmentFieldsWithRelationTargets` (frontend) and the workflow whack-a-mole code (server). ## Test plan - [ ] Save view: create an advanced filter using a one-hop relation traversal, click "Save as new view", reload, confirm the chip still reads "Source → Target operator value" - [ ] Workflow: configure a Search Records action with a relation-traversal filter, run the workflow, confirm the filter is actually applied - [ ] Dashboard chart: configure a chart with a relation-traversal filter, confirm the chart data respects it - [ ] Record table, group-by, calendar, total count, footer aggregates: all continue to work with both plain and relation-traversal filters |
||
|
|
aecfe699f4 |
feat(ai-chat) - Stop ai thinking if credits exhausted (#20526)
Billing is now decremented per-step, not per-turn. The onStepFinish callback in chat-execution.service.ts calls a new decrementAndCheckAvailableCredits method on each model step, so Redis is debited incrementally as the agent runs rather than all at once at the end. Credit exhaustion stops the stream mid-run. When a step depletes the remaining credits, a hasNoMoreAvailableCredits flag is set and passed into the stopWhen predicate of streamText, causing the agent to halt before starting the next step. A new credits-exhausted event is introduced. After the stream drains and the response is persisted, if credits ran out the job publishes a dedicated credits-exhausted event to the frontend instead of the normal message-persisted event. The frontend handles this new event. useAgentChatSubscription has a new credits-exhausted case that sets a BILLING_CREDITS_EXHAUSTED-coded error on the atom, closes the writer, and stops the streaming state — triggering the existing AiChatCreditsExhaustedMessage UI. |
||
|
|
3d90b3882b |
fix(ai-agent-node) - agent node execution error (#20534)
**Root cause:** getWorkflowRunContext(stepInfos) builds a Record<string, unknown> from the previous steps' results. There is no workspaceId key in it, so context.workspaceId as string silently evaluated to undefined. That undefined was then passed all the way down to WorkspaceCacheService.getOrRecompute, **which correctly throws** when workspaceId is not a valid UUID. Before : <img width="525" height="130" alt="Screenshot 2026-05-13 at 14 58 54" src="https://github.com/user-attachments/assets/0549b4dc-7063-44e5-95a1-00a460a6d7f1" /> Introduced with billing v2 yesterday, since then, workspaceId is needed to bill credit usage |
||
|
|
a159a68e2c |
[twenty-server] no floating promises lint rule (#20499)
## Introduction
That's an audit + RFC
## Fire-and-forget (`void`) -- Intentional, correct
These are telemetry, metrics, and audit logging in hot paths or
non-critical contexts. `void` is the right choice.
| File | What was voided |
|---|---|
| `sign-in-up.service.ts` | `metricsService.incrementCounter` (sign-up
metric) + `auditService.insertWorkspaceEvent` (workspace created) |
| `use-graphql-error-handler.hook.ts` | 5x
`metricsService.incrementCounter` (GraphQL operation metrics) |
| `bullmq.driver.ts` | 2x `metricsService.incrementCounter` (job
completed/failed metrics) |
| `call-webhook.job.ts` | 2x `auditService.insertWorkspaceEvent` + 1x
`metricsService.incrementCounter` |
| `custom-domain-manager.service.ts` | `analytics.insertWorkspaceEvent`
(domain activation event) |
| `logic-function-executor.service.ts` |
`auditService.insertWorkspaceEvent` (function execution) |
| `workflow-runner.workspace-service.ts` |
`metricsService.incrementCounter` (throttle metric) |
| `cleaner.workspace-service.ts` | `metricsService.incrementCounter`
(deleted workspace metric) |
| `stream-agent-chat.job.ts` | Detached IIFE for streaming chunks
(intentional concurrent pipeline) |
| `workspace-auth-context.middleware.ts` |
`withWorkspaceAuthContext(...)` (AsyncLocalStorage, returns void anyway)
|
## Top-level script entry points (`void bootstrap()`)
These are module-level calls where the promise has no consumer. `void`
makes the lint rule happy and documents the intent.
| File | What changed |
|---|---|
| `main.ts` | `void bootstrap()` |
| `command.ts` | `void bootstrap()` |
| `queue-worker.ts` | `void bootstrap()` |
| `truncate-db.ts` | `void dropSchemasSequentially()` |
| `codegen/index.ts` | `void generateTests(forceArg)` |
## Now properly awaited -- Real bug fixes
These were floating promises that could silently fail, lose data, or
cause race conditions.
| File | What was fixed |
|---|---|
| `billing-sync-plans-data.command.ts` | `meters.map(async ...)` wrapped
in `Promise.all` -- was returning before upserts finished |
| `cache-storage.service.ts` | `setAdd` and `setPop` had `.then()`
chains that weren't returned/awaited |
| `create-audit-log-from-internal-event.ts` | 4x
`auditService.createObjectEvent` now awaited inside a job |
| `cleaner.workspace-service.ts` | 2x `emailService.send(...)` now
awaited -- emails could silently fail |
| `agent-async-executor.service.ts` | `calculateAndBillUsage` +
`billNativeWebSearchUsage` in `finally` block now awaited |
| `repair-tool-call.util.ts` | `calculateAndBillUsage` now awaited |
| `agent-title-generation.service.ts` | `calculateAndBillUsage` now
awaited |
| `chat-execution.service.ts` | `billNativeWebSearchUsage` now awaited |
| `ai-generate-text.controller.ts` | `calculateAndBillUsage` in
`finally` block now awaited |
| `agent-turn.resolver.ts` | `messageQueueService.add(...)` now awaited
|
| `command.ts` | `app.close()` now awaited (was exiting before graceful
shutdown) |
| `i18n.service.ts` | `loadTranslations()` in `onModuleInit` now awaited
|
| `workspace-query-hook.explorer.ts` | `explore()` in `onModuleInit` now
awaited |
| `message-queue.explorer.ts` | `handleProcessorGroupCollection` in
`onModuleInit` now awaited |
| `ai-billing.service.spec.ts` | Test now properly `await`s the async
call |
| `messaging-messages-import.service.spec.ts` | `expect(...)` now
properly `await`ed for async assertion |
| `archive.finalize()` (3 files) | Voided -- promise resolution already
handled by `pipeline()` / `on('end')` |
## Impersonation & security audit trail -- Upgraded from `void` to
`await`
These were previously fire-and-forget but are
security/compliance-critical events that must be reliably persisted.
| File | What was fixed |
|---|---|
| `impersonation.service.ts` | 4x `auditService.insertWorkspaceEvent`
now awaited (impersonation attempt, token generation
attempt/success/failure) |
| `auth.resolver.ts` | 5x `auditService.insertWorkspaceEvent` now
awaited (impersonation token exchange attempt/success/failure at server
and workspace levels) |
| `auth.service.ts` | 2x `analytics.insertWorkspaceEvent` now awaited
(impersonation attempted/issued) |
## Billing audit -- Upgraded from `void` to `await`
Payment events should be reliably persisted for financial/compliance
reporting.
| File | What was fixed |
|---|---|
| `billing-webhook-invoice.service.ts` |
`auditService.insertWorkspaceEvent(PAYMENT_RECEIVED_EVENT)` now awaited
inside Stripe webhook handler |
## Fire-and-forget with proper error handling -- Upgraded from bare
`void`
These remain non-blocking but now catch and log errors instead of
risking unhandled rejections.
| File | What was fixed |
|---|---|
| `logic-function-executor.service.ts` |
`applicationLogsService.writeLogs` now uses `.catch()` instead of bare
`void` -- user-facing logs should surface errors |
## Systemic infrastructure fixes
| File | What was fixed |
|---|---|
| `metrics.service.ts` | `incrementCounter`: Redis cache write
(`metricsCacheService.updateCounter`) now uses `.catch()` internally
instead of raw `await` -- prevents unhandled rejections across all `void
metricsService.incrementCounter(...)` call sites when Redis is unhealthy
|
| `audit.service.ts` | `preventIfDisabled`: made properly `async` with
`await` and consistent `Promise<{ success: boolean }>` return type.
Removed broken `catch` that returned an `AuditException` as a value
(wrong constructor args, unreachable dead code). Removed unused
`AuditException` import |
## Fixed in this session (beyond original PR)
| File | What changed |
|---|---|
| `telemetry.listener.ts` | Removed misleading `Promise.all` + `void`
combo; replaced with simple `for...of` + `void` |
| `message-queue.explorer.ts` | Changed from `void` to `await` so
startup crashes on registration failure |
|
||
|
|
93df64b9b0 |
Show logic function label instead of technical name in workflow UI (#20470)
### Before <img width="1304" height="812" alt="Screenshot 2026-05-12 at 7 02 32 AM" src="https://github.com/user-attachments/assets/94ca4b1d-69c0-4059-8c45-dd8eae8e2a29" /> ### After <img width="1296" height="782" alt="Screenshot 2026-05-12 at 6 53 26 AM" src="https://github.com/user-attachments/assets/47f2e291-73df-4471-9174-bd5aca23e228" /> |
||
|
|
9fc5be1c4c |
Billing - Migrate from Stripe metering (#20298)
**Overall strategy** **1. Introduce “Billing V2” behind a workspace flag** Gate the new model with FeatureFlagKey.IS_BILLING_V2_ENABLED so existing workspaces stay on the old behavior until they’re migrated or explicitly on V2. **2. Replace workflow metered SKUs with a resource-credit product** Conceptually, billable “workflow execution” usage is not the primary subscription line item anymore. Add a RESOURCE_CREDIT product (and keep WORKFLOW_NODE_EXECUTION as deprecated for the transition). Usage and limits are expressed through credit buckets (e.g. price metadata like credit_amount), so one product can represent pooled credits instead of a narrow workflow-only meter. **3. Migrate subscriptions in two layers** Schema/catalog: persist extra price metadata (instance upgrade) so the server knows credit amounts and can match Stripe prices to the new model. Per workspace: the registered workspace command upgrade:2-2:migrate-to-billing-v2 finds subscriptions that still have WORKFLOW_NODE_EXECUTION, swaps those items to the right RESOURCE_CREDIT prices (using existing Stripe schedule + BillingSubscriptionUpdateService stack), then treats the workspace as V2 (flag). Workspaces without that legacy item or without a subscription are skipped. **4. Unify subscription lifecycle + usage on the server** **5. Refresh the product surface in Settings** Test : - [x] Subscribe v1 + Update subscribe + Migrate - [x] Subscribe v2 + Update subscribe |