Files
portal/tools/night/disk.mjs
T

137 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Свободное место на диске. Замер — родным `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;
}