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
@@ -98,6 +98,7 @@ export class WorkflowSchemaWorkspaceService {
case WorkflowActionType.UPDATE_RECORD:
case WorkflowActionType.DELETE_RECORD:
case WorkflowActionType.UPSERT_RECORD:
case WorkflowActionType.PICK_RECORD:
return this.computeRecordOutputSchema({
objectType: step.settings.input.objectName,
workspaceId,
@@ -54,6 +54,11 @@ const VARIABLE_CONSUMING_ACTION_TYPES = new Set<WorkflowActionType>([
...RECORD_CRUD_ACTION_TYPES,
]);
const OBJECT_TARGETING_ACTION_TYPES = new Set<WorkflowActionType>([
...RECORD_CRUD_ACTION_TYPES,
WorkflowActionType.PICK_RECORD,
]);
@Injectable()
export class WorkflowValidationWorkspaceService {
constructor(
@@ -578,7 +583,7 @@ export class WorkflowValidationWorkspaceService {
steps: WorkflowAction[];
}): Promise<WorkflowValidationIssue[]> {
const recordSteps = steps.filter((step) =>
RECORD_CRUD_ACTION_TYPES.has(step.type),
OBJECT_TARGETING_ACTION_TYPES.has(step.type),
);
if (recordSteps.length === 0) {
@@ -442,6 +442,28 @@ export class WorkflowVersionStepOperationsWorkspaceService {
},
};
}
case WorkflowActionType.PICK_RECORD: {
const activeObjectMetadataItem =
await this.objectMetadataRepository.findOne({
where: { workspaceId, isActive: true, isSystem: false },
});
return {
builtStep: {
...baseStep,
name: 'Pick Record',
type: WorkflowActionType.PICK_RECORD,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
strategy: 'RANDOM',
recordIds: [],
},
},
},
};
}
case WorkflowActionType.FORM: {
return {
builtStep: {
@@ -21,6 +21,7 @@ import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/
import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action';
import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action';
import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action';
import { PickRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/pick-record.workflow-action';
import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action';
import { UpsertRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action';
import { WorkflowActionType } from 'twenty-shared/workflow';
@@ -35,6 +36,7 @@ export class WorkflowActionFactory {
private readonly updateRecordWorkflowAction: UpdateRecordWorkflowAction,
private readonly deleteRecordWorkflowAction: DeleteRecordWorkflowAction,
private readonly findRecordsWorkflowAction: FindRecordsWorkflowAction,
private readonly pickRecordWorkflowAction: PickRecordWorkflowAction,
private readonly formWorkflowAction: FormWorkflowAction,
private readonly filterWorkflowAction: FilterWorkflowAction,
private readonly ifElseWorkflowAction: IfElseWorkflowAction,
@@ -67,6 +69,8 @@ export class WorkflowActionFactory {
return this.deleteRecordWorkflowAction;
case WorkflowActionType.FIND_RECORDS:
return this.findRecordsWorkflowAction;
case WorkflowActionType.PICK_RECORD:
return this.pickRecordWorkflowAction;
case WorkflowActionType.FORM:
return this.formWorkflowAction;
case WorkflowActionType.FILTER:
@@ -0,0 +1,11 @@
import { WorkflowActionType } from 'twenty-shared/workflow';
import {
type WorkflowAction,
type WorkflowPickRecordAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const isWorkflowPickRecordAction = (
action: WorkflowAction,
): action is WorkflowPickRecordAction => {
return action.type === WorkflowActionType.PICK_RECORD;
};
@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { type ObjectRecord } from 'twenty-shared/types';
import { resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { isWorkflowPickRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-pick-record-action.guard';
import { type WorkflowPickRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type';
@Injectable()
export class PickRecordWorkflowAction implements WorkflowAction {
constructor(
private readonly findRecordsService: FindRecordsService,
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
) {}
async execute({
currentStepId,
steps,
context,
runInfo,
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = findStepOrThrow({
steps,
stepId: currentStepId,
});
if (!isWorkflowPickRecordAction(step)) {
throw new WorkflowStepExecutorException(
'Step is not a pick record action',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const { objectName, recordIds } = resolveInput(
step.settings.input,
context,
) as WorkflowPickRecordActionInput;
if (!isNonEmptyString(objectName) || !Array.isArray(recordIds)) {
return { error: 'Pick record action received invalid input' };
}
if (recordIds.length === 0) {
return { error: 'Pick record action has no candidate records' };
}
const executionContext =
await this.workflowExecutionContextService.getExecutionContext(runInfo);
const findRecordsOutput = await this.findRecordsService.execute({
objectName,
filter: { id: { in: recordIds } },
authContext: executionContext.authContext,
rolePermissionConfig: executionContext.rolePermissionConfig,
shouldBuildEffectiveSelectFields: false,
});
if (!findRecordsOutput.success) {
return { error: findRecordsOutput.error || findRecordsOutput.message };
}
const candidateRecords = (findRecordsOutput.result?.records ??
[]) as ObjectRecord[];
if (candidateRecords.length === 0) {
return {
error: 'Pick record action could not find any of the candidate records',
};
}
const pickedRecord =
candidateRecords[Math.floor(Math.random() * candidateRecords.length)];
return { result: pickedRecord };
}
}
@@ -10,6 +10,7 @@ import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-e
import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action';
import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action';
import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action';
import { PickRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/pick-record.workflow-action';
import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action';
import { UpsertRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action';
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
@@ -31,6 +32,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
UpdateRecordWorkflowAction,
DeleteRecordWorkflowAction,
FindRecordsWorkflowAction,
PickRecordWorkflowAction,
],
exports: [
CreateRecordWorkflowAction,
@@ -38,6 +40,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
UpdateRecordWorkflowAction,
DeleteRecordWorkflowAction,
FindRecordsWorkflowAction,
PickRecordWorkflowAction,
],
})
export class RecordCRUDActionModule {}
@@ -0,0 +1,7 @@
export type WorkflowPickRecordStrategy = 'RANDOM';
export type WorkflowPickRecordActionInput = {
objectName: string;
strategy: WorkflowPickRecordStrategy;
recordIds: string[];
};
@@ -1,3 +1,4 @@
import { type WorkflowPickRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type';
import {
type WorkflowUpsertRecordActionInput,
type WorkflowCreateRecordActionInput,
@@ -26,3 +27,7 @@ export type WorkflowFindRecordsActionSettings = BaseWorkflowActionSettings & {
export type WorkflowUpsertRecordActionSettings = BaseWorkflowActionSettings & {
input: WorkflowUpsertRecordActionInput;
};
export type WorkflowPickRecordActionSettings = BaseWorkflowActionSettings & {
input: WorkflowPickRecordActionInput;
};
@@ -13,6 +13,7 @@ import {
type WorkflowCreateRecordActionSettings,
type WorkflowDeleteRecordActionSettings,
type WorkflowFindRecordsActionSettings,
type WorkflowPickRecordActionSettings,
type WorkflowUpdateRecordActionSettings,
type WorkflowUpsertRecordActionSettings,
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type';
@@ -42,6 +43,7 @@ export type WorkflowActionSettings =
| WorkflowDeleteRecordActionSettings
| WorkflowUpsertRecordActionSettings
| WorkflowFindRecordsActionSettings
| WorkflowPickRecordActionSettings
| WorkflowFormActionSettings
| WorkflowFilterActionSettings
| WorkflowIfElseActionSettings
@@ -14,6 +14,7 @@ import {
type WorkflowCreateRecordActionSettings,
type WorkflowDeleteRecordActionSettings,
type WorkflowFindRecordsActionSettings,
type WorkflowPickRecordActionSettings,
type WorkflowUpdateRecordActionSettings,
type WorkflowUpsertRecordActionSettings,
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type';
@@ -77,6 +78,11 @@ export type WorkflowFindRecordsAction = BaseWorkflowAction & {
settings: WorkflowFindRecordsActionSettings;
};
export type WorkflowPickRecordAction = BaseWorkflowAction & {
type: WorkflowActionType.PICK_RECORD;
settings: WorkflowPickRecordActionSettings;
};
export type WorkflowFormAction = BaseWorkflowAction & {
type: WorkflowActionType.FORM;
settings: WorkflowFormActionSettings;
@@ -126,6 +132,7 @@ export type WorkflowAction =
| WorkflowDeleteRecordAction
| WorkflowUpsertRecordAction
| WorkflowFindRecordsAction
| WorkflowPickRecordAction
| WorkflowFormAction
| WorkflowFilterAction
| WorkflowIfElseAction