e81fdbcc7a62b91803d3566b8324b93616dad99a
72 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e81fdbcc7a |
feat(workflow): variable pickers for Search Records limit, offset and date filters (#23696)
## Summary <img width="491" height="390" alt="Capture d’écran 2026-08-03 à 11 30 40" src="https://github.com/user-attachments/assets/6db59b8a-3e8e-41b8-80b3-c736e56b7f7f" /> Adds workflow variable pickers to the **Search Records** action for fields that previously only accepted static values: - **Limit** and **Offset** number inputs now expose the `WorkflowVariablePicker`, so they can be bound to a variable from a previous step. The stored value can be a standalone variable string; the backend coerces the resolved value back to a number. - **Date filters** using the `Is before` (`IS_BEFORE`) and `Is after or equal` (`IS_AFTER`) operands now expose the variable picker in the advanced filter side panel (previously disabled for all date filters). The backend already resolves these inputs via `resolveInput`; the only backend change is a small numeric coercion of the resolved limit/offset. ## Changes - `WorkflowEditActionFindRecords.tsx` — pass `WorkflowVariablePicker` to the Limit/Offset inputs; make `onChange` and form state variable-aware (`number | string`). - `AdvancedFilterSidePanelValueFormInput.tsx` — enable the date `VariablePicker` only for `IS_BEFORE` / `IS_AFTER`. - `useGetRecordFilterDisplayValue.ts` — return the raw variable for a standalone `{{variable}}` value so date filters don't crash `Temporal.*.from`. - `find-records-action-settings-schema.ts` — allow a string (variable) for `limit` / `offset`. - `find-records.workflow-action.ts` — coerce resolved `limit` / `offset` to numbers before querying. ## Testing Built a workflow locally (Manual trigger → Code step returning `{ limit: 2, offset: 1, sinceDate }` → Search Records) with all three fields bound to those variables. The run completed successfully; the Search Records step returned exactly 2 records (limit applied) filtered by `createdAt >= sinceDate`, confirming the backend resolves each variable. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23696?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
ad8830ecbf |
fix(server): stop FIND_RECORDS from silently ignoring its filter (#23640)
## Problem
A FIND_RECORDS workflow step with a filter configured could silently
return every record (and thus the first row of the table) instead of
applying the filter. Ways to hit it:
- `recordFilters` set but `recordFilterGroups` omitted (e.g. an
API/agent caller, or any non-UI config).
- A grouped filter (carrying `recordFilterGroupId`) whose
`recordFilterGroups` is missing.
- A filter referencing an unknown `fieldMetadataId` or an unresolvable
relation (`turnRecordFilterIntoRecordGqlOperationFilter` returns
`undefined`, silently dropped).
- `gqlOperationFilter` set, which passed validation but was never read.
In every case the computed filter collapses to `{}`,
`FindRecordsService` returns all records ordered by `id ASC`, and
`records[0]` is the first row. This is fail-open: the step reports
success and returns wrong records rather than erroring.
## Fix
- Compute the filter whenever `recordFilters` is non-empty, defaulting
`recordFilterGroups` to `[]`. `computeRecordGqlOperationFilter` handles
ungrouped filters independently of groups.
- **Fail closed**: if `recordFilters` is non-empty but the computed
`gqlOperationFilter` is empty, throw `INVALID_STEP_INPUT` instead of
running an unfiltered query. This covers grouped-without-groups and
unknown-field/unresolvable-relation cases raised in review. An absent or
empty `recordFilters` still legitimately means "find all".
- Remove the unused `gqlOperationFilter` field from the find-records
input type and settings schema so it is no longer advertised as a filter
option (it has been dead since #16147, when the action moved to
computing the filter at runtime from `recordFilters`).
|
||
|
|
0e22ae0521 |
feat: create calendar events on Google and Microsoft accounts (#22231)
## Context Twenty can import calendar events and send emails, but cannot create calendar events. This adds calendar event creation on connected **Google** and **Microsoft** accounts, mirroring the existing email-send architecture (`message-outbound-manager`). ## What it adds The capability is exposed three ways, all backed by the same composer → driver → persist pipeline: - **GraphQL mutation** `createCalendarEvent` (metadata API) - **AI agent tool** `create_calendar_event` (flows to MCP automatically), gated by a new `CREATE_CALENDAR_EVENT_TOOL` permission flag - **Workflow builder node** "Create Calendar Event" in the **Core** section, with a full settings form (variable interpolation supported) CalDAV/IMAP is intentionally out of scope for now (different long pole). ## Design notes - **Reuse over reinvention** — the created event is run through the existing inbound formatters (`formatGoogleCalendarEvents` / `formatMicrosoftCalendarEvents`) and persisted immediately via the existing `CalendarSaveEventsService`, so it appears in Twenty right away and is reconciled by the next provider sync (dedup on external id). Persistence is best-effort. - **OAuth scopes** — Google already requests `calendar.events` (read+write), so no change there. Microsoft moves `Calendars.Read` → `Calendars.ReadWrite`; existing Microsoft accounts must re-consent (surfaced as a clear "reconnect" error via a missing-scope check). - **Deliberate invitation semantics** — `sendInvitations` is off by default. When off, the event is created with **no attendees** on either provider, so creating an event never silently emails external people. When on, attendees are attached and notified (Google `sendUpdates: all`, Microsoft's default). This sidesteps Microsoft Graph having no per-request suppression. - **Timezone correctness** — Microsoft Graph interprets `dateTime` as wall-clock in the supplied `timeZone` and ignores the offset, so the absolute instant is converted to its wall-clock form before sending (Google honors the offset directly). Both providers end up scheduling the same instant. - **Conferencing** — optional Google Meet (`conferenceData.createRequest`, with a follow-up `events.get` to resolve the async link) / Microsoft Teams (`isOnlineMeeting`). - Attendees are a comma-separated string everywhere (tool input, GraphQL DTO, workflow input), consistent with `send_email` recipients; the composer parses to its internal list. ## Test plan - **Unit**: 45 tests covering the composer (validation, all-day boundaries, offset enforcement, timezone, scope checks, default-account resolution), both provider drivers, the dispatcher, and the workflow step-log builder. - **Integration**: `createCalendarEvent` on the `/metadata` API fails closed with a structured error for a non-existent account (the auth/ownership/validation path that doesn't require provider mocking). - **Manual**: verified the workflow node appears in the Core section, the settings form renders and round-trips (edit → autosave → reload), and the live mutation returns a structured failure for a bogus account. ## Open question for reviewers The metadata mutation `createCalendarEvent` shares a name with the core schema's auto-generated `createCalendarEvent(data:)` CRUD mutation for the CalendarEvent object — they live on different endpoints (`/metadata` vs `/graphql`) so there's no runtime conflict, but it's a potential point of confusion for API consumers. Happy to rename (e.g. `createCalendarEventOnConnectedAccount`) if preferred. ## Out of scope / follow-ups - CalDAV/IMAP support - Event update/delete and recurrence - Existing Microsoft accounts need re-consent for the widened scope <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22231?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: neo773 <neo773@protonmail.com> |
||
|
|
6e2df0654b |
[Workflows] Allow iterator to take whole item as variable (#22031)
**Select the whole item in iterator loops, and iterate over a step's array output** ## Summary Two related improvements to working with lists in workflows: - Pick the current item as a whole inside an iterator loop. Previously, in a node inside the loop, you could only reference individual fields of the Iterator's current item. Now you can select the whole item (e.g. a full record) — useful for passing it straight into a downstream step. <img width="1270" height="744" alt="Screenshot 2026-06-23 at 17 02 47" src="https://github.com/user-attachments/assets/6b92e72e-ec25-4c1a-9841-3a438210e753" /> - Iterate over a step's array output. A Code / Logic Function step that returns a top-level array couldn't be fed to the Iterator: its output was flattened into indexed entries (0, 1, …) with no way to select the array as a whole. A new "Whole list" option selects the step's entire output, and the Iterator infers the per-iteration item shape from it. <img width="1026" height="728" alt="Screenshot 2026-06-23 at 17 17 53" src="https://github.com/user-attachments/assets/db07dcd8-4fb8-4db9-8b45-aa56051d9f3b" /> Together these complete the loop ergonomics: select a list → iterate → reference the current item (whole or by field) downstream — matching the model used by tools like Windmill. ## What changed - The variable picker offers a "Use the whole item" option when viewing an iterator's current item, and a "Whole list" option when a step returns a top-level array. - The Iterator's current-item schema can now be inferred from a variable pointing at a step's whole output. ## Risks for existing workflows None expected. The change is purely additive: - No DB migration and no change to how output schemas are stored or read — existing schemas, variables, and iterators behave identically. - No change to runtime variable resolution; existing {{step.field}} and current-item references are untouched. - The new options only apply to new selections (whole item / whole list); all existing paths take the unchanged code path. - The only edge case: array detection is heuristic (an output whose keys are exactly 0…n-1), so an object that happens to have those keys would also show "Whole list". This is rare for real outputs, affects nothing unless a user selects it, and fails safe — the Iterator validates its input and throws a clear "items must be an array" error if a non-array is passed. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22031?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
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. -->
|
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
ff03e935ef |
feat(workflow): expand manual-trigger runtime payload with payload + _metadata (#21676)
## Summary
Step 1 (**expand**) of restructuring the manual-trigger output so record
fields live under a `payload` key and trigger-level metadata (the
running workspace member) lives alongside it. This step is **additive
and runtime-only** — no behavior changes for existing workflows, and
nothing new is surfaced in the variable picker yet.
The manual-trigger runtime payload now additively carries:
- `payload`: a mirror of the incoming record fields, reachable at
`{{trigger.payload.*}}`
- `_metadata.workspaceMemberId`: the member who ran the workflow,
reachable at `{{trigger._metadata.workspaceMemberId}}`
Record fields are still served at the trigger root, so existing
`{{trigger.id}}` references keep working unchanged. The output schema /
variable picker is intentionally left untouched here.
### Why `_metadata` (underscore)
During the transition, record fields still sit at the `trigger` root
next to the injected keys. Field API names can't start with `_`, so
`_metadata` is collision-proof against any record field; picking the
name now avoids a later variable-path rename migration.
### Phasing
- **Step 1 (this PR):** write `payload` + `_metadata` at runtime; keep
using direct `trigger.*`; don't display the new paths.
- **Step 2:** surface `payload` + `_metadata` in the variable picker.
- **Step 3:** migrate existing variables to `trigger.payload.*` and
contract the root record fields.
## Test plan
- [x] `twenty-shared` builds, `twenty-server` typechecks, lint clean on
changed files
- [x] Manual: run a manually-triggered (SINGLE_RECORD) workflow and
confirm the run's trigger payload contains `payload.*` mirroring the
record and `_metadata.workspaceMemberId`
- [x] Manual: confirm existing `{{trigger.id}}` references still resolve
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21676?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> |
||
|
|
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. |
||
|
|
66afd5a1de |
Fix array-typed parameters in code/logic-function action forms (#21102)
## Problem `any[]` type prevented value input: <img width="544" height="349" alt="Screenshot 2026-06-01 at 14 08 47" src="https://github.com/user-attachments/assets/956238d0-6fea-4be1-b75a-ab0e6e6424ac" /> In the workflow Code action (and the Logic Function action), parameters typed as any[], string[], etc. rendered as an empty grey box instead of an "Enter value" text input. After any debounced save, even a properly initialised array field would also collapse into an empty container. The Array<T> / ReadonlyArray<T> generic form fell through to a generic text input by accident (which "looked" right, but for the wrong reason — no schema info downstream). ## Root causes Three places treated arrays as plain objects via @sniptt/guards' isObject (which is true for arrays): 1. WorkflowEditActionCodeFields.tsx — arrays went into the nested-fields branch; Object.entries([]) is empty → empty container, no placeholder. 2. mergeDefaultFunctionInputAndFunctionInput.ts — recursed into arrays during merge, turning [] into {}. Triggered on every debounced save, so the bug surfaced after any edit. 3. get-function-input-schema.ts — only handled T[] (SyntaxKind.ArrayType); Array<T> (SyntaxKind.TypeReference) was unrecognised, so the form lost any item-type info. |
||
|
|
de7daaa81a |
fix: exclude system objects and workflow/dashboard from AI/MCP write tool descriptors (#20973)
## Summary fix: exclude system join objects from AI/MCP create/update/delete tool descriptors Closes #20403 --- AI was used for assistance. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
1adb59f7f8 |
Support optional labels on logic-function input schema fields (#20471)
Adds an optional label field to logic-function input schema properties (InputSchemaProperty and InputJsonSchema). When set, the workflow builder renders the label in place of the raw property key for both leaf inputs and nested sections; when unset, it falls back to the key. jsonSchemaToInputSchema propagates the label so app authors can declare it in their JSON schema. Payload paths, the variable picker, and saved workflow inputs continue to use the property key — labels are display-only. |
||
|
|
ed75fc8a25 |
Use workflow inputSchema to render boolean, number, and enum fields in code/logic function steps (#20439)
<img width="415" height="772" alt="Screenshot 2026-05-11 at 4 44 08 PM" src="https://github.com/user-attachments/assets/32dbdd3c-e60b-4c43-90bc-18be05f22dcf" /> <img width="414" height="371" alt="Screenshot 2026-05-11 at 4 48 24 PM" src="https://github.com/user-attachments/assets/83be062c-7ed3-4953-98bb-e4290865040b" /> |
||
|
|
d47ddad4c5 |
Add multiselect option to form step (#18979)
<img width="415" height="462" alt="Capture d’écran 2026-03-25 à 18 10 06" src="https://github.com/user-attachments/assets/e5157636-25ea-41b9-ae13-8f64a24cbd5e" /> <img width="415" height="462" alt="Capture d’écran 2026-03-25 à 18 10 12" src="https://github.com/user-attachments/assets/5811848f-1ce6-4c09-9563-61f906968888" /> |
||
|
|
8ef32c4781 |
Add In-Reply-To to Email Workflow Node (#18641)
Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
b2b3a3f860 |
Workflow iterator continues on faillure (#18325)
<img width="450" height="212" alt="Capture d’écran 2026-03-03 à 11 41 54" src="https://github.com/user-attachments/assets/b2c29a48-7dc0-4b16-a085-8f305d21f7ca" /> New status `FAIL_SAFE` added. This status propagates to the following nodes until reaching the iterator, that will start the new iteration. The difference with `SKIP` is that, when the parent nodes have at least one `FAIL_SAFE`, it becomes `FAIL_SAFE` too. While a parent 1 `SKIP` + parent 2 `SUCCESS` => to be executed. I also thought about just going back to the iterator as a break would, but since we have branches, it may lead to inconsistent statuses with parallel updates. |
||
|
|
163c1175cb |
File - Migrate core pictures (workspace and member logo) + workflow attachments (#17924)
- Create a common file-by-id download controller - Create core picture module with resolver and logic to handle workspaceLogo and workspaceMemberProfilePicture update - Create workflow file module (same) - Data migration |
||
|
|
e3fcff00b0 |
Fix initial code step functionInput (#18002)
At code step creation ## before <img width="623" height="816" alt="image" src="https://github.com/user-attachments/assets/0aed5ed8-8d56-4988-9d31-fe80942191bb" /> ## after <img width="627" height="528" alt="image" src="https://github.com/user-attachments/assets/9e2a5cb9-7480-4734-8e41-25b45edcec07" /> |
||
|
|
da064d5e88 |
Support define is tool logic function (#17926)
- supports isTool and timeout settings in defineLogicFunction in apps and in setting tabs definition - compute for all toolInputSchema for logic funciton, in settings and in code steps <img width="991" height="802" alt="image" src="https://github.com/user-attachments/assets/05dc1221-cac9-45a3-87b0-3b13161446fd" /> |
||
|
|
84afbb4d2c |
feat: add draft email workflow action (#17793)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> |
||
|
|
1e01f15182 |
Fix code step and logic function step in workflows (#17856)
- AI still often forgets to update the code step after creating it. Adding a next step - Starting by loading logic functions, so it avoids creating code steps when a function exists - Fix create complete workflow logic. Should not create code steps directly |
||
|
|
cc1ca630fd |
Workflow Send Email Node Multiple Recipients Support (#17458)
This PR adds support sending emails to multiple recipients Figma Reference: https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=88868-88963&t=Ya0csmNlN4xxczvV-11 Demo: https://github.com/user-attachments/assets/ecaeaaec-fe42-4fb5-96d3-a91d08b30148 |
||
|
|
6eebf6f23a |
Remove versions from logicFunction (#17540)
## Summary - Remove `latestVersion` and `publishedVersions` columns from `LogicFunction` entity - Remove version parameter from logic function execution, build, and source code retrieval - Update all related services, DTOs, utilities, and tests - Add database migration to drop the version columns |
||
|
|
3ad7a9ac94 |
App logic function as step (#17525)
- Add logic function as step - Rename previous one as Code <img width="494" height="573" alt="Capture d’écran 2026-01-28 à 16 15 29" src="https://github.com/user-attachments/assets/82f7e720-20da-4926-b5bc-94a33cd1f53a" /> <img width="494" height="264" alt="Capture d’écran 2026-01-28 à 16 15 39" src="https://github.com/user-attachments/assets/a0444ae9-8c50-418d-8c5f-68c4da08fce7" /> <img width="494" height="264" alt="Capture d’écran 2026-01-28 à 16 15 58" src="https://github.com/user-attachments/assets/3025db09-4e59-421e-8dc5-f3a7a7326554" /> --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> |
||
|
|
9672ca09a2 |
[Bug] Fix broken variables for database event (#17526)
Fix https://github.com/twentyhq/twenty/issues/17524 Recent update meant to support variable with spaces and dots broken the database event variables. Inserting a variable `My Name` will be inserted as `{{trigger.[My Name]}}` For example, for a database event, we send `trigger.properties.after.id` instead of `id`. With the new code, we will consider `properties.after.id` as a variable to wrap in `[]`, giving `trigger.[properties.after.id]` which cannot be resolve. Let's only support variable with spaces. Removing the logic to wrap variable with dots. |
||
|
|
da6f1bbef3 |
Rename serverlessFunction to logicFunction (#17494)
## Summary Rename "Serverless Function" to "Logic Function" across the codebase for clearer naming. ### Environment Variable Changes | Old | New | |-----|-----| | `SERVERLESS_TYPE` | `LOGIC_FUNCTION_TYPE` | | `SERVERLESS_LAMBDA_REGION` | `LOGIC_FUNCTION_LAMBDA_REGION` | | `SERVERLESS_LAMBDA_ROLE` | `LOGIC_FUNCTION_LAMBDA_ROLE` | | `SERVERLESS_LAMBDA_SUBHOSTING_URL` | `LOGIC_FUNCTION_LAMBDA_SUBHOSTING_URL` | | `SERVERLESS_LAMBDA_ACCESS_KEY_ID` | `LOGIC_FUNCTION_LAMBDA_ACCESS_KEY_ID` | | `SERVERLESS_LAMBDA_SECRET_ACCESS_KEY` | `LOGIC_FUNCTION_LAMBDA_SECRET_ACCESS_KEY` | ### Breaking Changes - Environment variables must be updated in production deployments - Database migration renames `serverlessFunction` → `logicFunction` tables |
||
|
|
c008e874e5 |
Allow variables with dots and keys (#17361)
Fixes https://github.com/twentyhq/private-issues/issues/410#issuecomment-3781085655 Currently, JSON keys with spaces like { "toto toto": 123 } are rejected with "JSON keys cannot contain spaces" error. This is problematic for HTTP requests and webhook triggers where users cannot control the response structure. We use Handlebars to eval variables, segment-literal bracket notation to escape keys with special characters: Normal: {{step.normalKey}} With spaces: `{{step.[key with space]}}` So we simply need to wrap segments with spaces with brackets. This PR: - Create shared path utilities to wrap the variable segments when needed - Use it in all variable generation places - Remove the restrictions This body is now supported: <img width="609" height="457" alt="Capture d’écran 2026-01-22 à 16 10 13" src="https://github.com/user-attachments/assets/e7653c0a-df1e-49af-9c9a-4b7d59a99726" /> |
||
|
|
dff5e3cd7b |
feat: Add If/Else node (#16833)
Closes [#1265](https://github.com/twentyhq/core-team-issues/issues/1265) |
||
|
|
95e0793f81 |
Compute output schema on frontend (#16530)
Fixes https://github.com/twentyhq/core-team-issues/issues/1382 Current issue : all step output schemas are computed and stored on backend side. Which means that, when the database schema is updated - like a field creation - steps needs to be deleted an recreated. Which is invisible to users. Solution : schema generation is moved on frontend side 1. Coming on the page the first time, the schema is populated for all steps except a few ones that are handled differently (Code, Webhook, http node, Agent) 2. A separated state allow to determine if a step needs a recomputation. 3. The user only needs a refresh to see the whole schema re-computed Follow-up: - check if remaining backend steps could be moved to runtime computation. But Code will still require storage. - Clean backend service that is not used anymore |
||
|
|
77e592502c |
fix: improve CRON schedule validation and display (#16360)
Fixes multiple issues with CRON schedule input validation and execution time display. ### Issues Fixed 1. **UTC label placement** - Added "UTC" suffix to specific times (e.g., "at 09:30 UTC") but not to interval descriptions (e.g., "every hour") 2. **Upcoming execution time calculation** - Fixed incorrect execution times for malformed CRON expressions by implementing auto-correction ### Changes - Created `normalizeCronExpression` utility to standardize cron expressions before parsing - Updated `formatTime` to support optional UTC suffix - Enhanced `getHoursDescription` to append UTC to specific times - Added comprehensive test coverage (102 tests passing) ### Before - `"1 /3 * * *"` showed daily executions at same time (incorrect) - `"9 * * *"` showed same time repeated 3 times (incorrect) - No UTC labels on schedule descriptions (confusing) ### After - All malformed expressions auto-corrected and show correct execution times - UTC labels clearly indicate timezone for specific times - User-friendly error messages for truly invalid patterns Closes #15870 |
||
|
|
a82b74c1ff |
Make upsert action body common with create record instead of update record (#15442)
We do not want the fields to update multiselect for upsert record action. We want all available fields displayed by default. This makes upsert record action closer to create record than update record. This PR: - deletes WorkflowUpdateRecordBody that was common between update and upsert and put back content into update - creates WorkflowCreateRecordBody that is now common between create and upsert - simplifies shouldDisplayFormField Before - using fields to update as update record action <img width="546" height="823" alt="Capture d’écran 2025-10-30 à 10 00 24" src="https://github.com/user-attachments/assets/9206bc8b-75c2-40fa-a8de-e708b6b2cd05" /> After - displaying all fields as create record action <img width="546" height="823" alt="Capture d’écran 2025-10-30 à 10 00 04" src="https://github.com/user-attachments/assets/87141a47-946f-4604-be55-f4c21ff4a3d8" /> |
||
|
|
ab24cae2eb | Front references to views as records (#15425) | ||
|
|
da0e5ba342 |
Support primitive types in filters (#15402)
When using primitive types such as array, number and boolean, we display a text field in filters because fieldmetadataId is empty. We should instead support these as we would do for our own fields. Adding also a fix for https://github.com/twentyhq/twenty/issues/15282 --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> |
||
|
|
be08233fa4 |
Add sort section in find records action (#15340)
https://github.com/user-attachments/assets/020f3e97-316b-41db-b95d-7b938a7f82cf |
||
|
|
4effa5351c |
Allow to stop running workflow (#15270)
https://github.com/user-attachments/assets/599154e4-8743-471b-b05a-721b635bcf4e On stoppage: - if no running steps, mark pending as failed and end the workflow - if running steps, set as stopping and exit. Going to the next step, as the workflow is not running anymore, it will naturally stop |
||
|
|
4b846a42a2 |
feat: Attachement for Send Email workflow node (#15044)
## Description - This PR addresses the one issue out of https://github.com/twentyhq/core-team-issues/issues/1685 - Added backend support for workflow node to support attachement - updated send email schema, core utility - added workflowattachmentRow and workflowsendEmailAttachment file to handle file attachment in email workflow - updated Google and Microsoft to use MailComposer which unifies with SMTP provider and improves mail structure ## Visual Appearance https://github.com/user-attachments/assets/16478569-0a83-417e-a85e-70e41fe83343 --------- Co-authored-by: martmull <martmull@hotmail.fr> |
||
|
|
27f50c4f4e |
feat: add-create-update-record in workflow (#14654)
## Description - this PR focuses on issue https://github.com/twentyhq/core-team-issues/issues/1476 - Added upsert action ## Visual Appearance <img width="1792" height="1041" alt="Screenshot 2025-10-03 at 12 57 58 PM" src="https://github.com/user-attachments/assets/57afb96c-d4b3-4a87-95f0-11ac4bd61dd8" /> <img width="1792" height="1031" alt="Screenshot 2025-10-03 at 12 57 48 PM" src="https://github.com/user-attachments/assets/9032d4c2-f0d2-46f1-8682-a7e5c280a303" /> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr> |
||
|
|
4e5783eaf4 |
feat: workflow delay action (Pause - Wait/Sleep/Delay) (#14915)
## Description - This PR focuses on issue https://github.com/orgs/twentyhq/projects/1/views/33?pane=issue&itemId=93150683&issue=twentyhq%7Ccore-team-issues%7C20 - added Workflow delay as a Flow action - for V1 added Type 1: Resume at a specific date or time ## Visual Appearance <img width="1792" height="1038" alt="Screenshot 2025-10-09 at 5 46 18 PM" src="https://github.com/user-attachments/assets/e62980e9-59c7-4e5a-b8ec-1e848a462d3f" /> <img width="1792" height="1037" alt="Screenshot 2025-10-09 at 5 46 35 PM" src="https://github.com/user-attachments/assets/7c3f4e39-ab0a-40ed-97a8-4f0cdb86f295" /> --------- Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr> |
||
|
|
d3f3f991a5 |
Infer array current item schema (#15115)
This PR allows to infer the schema of the current item of an iterator step: - iterator step receive a variable - added an util that navigate to the array in schema - navigateOutputSchemaProperty - use the array value in schema to generate a new schema - used the existing getFunctionOutputSchema that I renamed and moved to twenty-shared Also cleaned a bit the existing schema for AI. Before https://github.com/user-attachments/assets/9767fc89-3524-4bfb-b1ab-8abe92084767 After https://github.com/user-attachments/assets/3650c1d2-14f2-44f9-b10c-e649fe04128d |
||
|
|
cbfd73cbd8 |
Enable filters in iterators (#15017)
Filters should not cut the whole workflow. These should only stop the branch. This PR: - adds a new skipped status - when a filter stops, it still goes to the next step - the next step will execute if there is at least a successful step - if only skipped step, it will be skipped as well It allows to use filters in iterators. https://github.com/user-attachments/assets/1cfca052-55c0-4ce5-9eb8-63736618d082 |
||
|
|
e382730a39 |
Accept any json value in result and error (#14954)
Some users might throw JS values that aren't strings. <img width="3456" height="2160" alt="CleanShot 2025-10-07 at 17 48 36@2x" src="https://github.com/user-attachments/assets/b02f886c-1c8e-4186-954b-6d0e025a6fcf" /> Errors seen previously: <img width="1308" height="840" alt="image" src="https://github.com/user-attachments/assets/1d7e4947-8f6f-44fb-aefb-c7d105712204" /> |