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. -->
This commit is contained in:
Félix Malfait
2026-06-21 21:51:16 +02:00
committed by GitHub
parent f4219449db
commit 573fd00ea7
27 changed files with 672 additions and 2 deletions
@@ -19,6 +19,7 @@ import {
type workflowIteratorActionSchema,
type workflowLogicFunctionActionSchema,
type workflowManualTriggerSchema,
type workflowPickRecordActionSchema,
type workflowRunSchema,
type workflowRunStateSchema,
type workflowRunStatusSchema,
@@ -56,6 +57,9 @@ export type WorkflowUpsertRecordAction = z.infer<
export type WorkflowFindRecordsAction = z.infer<
typeof workflowFindRecordsActionSchema
>;
export type WorkflowPickRecordAction = z.infer<
typeof workflowPickRecordActionSchema
>;
export type WorkflowDelayAction = z.infer<typeof workflowDelayActionSchema>;
export type WorkflowFilterAction = z.infer<typeof workflowFilterActionSchema>;
export type WorkflowFormAction = z.infer<typeof workflowFormActionSchema>;
@@ -79,6 +83,7 @@ export type WorkflowAction =
| WorkflowDeleteRecordAction
| WorkflowUpsertRecordAction
| WorkflowFindRecordsAction
| WorkflowPickRecordAction
| WorkflowFilterAction
| WorkflowIfElseAction
| WorkflowFormAction
@@ -19,6 +19,7 @@ import { WorkflowEditActionFormFiller } from '@/workflow/workflow-steps/workflow
import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest';
import { WorkflowEditActionIfElse } from '@/workflow/workflow-steps/workflow-actions/if-else-action/components/WorkflowEditActionIfElse';
import { WorkflowEditActionIterator } from '@/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator';
import { WorkflowEditActionPickRecord } from '@/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord';
import { WorkflowEditActionLogicFunction } from '@/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
@@ -199,6 +200,18 @@ export const WorkflowRunStepNodeDetail = ({
);
}
case 'PICK_RECORD': {
return (
<WorkflowEditActionPickRecord
key={stepId}
action={stepDefinition.definition}
actionOptions={{
readonly: true,
}}
/>
);
}
case 'FORM': {
return (
<WorkflowEditActionFormFiller
@@ -19,6 +19,7 @@ import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflo
import { WorkflowEditActionIfElse } from '@/workflow/workflow-steps/workflow-actions/if-else-action/components/WorkflowEditActionIfElse';
import { WorkflowEditActionIterator } from '@/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator';
import { WorkflowEditActionLogicFunction } from '@/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction';
import { WorkflowEditActionPickRecord } from '@/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManual } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManual';
@@ -185,6 +186,16 @@ export const WorkflowStepDetail = ({
);
}
case 'PICK_RECORD': {
return (
<WorkflowEditActionPickRecord
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
case 'FORM': {
return (
<WorkflowEditActionFormBuilder
@@ -2,6 +2,7 @@ import { type WorkflowActionType } from '@/workflow/types/Workflow';
import { CREATE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/CreateRecordAction';
import { DELETE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/DeleteRecordAction';
import { FIND_RECORDS_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/FindRecordsAction';
import { PICK_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/PickRecordAction';
import { UPDATE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/UpdateRecordAction';
import { UPSERT_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/UpsertRecordAction';
@@ -14,6 +15,7 @@ export const RECORD_ACTIONS: Array<{
| 'DELETE_RECORD'
| 'UPSERT_RECORD'
| 'FIND_RECORDS'
| 'PICK_RECORD'
>;
icon: string;
}> = [
@@ -22,4 +24,5 @@ export const RECORD_ACTIONS: Array<{
DELETE_RECORD_ACTION,
FIND_RECORDS_ACTION,
UPSERT_RECORD_ACTION,
PICK_RECORD_ACTION,
];
@@ -0,0 +1,11 @@
import { type WorkflowActionType } from '@/workflow/types/Workflow';
export const PICK_RECORD_ACTION: {
defaultLabel: string;
type: Extract<WorkflowActionType, 'PICK_RECORD'>;
icon: string;
} = {
defaultLabel: 'Pick Record',
type: 'PICK_RECORD',
icon: 'IconArrowsShuffle',
};
@@ -0,0 +1,184 @@
import { styled } from '@linaria/react';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator } from 'twenty-ui/layout';
import { useDebouncedCallback } from 'use-debounce';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectMetadataSelectHelpers } from '@/object-metadata/hooks/useObjectMetadataSelectHelpers';
import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { type WorkflowPickRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WorkflowObjectDropdownContent } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowObjectDropdownContent';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledLabel = styled.span`
color: ${themeCssVariables.font.color.light};
display: block;
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin-bottom: ${themeCssVariables.spacing[1]};
`;
const StyledObjectSelectContainer = styled.div`
width: 100%;
`;
const defaultSelectedOptionMessage = msg`Select an option`;
type WorkflowEditActionPickRecordProps = {
action: WorkflowPickRecordAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowPickRecordAction) => void;
};
};
type PickRecordFormData = {
objectNameSingular: string;
recordIds: string[];
};
export const WorkflowEditActionPickRecord = ({
action,
actionOptions,
}: WorkflowEditActionPickRecordProps) => {
const { t } = useLingui();
const { getSelectIconPropsFromObjectMetadataItem } =
useObjectMetadataSelectHelpers();
const dropdownId = `workflow-edit-action-pick-record-object-name-${action.id}`;
const { closeDropdown } = useCloseDropdown();
const { objectMetadataItems } = useFilteredObjectMetadataItems();
const [formData, setFormData] = useState<PickRecordFormData>(() => ({
objectNameSingular: action.settings.input.objectName,
recordIds: action.settings.input.recordIds,
}));
const isFormDisabled = actionOptions.readonly ?? false;
const selectedObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === formData.objectNameSingular,
);
const selectedOption = selectedObjectMetadataItem
? {
label: selectedObjectMetadataItem.labelPlural,
value: selectedObjectMetadataItem.nameSingular,
...getSelectIconPropsFromObjectMetadataItem(selectedObjectMetadataItem),
}
: { label: i18n._(defaultSelectedOptionMessage), value: '' };
const saveAction = useDebouncedCallback(
async (updatedFormData: PickRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedFormData.objectNameSingular,
strategy: action.settings.input.strategy,
recordIds: updatedFormData.recordIds,
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const handleObjectChange = (value: string) => {
if (actionOptions.readonly === true) {
return;
}
const newFormData: PickRecordFormData = {
objectNameSingular: value,
recordIds: [],
};
setFormData(newFormData);
saveAction(newFormData);
closeDropdown(dropdownId);
};
const handleRecordIdsChange = (recordIds: string[]) => {
if (isFormDisabled === true) {
return;
}
const newFormData: PickRecordFormData = {
...formData,
recordIds,
};
setFormData(newFormData);
saveAction(newFormData);
};
return (
<>
<WorkflowStepBody>
<StyledObjectSelectContainer>
<StyledLabel>{t`Object`}</StyledLabel>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponent={
<SelectControl
isDisabled={isFormDisabled}
selectedOption={selectedOption}
/>
}
dropdownComponents={
!isFormDisabled && (
<WorkflowObjectDropdownContent
onOptionClick={handleObjectChange}
/>
)
}
dropdownOffset={{ y: 4 }}
/>
</StyledObjectSelectContainer>
<HorizontalSeparator noMargin />
{isDefined(selectedObjectMetadataItem) && (
<FormMultiRecordPicker
key={selectedObjectMetadataItem.nameSingular}
label={t`Pick at random from`}
objectNameSingular={selectedObjectMetadataItem.nameSingular}
defaultValue={formData.recordIds}
onChange={(value) =>
handleRecordIdsChange(Array.isArray(value) ? value : [])
}
readonly={isFormDisabled}
/>
)}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
);
};
@@ -12,6 +12,7 @@ export const getActionIcon = (actionType: WorkflowActionType) => {
case 'DELETE_RECORD':
case 'UPSERT_RECORD':
case 'FIND_RECORDS':
case 'PICK_RECORD':
return RECORD_ACTIONS.find((item) => item.type === actionType)?.icon;
case 'AI_AGENT':
return AI_ACTIONS.find((item) => item.type === actionType)?.icon;
@@ -17,6 +17,7 @@ export const getActionIconColorOrThrow = (
case 'DELETE_RECORD':
case 'UPSERT_RECORD':
case 'FIND_RECORDS':
case 'PICK_RECORD':
return themeCssVariables.font.color.tertiary;
case 'FORM':
return themeCssVariables.color.orange;
@@ -167,7 +167,8 @@ export const computeStepOutputSchema = ({
case 'CREATE_RECORD':
case 'UPDATE_RECORD':
case 'DELETE_RECORD':
case 'UPSERT_RECORD': {
case 'UPSERT_RECORD':
case 'PICK_RECORD': {
const objectName = step.settings?.input?.objectName;
if (!isDefined(objectName)) {