137 lines
10 KiB
JavaScript
137 lines
10 KiB
JavaScript
// Свободное место на диске. Замер — родным `statfs` Node (есть с 18-й версии),
|
||
// без вызова внешних программ: они на Windows зависят от локали и языка системы.
|
||
//
|
||
// 🪤 Ноль почти всегда сбой, а не правда о мире. Не удалось замерить — отдаём `null`,
|
||
// и `null` означает ЗАПРЕТ. «Наверное хватит» — это то, чем семь работников загоняют
|
||
// сервер в ноль за одну ночь: composer install и два npm ci на каждого.
|
||
import { statfs } from 'node:fs/promises';
|
||
|
||
// ━━━ 🔴🔴 ЦЕНА ОДНОГО РАБОЧЕГО УГЛА — ЗАМЕРЕНА, А НЕ ПРИДУМАНА ━━━
|
||
// Замер 05.08.2026 своей рукой, по углам, что лежат на этой машине
|
||
// (`.claude/worktrees/night-workspaces/<прогон>/<работник>`):
|
||
//
|
||
// rabota-1/w-1 2,42 ГБ (собран целиком)
|
||
// krug-d-full/w-1 2,21 ГБ из них зависимости: node_modules 1,72 · app/node_modules 0,35 · app/vendor 0,14
|
||
// krug-d-proba/w-1 1,72 ГБ (собран наполовину — только внешний node_modules)
|
||
// zhivoe-5/w-1 0,00 ГБ (не собирался вовсе)
|
||
//
|
||
// 🪤 Все четыре числа — НИЖНЯЯ граница: мерил прибор Windows, а он молча пропускает пути
|
||
// длиннее 260 знаков, которых в `node_modules` полно. Поэтому берётся не среднее, а верхнее
|
||
// с округлением ВВЕРХ: 2,5 ГБ. Занизить это число дороже, чем завысить, — занижение и есть
|
||
// та беда, ради которой всё это писалось.
|
||
//
|
||
// 🔴 ПОЧЕМУ ЧИСЛО ЖИВЁТ В КОДЕ, А НЕ В `night.config.json`. У настроек НЕТ ИСТОРИИ — файл
|
||
// закрыт от хранилища. Затея уже заплатила за это: настройку правили рукой между замером
|
||
// и записью, оплаченное показание сгорело молча, и три смены искали виновника в коде,
|
||
// которого никто не менял. Замеренное число обязано лежать там, где у него есть история.
|
||
// Владелец вправе его перебить — доводом `naUgol`, — но по умолчанию оно отсюда.
|
||
export const GB_NA_ODIN_UGOL = 2.5;
|
||
|
||
// 🔴🔴 ПОРОГ СЧИТАЕТСЯ ОТ ЧИСЛА РАБОТНИКОВ, А НЕ ВПИСЫВАЕТСЯ ЧИСЛОМ.
|
||
//
|
||
// ━━━ ОТКУДА ЭТО ━━━
|
||
// В настройках стояло плоское `disk_free_min_gb: 5`, и оно не было выведено ни из чего.
|
||
// А работников по настройке семеро, и каждому собирается СВОЙ угол: `composer install`
|
||
// плюс два `npm ci`. Углы живут ОДНОВРЕМЕННО — проверено по коду: `startAll` заводит их
|
||
// в одном цикле и ни одного не сносит, а `destroyWorkspace` зовёт только уборка старья
|
||
// (семь дней), и то не во время прогона. Значит место занимают не по очереди, а разом.
|
||
//
|
||
// ⇒ При пороге 5 ночь законно трогалась на шести свободных гигабайтах и упиралась в диск
|
||
// часа в три, когда на машине никого нет. И это худшее место для такой дыры: нехватка
|
||
// места — ЕДИНСТВЕННЫЙ повод разбудить владельца в тихие часы (замысел §«В тихие часы
|
||
// владельца не тревожат»). То есть неверен был сторож ровно у той беды, ради которой
|
||
// затея вообще имеет право будить человека.
|
||
//
|
||
// 🪤 `rabotnikov` берётся из `max_workers_per_task`, и имя настройки врёт: по решению
|
||
// владельца 24 от 05.08.2026 это потолок работников НА ВСЮ НОЧЬ, а не на задачу. Читать
|
||
// его как «на задачу» и умножать на число задач — значит завысить порог вдвое и не пускать
|
||
// ночь на здоровой машине.
|
||
//
|
||
// 🔴 Не сказали числа работников — порога НЕТ (`null`), и `diskState` откажет. Считать
|
||
// порог, не зная, сколько углов собирать, — это ровно та слепота, от которой лечим.
|
||
export function porogMesta({ rabotnikov, zapasGb, naUgol = GB_NA_ODIN_UGOL } = {}) {
|
||
const tseloePolozhitelnoe = n => typeof n === 'number' && Number.isInteger(n) && n > 0;
|
||
const chislo = n => typeof n === 'number' && Number.isFinite(n);
|
||
if (!tseloePolozhitelnoe(rabotnikov)) return null;
|
||
if (!chislo(zapasGb) || zapasGb < 0) return null;
|
||
if (!chislo(naUgol) || naUgol <= 0) return null;
|
||
// 🪤 Дроби складываются с погрешностью (2.5 * 7 + 5 даёт хвост в разряде мусора).
|
||
// Округляем до одного знака — порог показывают владельцу глазами.
|
||
const gb = Math.round((rabotnikov * naUgol + zapasGb) * 10) / 10;
|
||
return {
|
||
gb,
|
||
rabotnikov,
|
||
na_ugol: naUgol,
|
||
zapas_gb: zapasGb,
|
||
slova: `${gb} ГБ = ${rabotnikov} работников × ${naUgol} ГБ на угол + ${zapasGb} ГБ запаса сверх`,
|
||
};
|
||
}
|
||
|
||
export async function freeGb(path) {
|
||
try {
|
||
const s = await statfs(path);
|
||
return (s.bavail * s.bsize) / 1024 ** 3;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function diskState(free, porog) {
|
||
// 🔴 Порог приходит ДВУМЯ видами, и оба законны:
|
||
// · голым числом — так его зовут проверки правила и так звали все зовущие до 05.08.2026;
|
||
// · разбором от `porogMesta` — тогда к отказу прибавляются слова, ОТКУДА взялось число.
|
||
// Второе важнее, чем кажется: владелец, прочитавший «свободно 6 ГБ при пороге 22.5 ГБ»,
|
||
// без разбора решит, что затея зажралась, и снизит порог рукой — то есть вернёт ту самую
|
||
// дыру. С разбором он видит, что 22.5 — это семь углов по 2,5 ГБ плюс запас.
|
||
const razbor = porog && typeof porog === 'object' ? porog : null;
|
||
const minGb = razbor ? razbor.gb : porog;
|
||
|
||
// 🔴 ПОРОГА НЕТ — ЭТО НЕ «МОЖНО». Раньше эта ветка отсутствовала, и потерянная настройка
|
||
// `disk_free_min_gb` снимала правило целиком, молча и зелено: `50 <= undefined` — ложь,
|
||
// значит место пропускало что угодно, вплоть до нуля свободных гигабайт. Ровно то, от чего
|
||
// лечит девиз этого файла: ноль почти всегда сбой, а не правда о мире.
|
||
// Вырежи эту ветку — покраснеет «порога не дали».
|
||
//
|
||
// 🔴 05.08.2026 эта ветка ОЖИЛА. Прежде оба живых зовущих подставляли `?? 5` — потерянная
|
||
// настройка молча становилась пятёркой, и до сюда дело не доходило НИКОГДА: правило было
|
||
// написано, проверено и мертво. Теперь зовущие подают разбор `porogMesta`, а он на любой
|
||
// недостачe отдаёт `null`, и отказ доезжает до владельца по-настоящему.
|
||
if (typeof minGb !== 'number' || !Number.isFinite(minGb)) {
|
||
return {
|
||
ok: false,
|
||
free_gb: typeof free === 'number' && Number.isFinite(free) ? free : null,
|
||
min_gb: null,
|
||
reason: 'Порог свободного места не задан (в night.config.json потеряна настройка disk_free_min_gb '
|
||
+ 'или max_workers_per_task — порог считается от числа работников) — '
|
||
+ 'сравнивать не с чем. Работать без порога нельзя: «наверное хватит» и есть тот случай, '
|
||
+ 'когда сервер уходит в ноль за одну ночь.',
|
||
};
|
||
}
|
||
if (typeof free !== 'number' || !Number.isFinite(free)) {
|
||
return {
|
||
ok: false,
|
||
free_gb: null,
|
||
min_gb: minGb,
|
||
reason: 'Свободное место замерить не удалось — работать вслепую нельзя.',
|
||
};
|
||
}
|
||
// Ровно порог — уже нельзя: порог это черта, а не «можно до неё».
|
||
if (free <= minGb) {
|
||
const otkaz = {
|
||
ok: false,
|
||
free_gb: free,
|
||
min_gb: minGb,
|
||
reason: `На сервере кончается место: свободно ${free.toFixed(1)} ГБ при пороге ${minGb} ГБ.`
|
||
+ (razbor ? ` Порог не выдуман: ${razbor.slova}. Собрать все углы будет негде,`
|
||
+ ' и ночь упрётся в диск посреди работы — а это единственный повод разбудить владельца.' : ''),
|
||
};
|
||
// 🪤 Поле кладётся ТОЛЬКО когда разбор вправду есть. Положи его всегда — сравнение
|
||
// «отдал ровно это» у прежних проверок, зовущих голым числом, разошлось бы лишним ключом.
|
||
if (razbor) otkaz.raschet = razbor;
|
||
return otkaz;
|
||
}
|
||
const mozhno = { ok: true, free_gb: free, min_gb: minGb, reason: null };
|
||
if (razbor) mozhno.raschet = razbor;
|
||
return mozhno;
|
||
}
|