662 lines
50 KiB
JavaScript
662 lines
50 KiB
JavaScript
#!/usr/bin/env node
|
||
// Точки входа. `mail` зовёт планировщик Windows в 8:00, остальное — помощник по слову
|
||
// владельца. Кусок 1 знал четыре команды из одиннадцати; кусок 2 добавил ещё три
|
||
// (`budget`, `seal`, `approve-spec`) — итого семь. Остальные заводят куски 3–7.
|
||
//
|
||
// 🆕 Правка К1 (О-2): логика вынесена в экспортируемые функции (`main`, `runMailCommand`,
|
||
// `collectSummary`, `sweepNow`) с внедряемыми зависимостями — иначе проверить кусок 1
|
||
// нечем: раньше это был голый скрипт, который на одном чтении настроек уже дёргал
|
||
// `process.exit`. Точка входа (низ файла) зовёт `main()` только когда файл запущен
|
||
// САМ, а не подключён проверкой как модуль — `import.meta.url` совпал бы с путём
|
||
// запуска только в первом случае.
|
||
import { join, resolve } from 'node:path';
|
||
import { existsSync, readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { readJson, readLines, appendLine, writeJsonAtomic } from './state.mjs';
|
||
import {
|
||
REPO_ROOT, configPath, mailFailuresPath, summaryLinesPath as summaryLinesPathOf,
|
||
lastRunPath, summarySentPath, priyomkaDir, runsDir,
|
||
budgetMeasuresPath, probeAttemptsPath, planSealPath, specApprovalPath,
|
||
} from './paths.mjs';
|
||
import { buildSummary } from './summary.mjs';
|
||
import { makeTransport as makeTransportDefault, sendSummary as sendSummaryDefault } from './mailer.mjs';
|
||
import { planRun, startAll, smokeLongRun } from './run.mjs';
|
||
import { sweepStaleWorkspaces } from './workspace.mjs';
|
||
import { budgetState, recordMeasure } from './budget.mjs';
|
||
// 🔴 К платному замеру остатка точка входа ходит ТОЛЬКО через эти ворота: они считают
|
||
// сделанное за смену (владелец разрешил до 30) и отказывают на пределе. Прямой зов
|
||
// `probeUtilization` отсюда убран намеренно — сторож `platnyy-zamer-tolko-cherez-schyot`
|
||
// покраснеет, если его вернут.
|
||
import { zamerSoSchyotom } from './schyot-zamerov.mjs';
|
||
import { freeGb, diskState } from './disk.mjs';
|
||
import { sealPlan, checkSeal, assertPlanEditable } from './plan-fingerprint.mjs';
|
||
// 🔴 Разбор каталога прогонов «пустота против порванного» в затее ОДИН, и живёт он у замка
|
||
// на правку плана (`readRuns`, plan-lock-hook.mjs) — там этот класс беды закрыли первым.
|
||
// Своего второго обхода каталога здесь не заводим: две копии одного правила расходятся молча,
|
||
// а расходятся они ровно в ту ночь, когда запись порвалась.
|
||
import { readRuns } from './plan-lock-hook.mjs';
|
||
import { specApproved, papersCommitted, paperPathsFor } from './paper-gates.mjs';
|
||
// 🔴 Три тихие части точки входа живут отдельным файлом и проверены двадцатью семью
|
||
// проверками: разбор доводов, раскладка ответов правил по кормушкам ворот и строки отказа.
|
||
// Здесь их только зовут — иначе проверки сторожили бы код, которого программа не исполняет.
|
||
// 🪤 `zamerDovody` отсюда убран: доводы замера собирают теперь ворота счёта
|
||
// (`schyot-zamerov.mjs`) — там же, где зовётся сам замер. Оставленный тут мёртвый ввоз
|
||
// выглядел бы как «точка входа всё ещё умеет ходить к деньгам сама».
|
||
import { razborDovodov, vhodVorot, strokiOtkaza } from './cli-parts.mjs';
|
||
// 🔴 ВОРОТА ПЛАНА И ПРАВИЛО «ЧЕМ МЕРИТЬ ПУНКТ» — оба из `plan-model.mjs`, и оба нужны ДВОИМ:
|
||
// командной строке (здесь) и надзирателю (задача 5). Второй дороги к ним не заводить.
|
||
// 🔴🔴 Долг, закрытый здесь: до этой строки `validatePlan` не звал в рабочем коде НИКТО —
|
||
// правило было написано, проверено 44 проверками и не подключено к делу ни одним зовом.
|
||
// План с повторяющимися номерами пунктов, план без единого пункта, план с мерным прибором
|
||
// в `files=` — все уходили в ночь молча.
|
||
import { validatePlan, komandaProverokPunkta } from './plan-model.mjs';
|
||
// Ворота возврата отложенной задачи (проверки 28, 29, 30).
|
||
import { deferredTaskGate } from './items.mjs';
|
||
// 🔴 Путь к следу знает ОДИН файл на всю затею. Своего второго каталога следов здесь
|
||
// не заводить ни в каком виде: разойдись они хоть на знак — след лёг бы в одно место,
|
||
// а искался в другом, и отложенная задача подхватилась бы назавтра САМА.
|
||
import { readDeferred, clearDeferred, deferredDir } from './deferred.mjs';
|
||
|
||
function flag(argv, name) {
|
||
const i = argv.indexOf(name);
|
||
return i > -1 && i + 1 < argv.length ? argv[i + 1] : null;
|
||
}
|
||
|
||
// 🔴 ДВОЕ ВОРОТ ПОКА НЕ СТОРОЖАТ. Было четверо; двоих (потолки задач и работников,
|
||
// пересечение задач по файлам) кусок 3 дописал, и они сторожат по-настоящему.
|
||
// Оставшимся двоим правило пишет кусок 7; до тех пор они пропускают всё — иначе живую ночь
|
||
// нельзя было бы запустить, и владелец не увидел бы работающую затею до самого конца.
|
||
// Решение владельца: пропускать — но говорить об этом вслух, каждый раз, поимённо.
|
||
// Молчаливо пропускающие ворота — это самообман: человек видит «все ворота пройдены»
|
||
// и думает, что его сторожат девятеро.
|
||
const VOROTA_POKA_NE_STOROZHAT = [
|
||
'отклик владельца',
|
||
'от прошлого прогона никого',
|
||
];
|
||
|
||
function stroka_pro_nestorozhashchie() {
|
||
return `Эти ворота пока НЕ сторожат (их правило пишет кусок 7, сейчас они пропускают всё): ${VOROTA_POKA_NE_STOROZHAT.join(' · ')}.`;
|
||
}
|
||
|
||
// 🔴 Место мерится по каталогу ХОЗЯЙСТВА, а не по текущему каталогу: у задачи планировщика
|
||
// рабочий каталог системный, и она мерила бы не тот диск.
|
||
async function diskNow(cfg = {}) {
|
||
return diskState(await freeGb(REPO_ROOT), cfg.disk_free_min_gb ?? 5);
|
||
}
|
||
|
||
// Какие прогоны идут прямо сейчас. Нужно дважды: чтобы не дать перепечатать план идущего
|
||
// прогона (проверка 43) и чтобы честно сказать воротам ёмкости, сколько задач в работе.
|
||
//
|
||
// 🔴🔴 ПОПРАВЛЕНО ПО ЖИВОЙ БЕДЕ. Прежде здесь стоял плоский `readJson`, а он на порванном
|
||
// файле отдаёт `null` — и `.filter(Boolean)` убирал такой прогон из счёта МОЛЧА. Ворота
|
||
// порвали одну запись и тут же завели третью задачу при потолке в две, а следом задачу
|
||
// на файл, который порванный прогон держит: оба отказа исчезли без единого слова.
|
||
// Порванная запись — это «неизвестно, идёт он или кончился», а не «такого прогона нет».
|
||
//
|
||
// 🔴 Второго способа читать карточки прогонов НЕ ЗАВОДИМ. Прибор «пустота против порванного»
|
||
// в затее один — `readJsonOrBroken` в state.mjs, — и разбор каталога прогонов этим прибором
|
||
// уже написан: `readRuns` в plan-lock-hook.mjs (замок на правку плана, тот же класс беды
|
||
// и то же решение — «не смогли прочесть, значит замок ЗАКРЫТ»). Зовём его. Свой третий
|
||
// обход `readdirSync` рядом разъехался бы с ним молча.
|
||
//
|
||
// Каталог берётся доводом только ради проверок: живые зовущие зовут без довода и работают
|
||
// по настоящему `runsDir()`.
|
||
export function runningRuns(dir = runsDir()) {
|
||
const vse = readRuns(dir);
|
||
const porvannye = vse.filter(r => r?.broken);
|
||
if (porvannye.length) {
|
||
throw new Error(
|
||
`Карточку прогона прочесть не удалось: ${porvannye.map(r => r.card_path ?? r.run_id).join(', ')}. `
|
||
+ 'Файл на месте, но разобрать его нельзя. Считать это «такого прогона нет» нельзя: так потолок '
|
||
+ 'задач и замок на файлы откроются всем именно в ту ночь, когда запись порвалась (а рвётся она '
|
||
+ 'чаще всего оттого, что на сервере кончилось место посреди записи). Посмотрите этот файл глазами: '
|
||
+ 'прогон кончился — поправьте или удалите карточку, прогон идёт — остановите его командой «стоп всё».',
|
||
);
|
||
}
|
||
return vse.filter(r => r && r.status === 'running');
|
||
}
|
||
|
||
// 🔴 ОДНА ДВЕРЬ НА ОБОИХ ЗОВУЩИХ. Счёт идущих прогонов спрашивают двое — `seal` и `run`, —
|
||
// и обоим нужен ОДИН ответ на порванную запись: сказать владельцу строку и не запускаться,
|
||
// а не обвалиться следом стека. Два `try` по месту разъехались бы молча: поправят один,
|
||
// забудут другой. Довод `chitat` — точка впрыска для проверок, живые зовущие зовут без него.
|
||
export function idushchieProgony(chitat = runningRuns) {
|
||
try {
|
||
return { ok: true, running: chitat(), reason: null };
|
||
} catch (err) {
|
||
return {
|
||
ok: false,
|
||
running: [],
|
||
reason: `Сколько прогонов идёт сейчас — сосчитать не удалось. ${err?.message ?? err}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 🔴 Принудительная уборка старых углов случается ЗДЕСЬ — вместе с утренней сводкой,
|
||
// потому что это единственное, что просыпается каждый день само. Про каждый убранный угол
|
||
// в сводку идёт отдельная строка: тихо уносить чужую работу нельзя.
|
||
//
|
||
// 🆕 Долг перед куском 6 (решение владельца 126 от 01.08.2026): угол работника со статусом
|
||
// `stalled` или `extinguished` и БЕЗ отметки `resumed_at` эта уборка не тронет, сколько бы
|
||
// дней ни прошло, — правило и его причина названы у `isWorkspaceStale` (задача 7). Здесь,
|
||
// в `night:mail`/`night:summary`, ничего звать по-другому не нужно: защита стоит внутри
|
||
// `sweepStaleWorkspaces`, а не в этой обёртке, и обходить её отсюда нельзя.
|
||
export function sweepNow(cfg) {
|
||
return sweepStaleWorkspaces({
|
||
repoRoot: REPO_ROOT,
|
||
maxAgeDays: cfg.workspace_max_age_days ?? 7,
|
||
}).map(s => s.line);
|
||
}
|
||
|
||
// 🆕 Чтение последнего прогона и строк сводки вынесено во внедряемые зависимости
|
||
// (`readLastRun`/`readSummaryLines`) — иначе проверить «сводка на пустом месте не молчит»
|
||
// нечем без похода в НАСТОЯЩИЙ `.night/` этого рабочего угла, а там уже живут файлы
|
||
// второго правщика (виден `.night/mail-failures.jsonl`, `.night/runs/`) — трогать их нельзя.
|
||
export function collectSummary(cfg, extraLines = [], {
|
||
readLastRun = () => readJson(lastRunPath()),
|
||
readSummaryLines = runId => readLines(summaryLinesPathOf(runId)).map(r => r.line).filter(Boolean),
|
||
// Замер места делает зовущий и передаёт сюда готовым: сама сводка никуда не ходит.
|
||
disk = null,
|
||
} = {}) {
|
||
const last = readLastRun();
|
||
const lines = last?.run_id ? readSummaryLines(last.run_id) : [];
|
||
return buildSummary({
|
||
run_id: last?.run_id ?? null,
|
||
workers: last?.workers ?? [],
|
||
lines: [...lines, ...extraLines],
|
||
// Кусок 2 умеет мерить место — выдуманного числа здесь по-прежнему нет: не замерили,
|
||
// значит `null`, и сводка скажет об этом словами.
|
||
disk_free_gb: typeof disk?.free_gb === 'number' ? Number(disk.free_gb.toFixed(1)) : null,
|
||
server_rebooted_at: last?.server_rebooted_at ?? null,
|
||
});
|
||
}
|
||
|
||
// О-1: самое дорогое во всей затее. Раньше между «собрать сводку» и «отдать отправлялке»
|
||
// не было НИКАКОЙ защиты — сбой сборки сводки или транспорта (до самого sendMail) вылетал
|
||
// наружу необработанным исключением, `mail-failures.jsonl` не получал строку, и владелец
|
||
// утром видел ТИШИНУ — а тишину он не отличит от «всё хорошо». Отправлялка (`mailer.mjs`
|
||
// `sendSummary`) уже умеет записывать СВОЮ неудачу — тем же способом (`appendLine` в тот
|
||
// же файл) здесь записывается неудача на подступах к ней, не только внутри неё.
|
||
// Настоящих писем эта функция никогда не шлёт САМА — она только собирает и передаёт дальше;
|
||
// шлёт (или не шлёт) внедрённый `transport`.
|
||
export async function runMailCommand({
|
||
cfg,
|
||
failPath = mailFailuresPath(),
|
||
sentPath = summarySentPath(),
|
||
sweep = () => sweepNow(cfg),
|
||
measureDisk = diskNow,
|
||
buildSummaryLines = async extra => collectSummary(cfg, extra, { disk: await measureDisk(cfg) }),
|
||
makeTransport = makeTransportDefault,
|
||
sendSummary = sendSummaryDefault,
|
||
now = () => Date.now(),
|
||
} = {}) {
|
||
try {
|
||
// 🔴 Строка про несторожащие ворота идёт и в письмо тоже, а не только в окно помощника.
|
||
// Убрать её из письма значило бы прятать правду именно от того, кому она нужна.
|
||
const summary = await buildSummaryLines([...sweep(), stroka_pro_nestorozhashchie()]);
|
||
const transport = await makeTransport(cfg);
|
||
return await sendSummary({
|
||
...summary,
|
||
failPath,
|
||
sentPath,
|
||
transport,
|
||
from: cfg.mail.from,
|
||
to: cfg.mail.to,
|
||
});
|
||
} catch (err) {
|
||
// Тем же способом, что уже умеет отправлялка: пишем СВОЮ неудачу в тот же файл.
|
||
// Не бросаем дальше — обещание «никогда не бросает» держится и на этом шаге тоже.
|
||
const error = String(err?.message ?? err);
|
||
const at = now();
|
||
try {
|
||
appendLine(failPath, { at, subject: null, error, stage: 'before-send' });
|
||
} catch (writeErr) {
|
||
// 🔴 Запись собственной неудачи — тоже запись на диск, и она тоже умеет упасть.
|
||
// Последнее, что можно сделать, — крикнуть в свой журнал и всё равно вернуть отказ,
|
||
// а не сделать вид, что письмо ушло.
|
||
console.error(`Сводка не ушла (${error}), и записать эту неудачу тоже не вышло: ${writeErr?.message ?? writeErr}`);
|
||
return { ok: false, error, at, unrecorded: `не записано: ${writeErr?.message ?? writeErr}` };
|
||
}
|
||
return { ok: false, error, at };
|
||
}
|
||
}
|
||
|
||
// 🆕 Разбор команды вынесен в `main(argv, deps)` — точка входа внизу файла зовёт его,
|
||
// когда файл запущен как программа, а не подключён как модуль (проверки подключают
|
||
// именно так). Возвращает КОД, а не зовёт `process.exit` сам — иначе подключить
|
||
// функцию в проверку означало бы уронить процесс проверки вместе с ней.
|
||
export async function main(argv = process.argv, deps = {}) {
|
||
const {
|
||
readConfig = () => readJson(configPath()),
|
||
runMail = runMailCommand,
|
||
summaryOf = collectSummary,
|
||
sweep = sweepNow,
|
||
measureDisk = diskNow,
|
||
} = deps;
|
||
|
||
const cmd = argv[2];
|
||
const cfg = readConfig();
|
||
|
||
if (!cfg) {
|
||
console.error(`Настроек нет: ${configPath()}. Скопируйте night.config.example.json и заполните.`);
|
||
return 2;
|
||
}
|
||
|
||
if (cmd === 'summary') {
|
||
// Та же сводка, что уходит письмом, но напечатанная в окно: помощнику — посмотреть глазами.
|
||
// 🔴 Строка про несторожащие ворота — в оба зова, и в окно, и в письмо.
|
||
const summary = summaryOf(cfg, [...sweep(cfg), stroka_pro_nestorozhashchie()], {
|
||
disk: await measureDisk(cfg),
|
||
});
|
||
console.log(summary.text);
|
||
return 0;
|
||
}
|
||
|
||
if (cmd === 'mail') {
|
||
const r = await runMail({ cfg });
|
||
console.log(r.ok ? 'Сводка отправлена.' : `Сводка НЕ отправлена: ${r.error}`);
|
||
return r.ok ? 0 : 1;
|
||
}
|
||
|
||
if (cmd === 'budget') {
|
||
// 💳 Замер стоит копейки, но не ноль: прибор системы отдаёт остаток только в потоке
|
||
// живого запуска. Своего счёта в долларах у затеи нет вовсе — мы спрашиваем, а не считаем.
|
||
//
|
||
// 🔴 Обёртка обязательна. Нет настройки `probe_model` — ворота падают понятной
|
||
// строкой С ИМЕНЕМ НАСТРОЙКИ (задача 2), и эту строку надо ПОКАЗАТЬ владельцу. Без обёртки
|
||
// он получил бы обвал программы со следом стека — то есть строку не для человека.
|
||
let z = null;
|
||
try {
|
||
z = await zamerSoSchyotom({ cfg, attemptsPath: probeAttemptsPath(), komanda: 'budget' });
|
||
} catch (err) {
|
||
console.error(`Замер не сделан: ${err?.message ?? err}`);
|
||
console.error('Это настройка в вашем night.config.json. Пример строки есть в night.config.example.json.');
|
||
return 1;
|
||
}
|
||
// Замеров за смену уже сделано столько, сколько разрешил владелец. Печатаем ЕГО слова
|
||
// и всё равно показываем, что известно из журнала: остаток по прошлым замерам он видеть
|
||
// вправе, деньги на это не тратятся.
|
||
if (!z.ok) {
|
||
console.error(z.reason);
|
||
console.log(JSON.stringify(budgetState(budgetMeasuresPath(), cfg, Date.now()), null, 2));
|
||
return 1;
|
||
}
|
||
const m = z.measure;
|
||
if (m) {
|
||
recordMeasure(budgetMeasuresPath(), {
|
||
utilization: m.utilization, status: m.status, kind: m.kind,
|
||
resets_at: m.resets_at, at: Date.now(), phase: 'probe',
|
||
});
|
||
if (m.utilization === null) {
|
||
// 🔴 Молчать об этом нельзя: владелец, глядя на `"utilization": null` в ответе, решил бы,
|
||
// что прибор сломан. Он не сломан — доля приходит только вместе с тревогой.
|
||
console.log('Доли израсходованного система не прислала — она кладёт её в событие только вместе с тревогой («allowed_warning»). Светофор системы при этом получен, и решение принимается по нему.');
|
||
}
|
||
} else {
|
||
// 🔴 РЕШЕНИЕ Р2: причина называется ТА САМАЯ, а не одна на все случаи. Прежде здесь
|
||
// стояла строка «события rate_limit_event НЕДЕЛЬНОГО вида от системы не пришло» — и она
|
||
// была враньём ровно в том случае, который случался живьём каждый раз: событие приходило,
|
||
// не совпадал ВИД предела.
|
||
console.error(`Замер не удался: ${z.problem ?? 'причина не названа.'}`);
|
||
console.error('Ноль сюда не подставляется — это был бы выдуманный ответ.');
|
||
}
|
||
console.log(JSON.stringify(budgetState(budgetMeasuresPath(), cfg, Date.now()), null, 2));
|
||
return 0;
|
||
}
|
||
|
||
if (cmd === 'seal') {
|
||
// Печать плана ставит ПОМОЩНИК по слову владельца — ни работник, ни надзиратель.
|
||
// Печать значит «этот текст владелец видел и разрешил»; поставленная автоматом,
|
||
// она значила бы только «файл существует».
|
||
const d = razborDovodov(argv);
|
||
if (d.error) {
|
||
console.error(d.error);
|
||
return 2;
|
||
}
|
||
if (!existsSync(d.planPath)) {
|
||
console.error(`Плана нет: ${d.planPath}`);
|
||
return 2;
|
||
}
|
||
// 🔴 Проверка 43, первая половина: пока идёт прогон, план не перепечатывают. Правило
|
||
// (`assertPlanEditable`) было написано задачей 3 — а звать его было НЕКОМУ. Печать нового
|
||
// текста посреди ночи означала бы, что работники доделывают одно, а разрешено уже другое.
|
||
// 🔴 Порванную карточку прогона считать за «никого нет» нельзя: печать нового текста
|
||
// посреди чужой ночи — ровно то, от чего эти ворота и стоят. Отказ идёт СТРОКОЙ.
|
||
const schyot = idushchieProgony();
|
||
if (!schyot.ok) {
|
||
console.error(`План НЕ опечатан. ${schyot.reason}`);
|
||
return 1;
|
||
}
|
||
const going = schyot.running;
|
||
const editable = assertPlanEditable(going.length ? { status: 'running', run_id: going[0].run_id } : null);
|
||
if (!editable.ok) {
|
||
console.error(editable.reason);
|
||
return 1;
|
||
}
|
||
const seal = sealPlan(planSealPath(d.planPath), d.planPath, { approved_by: 'owner', at: Date.now() });
|
||
console.log(`План опечатан: ${d.planPath}`);
|
||
console.log(`Отпечаток: ${seal.fingerprint}`);
|
||
console.log('Поменяете в нём хоть строчку — запуска по нему не будет, пока не опечатаете заново.');
|
||
return 0;
|
||
}
|
||
|
||
if (cmd === 'approve-spec') {
|
||
// Отметка «замысел проверен» — это ДВА рецензента И слово владельца. Ставит её помощник.
|
||
const d = razborDovodov(argv);
|
||
if (d.error) {
|
||
console.error(d.error);
|
||
return 2;
|
||
}
|
||
if (!existsSync(d.specPath)) {
|
||
console.error(`Бумаги замысла нет: ${d.specPath}`);
|
||
console.error('Надо так: node tools/night/cli.mjs approve-spec <путь-к-бумаге> --reviewer <имя> --reviewer <имя>');
|
||
return 2;
|
||
}
|
||
// 🪤 Путь в отметку кладём в том же виде, в каком его потом спросит команда `run`:
|
||
// приводит его к одному виду сам разбор доводов. Отметка `docs\...\замысел.md` против
|
||
// запроса `docs/.../замысел.md` — это два разных текста, и ворота замысла отказывали бы
|
||
// всегда, а причина была бы непонятной.
|
||
const reviewers = d.reviewers ?? [];
|
||
const mark = { spec_path: d.specPath, reviewed_by: reviewers, owner_said_yes_at: Date.now() };
|
||
// 🔴 Вторым доводом — та же бумага: `specApproved` без неё отказывает всегда
|
||
// («не сказано, по какой бумаге запускают»), и отметку нельзя было бы поставить вовсе.
|
||
const verdict = specApproved(mark, d.specPath);
|
||
if (!verdict.ok) {
|
||
console.error(verdict.reason);
|
||
return 1;
|
||
}
|
||
writeJsonAtomic(specApprovalPath(), mark);
|
||
console.log(`Замысел ${d.specPath} отмечен проверенным: рецензенты ${reviewers.join(', ')} и слово владельца.`);
|
||
return 0;
|
||
}
|
||
|
||
if (cmd === 'run') {
|
||
// 🔴 Разбор доводов — один на все три команды, и он проверен двенадцатью проверками.
|
||
// Разбирай `run` по-своему рядом — проверки сторожили бы не тот код, что работает.
|
||
const razbor = razborDovodov(argv);
|
||
if (razbor.error) {
|
||
console.error(razbor.error);
|
||
return 2;
|
||
}
|
||
const { planPath, priyomkaPath, specPath } = razbor;
|
||
if (!existsSync(planPath)) {
|
||
console.error(`Плана нет: ${planPath}`);
|
||
return 2;
|
||
}
|
||
if (!existsSync(priyomkaPath)) {
|
||
console.error(`Приёмочного листа нет: ${priyomkaPath}. Без него закрывать пункт нечем.`);
|
||
return 2;
|
||
}
|
||
// Бумагу замысла кусок 1 не читает, но её существование проверяет здесь же: названный
|
||
// и несуществующий путь — это опечатка, которая всплыла бы только в куске 2, ночью.
|
||
if (!existsSync(specPath)) {
|
||
console.error(`Бумаги замысла нет: ${specPath}. Ворота на неё заводит кусок 2, но путь должен быть настоящим уже сейчас.`);
|
||
return 2;
|
||
}
|
||
|
||
// 🔴 РАЗБОР ПЛАНА — это ВХОД ворот, а не ворота. Без разобранного плана воротам
|
||
// питаться нечем вовсе, поэтому он идёт первым, до всего.
|
||
// 🔴 Из этого зова берутся ТОЛЬКО пункты: план читается и разбирается ровно ОДИН раз
|
||
// за запуск. Приговор ворот здесь НЕ смотрится и смотреться не может — запаса
|
||
// и места на диске в эту минуту ещё не мерили, бумаги и замысел сюда не поданы.
|
||
// Судят ворота ниже, одним полным зовом, и туда идут уже разобранные пункты.
|
||
// Второй раз файл плана не читается: между двумя чтениями он мог измениться,
|
||
// и ворота судили бы не тот план, который пошёл в работу.
|
||
// 🪤 Кусок 1 лепил здесь ОДИН пункт «весь-план» из первой строки файла, а файлы к нему
|
||
// называл владелец рукой ключом `--files`. Больше одного работника за раз так завести
|
||
// было нельзя. Этот путь заменён разбором целиком.
|
||
let punkty;
|
||
try {
|
||
punkty = vhodVorot({ planPath }).items;
|
||
} catch (err) {
|
||
// 🔴 Разбор плана падает вслух и только на настоящей беде плана (незакрытая
|
||
// рамка-образец). Показываем СЛОВА, а не след стека: их писали владельцу.
|
||
console.error('Прогон НЕ запущен: план прочитать не удалось.');
|
||
console.error(` · ${err?.message ?? err}`);
|
||
return 1;
|
||
}
|
||
|
||
// 🟡 СВЕРХ ПЛАНА, называю вслух. Ключ `--files` кусок 4 обессмыслил: файлы теперь
|
||
// берутся из плана. Промолчи мы — владелец набрал бы `--files a.php`, ничего
|
||
// бы не произошло, и он искал бы беду в своём плане, а не в лишнем ключе.
|
||
if ((razbor.files ?? []).length) {
|
||
console.log('Ключ --files больше не нужен и не учитывается: файлы каждого пункта теперь берутся из самого плана (строка «<!-- night: ... files=... -->» под заголовком пункта).');
|
||
}
|
||
|
||
// 🔴 ОТКАЗ ПО ПЛАНУ (проверки 39, 41 и прочие). Печатается ДО замера остатка: замер
|
||
// идёт платной программой `claude` из подписки, и жечь его на заведомо негодный
|
||
// план нельзя — каждая ошибочная попытка стоила бы владельцу денег.
|
||
// Список девяти ворот при этом не тронут: новых ворот туда не вставляется.
|
||
const planOk = validatePlan(punkty);
|
||
if (!planOk.ok) {
|
||
console.error('Прогон НЕ запущен: план проверку не прошёл.');
|
||
console.error(` · ${planOk.reason}`);
|
||
console.error(` · пунктов в плане: ${planOk.items_count}, из них помечено длинными: ${planOk.long_count}.`);
|
||
console.error(` · план: ${planPath}`);
|
||
console.error('Это правится в самом плане — ночь по такому плану не запускается. Ни один работник не заведён, деньги не потрачены.');
|
||
return 1;
|
||
}
|
||
|
||
// 🔴 ВОРОТА ОТЛОЖЕННОЙ ЗАДАЧИ (проверки 28, 29, 30). След кладёт надзиратель последнего
|
||
// погасшего работника (задача 5) либо рука владельца. Отложенная задача сама
|
||
// НЕ подхватывается ни следующим прогоном, ни через неделю.
|
||
const zadacha = razbor.taskId ?? null;
|
||
let sled = null;
|
||
if (zadacha) {
|
||
try {
|
||
sled = readDeferred(deferredDir(), zadacha);
|
||
} catch (err) {
|
||
// 🔴 След есть, но порван. Молчать нельзя: «следа нет» пустило бы отложенную
|
||
// задачу в работу само, без слова владельца.
|
||
console.error(`Прогон НЕ запущен: запись об отложенной задаче ${zadacha} испорчена и не читается.`);
|
||
console.error(` · ${err?.message ?? err}`);
|
||
return 1;
|
||
}
|
||
}
|
||
if (sled) {
|
||
const vorota = deferredTaskGate({
|
||
reason: sled.reason,
|
||
owner_said_continue: razbor.ownerSaidContinue,
|
||
// 🔴 Перепроверка плана — из ЭТОГО запуска, а не «когда-то была» (проверка 30):
|
||
// пока задача лежала, продукт изменился, и план мог разойтись с ним.
|
||
plan_revalidated: planOk.ok,
|
||
defect_list: razbor.defectList,
|
||
// 🔴🔴 ТОТ ЖЕ ПЛАН (проверка 29). Слева — путь из следа, справа — план этого запуска.
|
||
// Не подай оба — правило внутри ворот стояло бы и молчало, а задачу можно было бы
|
||
// «доделать» по совсем другому плану.
|
||
plan_path: sled.plan_path,
|
||
plan_path_now: razbor.planPath,
|
||
});
|
||
if (!vorota.may_run) {
|
||
console.error(`Прогон НЕ запущен: задача ${zadacha} отложена (${sled.reason === 'rejected' ? 'не прошла приёмку владельца' : 'не была доделана в прошлый раз'}).`);
|
||
console.error(` · ${vorota.reason}`);
|
||
console.error(` · осталось незакрытым: ${(sled.left_items ?? []).join(', ') || '(не записано)'}`);
|
||
console.error('Продолжить: тот же запуск плюс ключ --continue. Если задача вернулась с приёмки — ещё и --defects <файл со списком брака>.');
|
||
console.error('Список брака пока подаётся файлом с руки — по строке на замечание. Ни один работник не заведён, деньги не потрачены.');
|
||
return 1;
|
||
}
|
||
console.log(`Задача ${zadacha} возвращается в работу по слову владельца. План перепроверен заново. Делать её будет НОВЫЙ работник.`);
|
||
}
|
||
|
||
// 💳 Один крошечный замер остатка ПЕРЕД воротами. Без него воротам «запас» нечем
|
||
// питаться, и они пропустили бы что угодно. Замер идёт программой `claude` из подписки —
|
||
// ANTHROPIC_API_KEY не выставляется никогда, оплата ушла бы мимо подписки.
|
||
//
|
||
// 🔴 РЕШЕНИЕ ВЛАДЕЛЬЦА, дословно: замер делается СВЕЖИЙ перед КАЖДЫМ запуском, и срока
|
||
// годности у него нет вовсе. Не вышел замер — прогона нет, и владельцу сказано почему.
|
||
// Никакой «ещё не протухшей» строки из журнала: между тем замером и этой минутой могла
|
||
// пройти дневная работа и съесть весь запас, а решать о деньгах вслепую нельзя (проверка 17).
|
||
//
|
||
// 🔴 Обёртка обязательна: нет настройки `probe_model` — ворота падают понятной
|
||
// строкой с именем настройки, и её надо ПОКАЗАТЬ, а не уронить программу со следом стека.
|
||
//
|
||
// 🪤 Замер тут делается ДО ворот прогона, значит КАЖДЫЙ отказ ворот тоже стоит замера.
|
||
// Десяток попыток «поправил — перезапустил» — это десяток замеров, и ровно от такого
|
||
// расхода счёт за смену и защищает.
|
||
let z = null;
|
||
try {
|
||
z = await zamerSoSchyotom({ cfg, attemptsPath: probeAttemptsPath(), komanda: 'run' });
|
||
} catch (err) {
|
||
console.error(`Прогон НЕ запущен: замер остатка не сделан. ${err?.message ?? err}`);
|
||
console.error('Это настройка в вашем night.config.json. Пример строки есть в night.config.example.json.');
|
||
return 1;
|
||
}
|
||
if (!z.ok) {
|
||
console.error('Прогон НЕ запущен: замеров остатка за смену больше не осталось.');
|
||
console.error(z.reason);
|
||
return 1;
|
||
}
|
||
const m = z.measure;
|
||
if (!m) {
|
||
// 🔴🔴 РЕШЕНИЕ Р2. Здесь стояла строка «события "rate_limit_event" от системы не пришло»,
|
||
// и она была ВРАНЬЁМ: событие приходило исправно каждый раз, не совпадал ВИД предела.
|
||
// Владелец десять дней читал про поломку прибора, а поправить надо было одну строку
|
||
// в своих настройках. Теперь причина называется та самая, какая есть.
|
||
console.error('Прогон НЕ запущен: остаток запаса посчитать не удалось.');
|
||
console.error(` · ${z.problem ?? 'Причина не названа.'}`);
|
||
console.error('Решать о деньгах вслепую нельзя, поэтому прогон не начинается вовсе. Прошлые замеры в журнале запуск не разрешают: они могли устареть за один день.');
|
||
console.error('Что делать: повторить `npm run night:budget` и посмотреть, отвечает ли прибор.');
|
||
return 1;
|
||
}
|
||
recordMeasure(budgetMeasuresPath(), {
|
||
utilization: m.utilization, status: m.status, kind: m.kind,
|
||
resets_at: m.resets_at, at: Date.now(), phase: 'before-run',
|
||
});
|
||
|
||
const budget = budgetState(budgetMeasuresPath(), cfg, Date.now());
|
||
const disk = await measureDisk(cfg);
|
||
const seal = checkSeal(planSealPath(planPath), planPath);
|
||
// 🔴 Тот же счёт и та же одна дверь, что у `seal`. Порванная карточка — это «неизвестно,
|
||
// идёт прогон или нет»: с таким ответом ворота потолков и ворота пересечения по файлам
|
||
// пропустили бы что угодно, и оба отказа исчезли бы молча. Значит прогона нет, и сказано
|
||
// почему.
|
||
const schyotIdushchih = idushchieProgony();
|
||
if (!schyotIdushchih.ok) {
|
||
console.error(`Прогон НЕ запущен. ${schyotIdushchih.reason}`);
|
||
return 1;
|
||
}
|
||
const running = schyotIdushchih.running;
|
||
const papers = papersCommitted(REPO_ROOT, paperPathsFor({ planPath, priyomkaPath, specPath }));
|
||
// 🔴 Вторым доводом — путь бумаги замысла, по которой запускают. Без него отметка
|
||
// «замысел проверен» открывала бы запуск по ЛЮБОЙ другой бумаге, в том числе никем
|
||
// не читанной (задача 4 сверяет `spec_path` в отметке с этим путём).
|
||
const spec = specApproved(readJson(specApprovalPath()), specPath);
|
||
|
||
// 🔴 ПОПРАВЛЕНО ПРОТИВ ПЛАНА, вслух: план предлагал разобрать доводы здесь ещё раз
|
||
// (`const dovody = razborDovodov(argv)`), описывая `cli.mjs` таким, каким тот был
|
||
// ДО куска 1. Разбор уже сделан выше и лежит в `razbor` — второй разбор рядом означал бы
|
||
// два места, знающих доводы по-своему, и проверки сторожили бы не то, что работает.
|
||
const plan = planRun(vhodVorot({
|
||
// 🔴 Пункты уже разобраны выше и сюда подаются готовыми. `planPath` здесь НЕ подаётся
|
||
// намеренно: второе чтение файла плана — это второй план в одном запуске.
|
||
cfg, items: punkty, budget, disk, planSeal: seal, papers, spec, running,
|
||
// Номер задачи доходит и до ворот: продолжение начатого третьей задачей не считается,
|
||
// и своими же файлами задача сама с собой не пересекается.
|
||
taskId: razbor.taskId ?? null,
|
||
}));
|
||
if (!plan.ok) {
|
||
for (const s of strokiOtkaza(plan, budget)) console.error(s);
|
||
return 1;
|
||
}
|
||
|
||
// 🔴🔴 РЕШЕНИЕ Р5, ВИДИМАЯ ЧАСТЬ. Ворота запаса пропустили ПО СВЕТОФОРУ СИСТЕМЫ, а не
|
||
// по числу, — то есть прежняя рамка владельца («две седьмых неприкосновенны») сейчас
|
||
// не меряется ничем. Это СУЖЕНИЕ ЕГО ЗАЩИТЫ, и оно обязано быть видно ему прямым текстом,
|
||
// а не жить примечанием к коду. Печатается ДО слов «прогон заведён»: строка, спрятанная
|
||
// под списком работников, до глаз не доходит.
|
||
if (budget?.dolya_ne_prishla && budget?.slova_vladeltsu) {
|
||
console.log(budget.slova_vladeltsu);
|
||
}
|
||
|
||
// 🔴 ТОЧКА ОТСЧЁТА ПРОГОНА. Крошечный замер выше лёг в журнал БЕЗ номера прогона —
|
||
// номера в ту минуту ещё не существовало, его выдал `planRun`. А прирост прогона
|
||
// считается от первой строки ЭТОГО прогона (задача 2, `utilizationDelta`).
|
||
// Не дописать её — точкой отсчёта станет первый тик первого работника, всё съеденное
|
||
// до него в прирост не войдёт, и предел одной седьмой сработает позже обещанного.
|
||
// Число берём то же самое, что и в замере перед воротами: второй раз прибор не спрашиваем.
|
||
recordMeasure(budgetMeasuresPath(), {
|
||
utilization: m.utilization,
|
||
status: m.status,
|
||
kind: m.kind,
|
||
resets_at: m.resets_at,
|
||
at: Date.now(),
|
||
run_id: plan.run_id,
|
||
phase: 'start',
|
||
});
|
||
|
||
const started = startAll({
|
||
runId: plan.run_id,
|
||
workers: plan.workers,
|
||
planPath,
|
||
baseBranch: plan.base_branch,
|
||
// Голова работника — из настроек владельца. Здесь она только передаётся дальше.
|
||
workerModel: cfg.worker_model,
|
||
// Продолжаем названную владельцем задачу; не назвал — прогон сам себе задача.
|
||
taskId: razbor.taskId ?? plan.run_id,
|
||
// 🔴 Команда проверок — своя у каждого пункта, из его же `tests`. Общий набор здесь
|
||
// превратил бы «зелень по этому пункту» в «зелень вообще».
|
||
testCommandFor: w => komandaProverokPunkta(w.item),
|
||
// 🔴 В карточку прогона идут ВСЕ пункты плана, а не только пункты семерых заведённых:
|
||
// это очередь, из неё надзиратель берёт работнику следующий пункт.
|
||
items: punkty,
|
||
});
|
||
// 🔴🔴 «ЗАВЕДЁН» — ЭТО ПРО ЖИВЫХ РАБОТНИКОВ, А НЕ ПРО ЗАПИСЬ НА ДИСКЕ. Найдено воротами
|
||
// живьём: при нуле заведённых владельцу печаталось «Прогон заведён. Работников: 0» и следом
|
||
// «надзиратели переживут конец сессии» — при нуле надзирателей. Это прямое враньё тому,
|
||
// кто после этого закроет окно и уйдёт спать. Сам прогон при нуле работников закрывается
|
||
// в `startAll` (иначе он навсегда держал бы место в потолке задач) — здесь только правда
|
||
// словами и код возврата, отличный от успеха: ночь не началась.
|
||
if (!started.length) {
|
||
console.error(`Прогон ${plan.run_id} НЕ начался: не завёлся НИ ОДИН работник.`);
|
||
console.error(`Почему — написано в карточках работников (поле finish_reason): ${runsDir()}`);
|
||
console.error('Чаще всего это два пункта плана на один и тот же файл либо несобравшийся composer/npm в углу.');
|
||
console.error('Прогон закрыт сразу — места в потолке задач он не занимает, и следующая попытка ничем не заперта.');
|
||
return 1;
|
||
}
|
||
// След снят: задача пошла в работу. Не сними — следующий запуск отказал бы по нему снова,
|
||
// и владельцу пришлось бы каждый раз говорить «продолжай» заново. Раньше снимать нельзя:
|
||
// до этой строки любой отказ обязан застать след на месте, иначе вторая попытка прошла бы
|
||
// мимо ворот вовсе.
|
||
if (sled && zadacha) clearDeferred(deferredDir(), zadacha);
|
||
|
||
console.log(`Прогон ${plan.run_id} заведён. Работников: ${started.length}.`);
|
||
for (const s of started) {
|
||
console.log(` · ${s.worker_id} — угол ${s.workspace}, база ${s.db_name}, надзиратель ${s.supervisor_pid}`);
|
||
}
|
||
console.log('Окно можно закрывать: надзиратели переживут конец сессии.');
|
||
console.log(`Замысел ${specPath} проверен, бумаги сохранены, отпечаток плана сошёлся.`);
|
||
// 🔴 Честность про пустые ворота. «Все ворота пройдены» при четырёх ненаписанных правилах —
|
||
// это самообман; владелец обязан знать, что его сторожат пятеро, а не девятеро.
|
||
console.log(stroka_pro_nestorozhashchie());
|
||
return 0;
|
||
}
|
||
|
||
if (cmd === 'smoke-longrun') {
|
||
// Доказательство проверки 1: движение должно быть и через три часа после конца сессии
|
||
// помощника. Проверками кода это не доказывается — только живым прогоном.
|
||
const minutes = Number(flag(argv, '--minutes') ?? 200);
|
||
// 🔴 Имя прогона можно назвать своё (`--run`). Без этого вторая попытка невозможна:
|
||
// ветка и угол от первой уже заняты, а каталог прошлого прогона сносить нельзя —
|
||
// из него живёт сводка. Короткая проба перед ночью зовётся как раз со своим именем.
|
||
const runId = flag(argv, '--run') ?? 'smoke';
|
||
const r = await smokeLongRun({ cfg, minutes, runId });
|
||
console.log(`Пробный долгий прогон заведён: прогон ${runId}, работник ${r.worker_id}, надзиратель ${r.supervisor_pid}.`);
|
||
console.log(`Признаки жизни: ${r.heartbeat_path}`);
|
||
console.log(`След работника: ${r.notes_path}`);
|
||
console.log('Закройте окно помощника целиком и вернитесь через три часа.');
|
||
console.log(`Бумагу по итогам класть сюда: ${join(priyomkaDir(), '1')}`);
|
||
return 0;
|
||
}
|
||
|
||
console.error(`Неизвестная команда: ${cmd ?? '(команду не назвали)'}. Написаны семь: run, mail, summary, smoke-longrun, budget, seal, approve-spec.`);
|
||
return 2;
|
||
}
|
||
|
||
// Точка входа звонит только когда файл запущен САМ (`node tools/night/cli.mjs …»),
|
||
// а не подключён проверкой через import — иначе проверка сама себе позвала бы
|
||
// `process.exit` при подключении файла, и ни один тест этого файла не дожил бы до конца.
|
||
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||
if (isMain) {
|
||
main().then(code => process.exit(code));
|
||
}
|