573fd00ea7
## 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. -->
207 lines
11 KiB
TypeScript
207 lines
11 KiB
TypeScript
/*
|
|
* _____ _
|
|
*|_ _|_ _____ _ __ | |_ _ _
|
|
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
|
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
|
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
|
* |___/
|
|
*/
|
|
|
|
export { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from './constants/CaptureAllVariableTagInnerRegex';
|
|
export { CONTENT_TYPE_VALUES_HTTP_REQUEST } from './constants/ContentTypeValuesHttpRequest';
|
|
export { IF_ELSE_BRANCH_POSITION_OFFSETS } from './constants/IfElseBranchPositionOffsets';
|
|
export { OBJECTS_BLOCKED_FROM_AUTOMATION } from './constants/ObjectsBlockedFromAutomation';
|
|
export { TRIGGER_STEP_ID } from './constants/TriggerStepId';
|
|
export { WORKFLOW_TRIGGER_METADATA_KEY } from './constants/WorkflowTriggerMetadataKey';
|
|
export { WORKFLOW_TRIGGER_METADATA_LABEL } from './constants/WorkflowTriggerMetadataLabel';
|
|
export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_KEY } from './constants/WorkflowTriggerMetadataWorkspaceMemberIdKey';
|
|
export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_LABEL } from './constants/WorkflowTriggerMetadataWorkspaceMemberIdLabel';
|
|
export { WORKFLOW_TRIGGER_PAYLOAD_KEY } from './constants/WorkflowTriggerPayloadKey';
|
|
export { WORKFLOW_TRIGGER_RECORD_LABEL } from './constants/WorkflowTriggerRecordLabel';
|
|
export { WORKFLOW_TRIGGER_RECORDS_LABEL } from './constants/WorkflowTriggerRecordsLabel';
|
|
export { WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS } from './layout/constants/WorkflowDiagramDefaultNodeDimensions';
|
|
export { WORKFLOW_LAYOUT_DEFAULT_OPTIONS } from './layout/constants/WorkflowLayoutDefaultOptions';
|
|
export type {
|
|
WorkflowLayoutNode,
|
|
WorkflowLayoutEdge,
|
|
WorkflowLayoutPosition,
|
|
WorkflowLayoutOptions,
|
|
} from './layout/utils/compute-workflow-layout.util';
|
|
export { computeWorkflowLayout } from './layout/utils/compute-workflow-layout.util';
|
|
export { workflowAiAgentActionSchema } from './schemas/ai-agent-action-schema';
|
|
export { workflowAiAgentActionSettingsSchema } from './schemas/ai-agent-action-settings-schema';
|
|
export { baseTriggerSchema } from './schemas/base-trigger-schema';
|
|
export { baseWorkflowActionSchema } from './schemas/base-workflow-action-schema';
|
|
export { baseWorkflowActionSettingsSchema } from './schemas/base-workflow-action-settings-schema';
|
|
export { workflowCodeActionSchema } from './schemas/code-action-schema';
|
|
export { workflowCodeActionSettingsSchema } from './schemas/code-action-settings-schema';
|
|
export { workflowCreateRecordActionSchema } from './schemas/create-record-action-schema';
|
|
export { workflowCreateRecordActionSettingsSchema } from './schemas/create-record-action-settings-schema';
|
|
export { workflowCronTriggerSchema } from './schemas/cron-trigger-schema';
|
|
export { workflowDatabaseEventTriggerSchema } from './schemas/database-event-trigger-schema';
|
|
export { workflowDeleteRecordActionSchema } from './schemas/delete-record-action-schema';
|
|
export { workflowDeleteRecordActionSettingsSchema } from './schemas/delete-record-action-settings-schema';
|
|
export { workflowDraftEmailActionSchema } from './schemas/draft-email-action-schema';
|
|
export { workflowEmptyActionSchema } from './schemas/empty-action-schema';
|
|
export { workflowEmptyActionSettingsSchema } from './schemas/empty-action-settings-schema';
|
|
export { expectedOutputSchemaShape } from './schemas/expected-output-schema-shape';
|
|
export { workflowFilterActionSchema } from './schemas/filter-action-schema';
|
|
export { workflowFilterActionSettingsSchema } from './schemas/filter-action-settings-schema';
|
|
export { workflowFindRecordsActionSchema } from './schemas/find-records-action-schema';
|
|
export { workflowFindRecordsActionSettingsSchema } from './schemas/find-records-action-settings-schema';
|
|
export { workflowFormActionSchema } from './schemas/form-action-schema';
|
|
export { workflowFormActionSettingsSchema } from './schemas/form-action-settings-schema';
|
|
export { workflowHttpRequestActionSchema } from './schemas/http-request-action-schema';
|
|
export { workflowHttpRequestActionSettingsSchema } from './schemas/http-request-action-settings-schema';
|
|
export { workflowIfElseActionSchema } from './schemas/if-else-action-schema';
|
|
export {
|
|
stepIfElseBranchSchema,
|
|
workflowIfElseActionSettingsSchema,
|
|
} from './schemas/if-else-action-settings-schema';
|
|
export { workflowIteratorActionSchema } from './schemas/iterator-action-schema';
|
|
export { workflowIteratorActionSettingsSchema } from './schemas/iterator-action-settings-schema';
|
|
export { workflowLogicFunctionActionSchema } from './schemas/logic-function-action-schema';
|
|
export { workflowLogicFunctionActionSettingsSchema } from './schemas/logic-function-action-settings-schema';
|
|
export { workflowManualTriggerSchema } from './schemas/manual-trigger-schema';
|
|
export { objectRecordSchema } from './schemas/object-record-schema';
|
|
export { workflowPickRecordActionSchema } from './schemas/pick-record-action-schema';
|
|
export {
|
|
workflowPickRecordStrategySchema,
|
|
workflowPickRecordActionSettingsSchema,
|
|
} from './schemas/pick-record-action-settings-schema';
|
|
export { workflowSendEmailActionSchema } from './schemas/send-email-action-schema';
|
|
export type { WorkflowEmailFiles } from './schemas/send-email-action-settings-schema';
|
|
export {
|
|
workflowEmailFilesSchema,
|
|
workflowSendEmailActionSettingsSchema,
|
|
} from './schemas/send-email-action-settings-schema';
|
|
export { stepFilterGroupSchema } from './schemas/step-filter-group-schema';
|
|
export { stepFilterSchema } from './schemas/step-filter-schema';
|
|
export { workflowUpdateRecordActionSchema } from './schemas/update-record-action-schema';
|
|
export { workflowUpdateRecordActionSettingsSchema } from './schemas/update-record-action-settings-schema';
|
|
export { workflowUpsertRecordActionSchema } from './schemas/upsert-record-action-schema';
|
|
export { workflowUpsertRecordActionSettingsSchema } from './schemas/upsert-record-action-settings-schema';
|
|
export { workflowWebhookTriggerSchema } from './schemas/webhook-trigger-schema';
|
|
export { workflowActionSchema } from './schemas/workflow-action-schema';
|
|
export { workflowDelayActionSchema } from './schemas/workflow-delay-action-schema';
|
|
export { workflowDelayActionSettingsSchema } from './schemas/workflow-delay-action-settings-schema';
|
|
export { workflowFileSchema } from './schemas/workflow-file-action-schema';
|
|
export { workflowRunSchema } from './schemas/workflow-run-schema';
|
|
export { workflowRunStateSchema } from './schemas/workflow-run-state-schema';
|
|
export { workflowRunStateStepInfoSchema } from './schemas/workflow-run-state-step-info-schema';
|
|
export { workflowRunStateStepInfosSchema } from './schemas/workflow-run-state-step-infos-schema';
|
|
export { workflowRunStatusSchema } from './schemas/workflow-run-status-schema';
|
|
export {
|
|
workflowRunStepLogSchema,
|
|
workflowRunStepLogsSchema,
|
|
} from './schemas/workflow-run-step-log-schema';
|
|
export { workflowRunStepStatusSchema } from './schemas/workflow-run-step-status-schema';
|
|
export { workflowTriggerSchema } from './schemas/workflow-trigger-schema';
|
|
export type { EmailRecipients } from './types/EmailRecipients';
|
|
export type { FunctionInput } from './types/FunctionInput';
|
|
export type {
|
|
RecordSchemaType,
|
|
InputSchemaPropertyType,
|
|
InputSchemaProperty,
|
|
InputSchema,
|
|
} from './types/InputSchema';
|
|
export type { StepIfElseBranch } from './types/StepIfElseBranch';
|
|
export { WorkflowActionType } from './types/WorkflowActionType';
|
|
export type { WorkflowAttachment } from './types/WorkflowAttachment';
|
|
export type { BodyType } from './types/workflowHttpRequestStep';
|
|
export type {
|
|
WorkflowRunStepInfo,
|
|
WorkflowRunStepInfos,
|
|
} from './types/WorkflowRunStateStepInfos';
|
|
export { StepStatus } from './types/WorkflowRunStateStepInfos';
|
|
export type {
|
|
WorkflowRunStepLog,
|
|
WorkflowRunStepLogs,
|
|
AiAgentStepLogDetails,
|
|
AiToolCallLog,
|
|
} from './types/WorkflowRunStepLog';
|
|
export { canObjectBeManagedByAutomation } from './utils/canObjectBeManagedByAutomation';
|
|
export { extractRawVariableNamePart } from './utils/extractRawVariableNameParts';
|
|
export { getFunctionInputFromInputSchema } from './utils/getFunctionInputFromInputSchema';
|
|
export { getWorkflowRunContext } from './utils/getWorkflowRunContext';
|
|
export { parseBooleanFromStringValue } from './utils/parseBooleanFromStringValue';
|
|
export { parseDataFromContentType } from './utils/parseDataFromContentType';
|
|
export {
|
|
needsEscaping,
|
|
escapePathSegment,
|
|
joinVariablePath,
|
|
parseVariablePath,
|
|
} from './utils/variable-path.util';
|
|
export { isIfElseStepInput } from './validation/guards/isIfElseStepInput';
|
|
export { isIteratorStepInput } from './validation/guards/isIteratorStepInput';
|
|
export type {
|
|
IfElseStepInput,
|
|
IteratorStepInput,
|
|
WorkflowValidationSeverity,
|
|
WorkflowValidationIssueCode,
|
|
WorkflowValidationIssue,
|
|
WorkflowValidationResult,
|
|
ValidatableWorkflowStep,
|
|
ValidatableWorkflowTrigger,
|
|
ValidatableWorkflow,
|
|
} from './validation/types/workflow-validation.type';
|
|
export type { WorkflowGraph } from './validation/utils/build-workflow-graph.util';
|
|
export { buildWorkflowGraph } from './validation/utils/build-workflow-graph.util';
|
|
export { extractVariablesFromInput } from './validation/utils/extract-variables-from-input.util';
|
|
export { getEditDistance } from './validation/utils/get-edit-distance.util';
|
|
export {
|
|
getStepInput,
|
|
getStepOutgoingStepIds,
|
|
} from './validation/utils/get-step-outgoing-step-ids.util';
|
|
export { getVariablePathSuggestions } from './validation/utils/get-variable-path-suggestions.util';
|
|
export { validateWorkflowGraph } from './validation/utils/validate-workflow-graph.util';
|
|
export { validateWorkflowStepParams } from './validation/utils/validate-workflow-step-params.util';
|
|
export { validateWorkflowVariableReferences } from './validation/utils/validate-workflow-variable-references.util';
|
|
export { validateWorkflowStructure } from './validation/validate-workflow-structure.util';
|
|
export { isBaseOutputSchemaV2 } from './workflow-schema/guards/isBaseOutputSchemaV2';
|
|
export type {
|
|
LeafType,
|
|
NodeType,
|
|
Leaf,
|
|
Node,
|
|
BaseOutputSchemaV2,
|
|
} from './workflow-schema/types/base-output-schema.type';
|
|
export type {
|
|
RecordFieldLeaf,
|
|
RecordFieldNode,
|
|
RecordFieldNodeValue,
|
|
FieldOutputSchemaV2,
|
|
RecordOutputSchemaV2,
|
|
RecordNode,
|
|
FindRecordsOutputSchema,
|
|
IteratorOutputSchema,
|
|
FormFieldLeaf,
|
|
FormFieldNode,
|
|
FormOutputSchema,
|
|
LinkOutputSchema,
|
|
CodeOutputSchema,
|
|
ManualTriggerOutputSchema,
|
|
OutputSchemaV2,
|
|
VariableSearchResult,
|
|
} from './workflow-schema/types/output-schema.type';
|
|
export { buildManualTriggerMetadataNode } from './workflow-schema/utils/build-manual-trigger-metadata-node';
|
|
export { collectOutputSchemaPaths } from './workflow-schema/utils/collect-output-schema-paths';
|
|
export type { OutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure';
|
|
export { findOutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure';
|
|
export { navigateOutputSchemaProperty } from './workflow-schema/utils/navigate-output-schema-property';
|
|
export type { ResolvedVariable } from './workflow-schema/utils/resolve-variable-path-in-output-schema';
|
|
export {
|
|
resolveInSchema,
|
|
resolveVariablePathInOutputSchema,
|
|
collectOutputSchemaVariablePaths,
|
|
} from './workflow-schema/utils/resolve-variable-path-in-output-schema';
|
|
export {
|
|
searchRecordOutputSchema,
|
|
searchVariableInOutputSchema,
|
|
} from './workflow-schema/utils/search-variable-in-output-schema';
|
|
export type {
|
|
GlobalAvailability,
|
|
SingleRecordAvailability,
|
|
BulkRecordsAvailability,
|
|
} from './workflow-trigger/types/workflow-trigger.type';
|