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. -->
This commit is contained in:
Félix Malfait
2026-06-22 07:09:15 +02:00
committed by GitHub
parent 82f6597dc3
commit 2abf9c2930
11 changed files with 824 additions and 10 deletions
@@ -1,16 +1,37 @@
import { z } from 'zod';
import { isDefined } from '@/utils';
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
export const workflowPickRecordStrategySchema = z.enum([
'RANDOM',
'ROUND_ROBIN',
'LOAD_BALANCED',
]);
export const workflowPickRecordActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.object({
objectName: z.string(),
strategy: workflowPickRecordStrategySchema,
recordIds: z.array(z.string()),
}),
input: z
.object({
objectName: z.string(),
strategy: workflowPickRecordStrategySchema,
recordIds: z.array(z.string()),
loadBalance: z
.object({
objectNameSingular: z.string(),
fieldName: z.string(),
})
.optional(),
})
.superRefine((input, ctx) => {
if (
input.strategy === 'LOAD_BALANCED' &&
!isDefined(input.loadBalance)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['loadBalance'],
message: 'loadBalance is required when strategy is LOAD_BALANCED',
});
}
}),
});
@@ -174,6 +174,26 @@ describe('searchVariableInOutputSchema - record output schema', () => {
});
});
describe('PICK_RECORD routing', () => {
it('routes a Pick Record output variable through the record schema', () => {
const result = searchVariableInOutputSchema({
schema: mockRecordSchema,
stepType: 'PICK_RECORD',
stepName: 'Pick Company',
rawVariableName: '{{step1.name}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Company Name',
variablePathLabel: 'Pick Company > Company Name',
variableType: FieldMetadataType.TEXT,
fieldMetadataId: 'company-name-metadata-id',
compositeFieldSubFieldName: undefined,
});
});
});
describe('error handling', () => {
it('should handle undefined recordOutputSchema', () => {
const result = searchVariableThroughRecordOutputSchema({
@@ -27,6 +27,7 @@ const RECORD_STEP_TYPES = [
'UPDATE_RECORD',
'DELETE_RECORD',
'UPSERT_RECORD',
'PICK_RECORD',
];
const isRecordOutputSchemaV2 = (