/**
* @module utils/shardConfig
* @description CAP-002 — Single source of truth for the per-request shard
* + parallel-worker normalization used by both `/run` and `/trigger`.
*
* Replaces the verbatim copy of the clamp + decoupling logic that previously
* lived in `routes/runs.js` and `routes/trigger.js`. Per AGENT.md pre-flight
* rule #4 ("if a helper is used by ≥2 call sites, put it in `utils/`"), this
* lives here as a pure function so both entry points apply identical
* semantics.
*
* ### Contract — BUG-0001 decoupling
*
* `shardCount` and `parallelWorkers` are **independent** concepts:
*
* - `shardCount` — cross-process partition count. Only `> 1` when the
* caller explicitly passed `shards: N`. Drives the per-shard progress
* badge on RunDetail and the BullMQ job fan-out. Cross-shard
* parallelism comes from BullMQ's worker pool (`concurrency: MAX_WORKERS`
* in `backend/src/workers/runWorker.js`); each shard runs in its own
* job and is picked up by a separate worker slot.
* - `parallelWorkers` — concurrency *inside* one shard's process (the
* in-shard test pool). Reflects only the dials input; defaults to `1`.
*
* `shardCount` and `parallelWorkers` are **independent** — total
* concurrent browser instances on one replica is bounded by
* `MAX_WORKERS × parallelWorkers`, NOT `shardCount × parallelWorkers`.
* The previous formula `max(shardCount, dialsParallelWorkers)` re-coupled
* the two and produced surprising N² concurrency for callers requesting
* `shards: 4` without an explicit dials override (one slot per shard ×
* 4 internal workers per shard = 16 browsers). Decoupled here so
* `shards: 4` with no dials input gives 4× cross-process parallelism at
* 1 browser per shard — the operator-intuitive shape.
*
* A request that only sets `dialsConfig.parallelWorkers: 4` leaves
* `shardCount = 1` so no shard badge is shown (BUG-0001 — discovered during
* the CAP-002 review pass).
*
* `shards` is clamped to `[1, WORKER_CONCURRENCY]` server-side regardless of
* input type. Non-numeric / negative / fractional values fall back to `1`.
*
* The upper bound is read from `WORKER_CONCURRENCY` with `MAX_WORKERS` as a
* backward-compatible fallback — the same precedence the BullMQ worker uses
* in `backend/src/workers/runWorker.js`. Without this mirror, a deployment
* that sets `WORKER_CONCURRENCY=8` (AUTO-008's preferred knob) but leaves
* `MAX_WORKERS` unset would silently clamp `shards: 8` requests down to 2,
* wasting 6 worker slots.
*
* @param {unknown} shardsInput - Raw `req.body.shards` (any type).
* @param {number|null} [dialsParallelWorkers] - Validated dials request, may be undefined.
* @returns {{ shardCount: number, parallelWorkers: number, maxWorkers: number }}
*/
export function normalizeShardConfig(shardsInput, dialsParallelWorkers) {
const maxWorkers = Math.max(
1,
parseInt(process.env.WORKER_CONCURRENCY || process.env.MAX_WORKERS || "2", 10) || 2,
);
const normalizedShards = Number.isFinite(Number(shardsInput))
? Math.max(1, Math.min(maxWorkers, Math.trunc(Number(shardsInput))))
: null;
const shardCount = normalizedShards ?? 1;
const parallelWorkers = Math.max(1, dialsParallelWorkers ?? 1);
return { shardCount, parallelWorkers, maxWorkers };
}