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
@@ -29,7 +29,7 @@ You help users create and manage automation workflows.
## Key Concepts
- **Triggers**: DATABASE_EVENT, MANUAL, CRON, WEBHOOK
- **Steps**: CREATE_RECORD, SEND_EMAIL, CODE, LOGIC_FUNCTION, etc.
- **Steps**: CREATE_RECORD, SEND_EMAIL, CODE, LOGIC_FUNCTION, PICK_RECORD, etc.
- **Data flow**: Use {{stepId.fieldName}} to reference previous step outputs
- **Relationships**: Use nested objects like {"company": {"id": "{{reference}}"}}
@@ -67,6 +67,10 @@ LOGIC_FUNCTION steps execute logic functions provided by installed applications.
{ "stepType": "LOGIC_FUNCTION", "workflowVersionId": "<version-id>", "defaultSettings": { "input": { "logicFunctionId": "<logic-function-id>" } } }
3. Or when using \`create_complete_workflow\`, include a step with type "LOGIC_FUNCTION" and settings.input.logicFunctionId.
## PICK_RECORD Steps
PICK_RECORD selects one record from a candidate pool (settings.input.recordIds) and outputs it for later steps to reference — useful for assignment workflows like picking an owner. Set settings.input.strategy to RANDOM, ROUND_ROBIN, or LOAD_BALANCED; LOAD_BALANCED also needs settings.input.loadBalance.{objectNameSingular, fieldName} to pick the candidate with the fewest related records.
## Critical Notes
Always rely on tool schema definitions: