Files
portal/tools/night/items.mjs
T

366 lines
30 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.
// Команды снаружи. Панель, бот или сама рука владельца кладут файл в `.night/commands/`,
// а читает его НАДЗИРАТЕЛЬ — работник этого модуля не видит вовсе: он другой процесс
// в другом каталоге и разговаривает только файлами своего почтового ящика.
//
// 🔴 Файл заведён ЧАСТЬЮ. Здесь только приём команд; `closeItem`, `afterItemDone`,
// `afterTaskDone` и `deferredTaskGate` дописывает в этот же файл кусок 4 — заводить
// его заново ему не надо.
import { existsSync, readdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { readJson, writeJsonAtomic } from './state.mjs';
// Закрытый список, и порядок в нём значимый: «стоп всё» главнее «стопа», «стоп» главнее
// «паузы». Слово не из списка не читается вовсе — выдумывать, что оно значит, нельзя.
// 🔴 Список вывезен наружу нарочно: надзиратель называет его владельцу в сводке, когда
// команду не понял. Второй такой же список рядом разошёлся бы с этим в первый же месяц.
export const COMMAND_WORDS = ['stopall', 'stop', 'pause', 'resume'];
const CMDS = COMMAND_WORDS;
export function readCommand(commandsDirPath, workerId) {
// 🪤 Каталог команд заводит рука владельца, и в первую ночь его может не быть.
// Брошенная тут ошибка убила бы надзирателя молча — тот самый класс беды, из-за
// которого 334 прогона умирали три месяца незамеченными.
if (!commandsDirPath || !existsSync(commandsDirPath)) return null;
let files;
try {
files = readdirSync(commandsDirPath);
} catch {
return null; // каталог недоступен — это отсутствие команды, а не повод падать
}
for (const cmd of CMDS) {
const all = `all.${cmd}.json`;
const mine = `${workerId}.${cmd}.json`;
// Общая идёт первой: она касается и того, кому лично ничего не давали.
for (const name of [all, mine]) {
if (!files.includes(name)) continue;
// Порванный файл — отсутствие сведений, а не мусор: слово команды стоит
// в самом имени файла, и его достаточно.
const body = readJson(join(commandsDirPath, name)) ?? {};
// 🪤 Общую команду, которую ЭТОТ работник уже исполнил, второй раз не отдаём:
// иначе он гасился бы по ней снова и снова, пока файл ждёт остальных.
if (name === all && (body.done ?? []).includes(workerId)) continue;
// 🔴🔴 `cmd` стоит ПОСЛЕ тела, а не до него, и это не вкус. Слово команды берётся
// из ИМЕНИ файла — оно и есть закрытый список. Поставь мы `cmd` перед `...body`,
// и тело файла перебило бы его: положи кто-нибудь в `w-1.pause.json` тело
// {"cmd":"stopall"} — работник был бы погашен, хотя имя файла говорит «пауза».
// Тем же способом обходился бы и весь закрытый список `CMDS`. Тело команды несёт
// сведения (кто, когда, почему), а не слово; слово — только из имени.
return { ...body, cmd, file: name, common: name === all };
}
}
return null;
}
// 🔴🔴 Владелец узнаёт, что его команду НЕ ПОНЯЛИ (решение диспетчера). Слово не из
// закрытого списка исполнять нельзя — выдумывать его смысл мы не станем. Но и молчать
// нельзя: владелец положил файл, ушёл спать и думает, что «пауза» нажата, а её никто
// не понял. Молчание в ответ на команду хозяина недопустимо.
// 🪤 Эта работа НИЧЕГО не удаляет и ничего не исполняет — только называет. Файл владельца
// остаётся лежать: он и есть доказательство, и утром хозяин увидит его на месте, а не
// будет гадать, куда тот делся.
export function unknownCommands(commandsDirPath, workerId) {
if (!commandsDirPath || !existsSync(commandsDirPath)) return [];
let files;
try {
files = readdirSync(commandsDirPath);
} catch {
return []; // каталог недоступен — сказать нечего, и падать не о чем
}
const out = [];
for (const name of files) {
// Команда — это ровно `<кому>.<слово>.json`. Всё прочее в каталоге не наше дело:
// объявлять чужой файл «непонятой командой» — значит пугать владельца на пустом месте.
const razbor = /^(.+)\.([^.]+)\.json$/.exec(name);
if (!razbor) continue;
const [, komu, slovo] = razbor;
// Чужая личная команда — не наша забота: о ней скажет надзиратель того работника,
// иначе одну и ту же строку написали бы семеро разом.
if (komu !== 'all' && komu !== workerId) continue;
if (CMDS.includes(slovo)) continue; // понятную здесь не называем
out.push({ file: name, word: slovo, common: komu === 'all' });
}
return out;
}
// 🪤 Прежде эта работа стирала И общий, И личный файл разом. Из-за этого «стоп всё»
// съедал первый же прочитавший, а остальные шестеро продолжали работать при отданной
// команде — и владелец видел «остановлено», когда ночь шла дальше. Теперь личная команда
// убирается сразу, а общая — только когда её исполнили ВСЕ, кого она касается; до тех пор
// в её файле копится список исполнивших.
export function consumeCommand(commandsDirPath, workerId, cmd, { concerns = [] } = {}) {
const result = { personal_removed: false, common_removed: false, done: [] };
if (!commandsDirPath || !existsSync(commandsDirPath)) return result;
const mine = join(commandsDirPath, `${workerId}.${cmd}.json`);
if (existsSync(mine)) {
unlinkSync(mine);
result.personal_removed = true;
}
const all = join(commandsDirPath, `all.${cmd}.json`);
if (!existsSync(all)) return result;
// 🔴 Список исполнивших живёт В ФАЙЛЕ, а не в памяти надзирателя: надзиратели — разные
// процессы, и каждый видел бы свой собственный список. Перечитываем прямо перед записью,
// чтобы не затереть отметку соседа, поставленную секунду назад.
const body = readJson(all) ?? {};
const done = Array.from(new Set([...(body.done ?? []), workerId]));
result.done = done;
// Кого команда касается, знает зовущий: надзиратель берёт состав прогона из `run.json`.
// Списка нет — файл НЕ удаляем: лучше лишний раз промолчать, чем снять команду с тех,
// до кого она не дошла.
if (concerns.length && concerns.every(w => done.includes(w))) {
unlinkSync(all);
result.common_removed = true;
return result;
}
writeJsonAtomic(all, { ...body, done });
return result;
}
// ── Кусок 4: закрытие пункта, очередь пунктов, конец задачи ────────────────────
//
// 🔴 «Пункт закрыт» — не слово работника, а доказательство: сохранённый коммит ПЛЮС
// зелёные проверки ИМЕННО ЭТОГО пункта. Зелень меряет надзиратель сам (`item-audit.mjs`,
// задача 4), сюда она приходит уже замеренной; слово работника доказательством не считается.
//
// 🔴 Порядок отказов значим. Владелец и работник читают ПЕРВУЮ названную причину и чинят
// ЕЁ. Поэтому сперва «в плане нет проверок», потом «в расчёте на ответ», потом «нет
// коммита», и только потом «проверки красные»: скажи мы про красный код там, где на деле
// не пришёл ответ хозяина, — работник полночи чинил бы верное.
// Номера пунктов владелец пишет то `2`, то `"2"`. Сравнение знак в знак вернуло бы
// работнику пункт, который он только что закрыл, и он крутился бы на нём до утра.
function odinNomer(id) {
return String(id ?? '').trim();
}
export function closeItem({ item, commit_sha, tests_green, on_assumption = false, answer_received = false }) {
if (!item || typeof item !== 'object') {
throw new Error('closeItem: пункт не передан. Это сбой зова, а не свойство пункта: тихий отказ «не засчитан» выглядел бы как честная работа замка, хотя замок ничего не смотрел.');
}
// Проверка 39б: у пункта в плане не записано ни одной проверки — закрывать нечем.
if (!Array.isArray(item.tests) || item.tests.length === 0) {
return { closed: false, reason: `Для пункта ${odinNomer(item.id) || '?'} в плане не записано ни одной проверки — закрыть его нечем. Проверки пункта ставит тот, кто пишет план, строкой-пометой под заголовком.` };
}
// Проверка 40: сделанное «в расчёте на ответ» не закрывается, пока ответа нет,
// сколько бы зелёных проверок по нему ни прошло.
if (on_assumption && !answer_received) {
return { closed: false, reason: `Пункт ${odinNomer(item.id)} сделан «в расчёте на ответ» владельца, а ответа ещё нет. Закрывать такой пункт нельзя, сколько бы зелёных проверок по нему ни прошло: ответ может отменить сделанное.` };
}
if (!commit_sha) {
return { closed: false, reason: `Сделанное по пункту ${odinNomer(item.id)} не сохранено — коммита в ветке задачи нет. Несохранённое не переживёт ночь.` };
}
// Проверка 38: нет зелени по ЭТОМУ пункту — не засчитан. `tests_green` приходит
// от надзирателя замеренным (задача 4); не переданный вовсе — это «замерить
// не удалось», а не «наверное зелено».
if (tests_green !== true) {
return { closed: false, reason: `Проверки пункта ${odinNomer(item.id)} (${item.tests.join(', ')}) не прогнаны или не зелёные — пункт не засчитан.` };
}
return { closed: true, reason: null };
}
// 🔴🔴 ГРАНИЦА ЭТОЙ РАБОТЫ — ЧИТАТЬ ДО ТОГО, КАК ВЗЯТЬ ЕЁ ОТВЕТ ЗА «ВОТ ТВОЙ ПУНКТ».
//
// На вопрос «какой пункт работнику брать следующим» в затее отвечают ДВЕ работы, и они
// отвечают РАЗНОЕ на одном и том же хозяйстве. Это не поломка, это разделение ролей:
//
// · `nextFreeItem` (`file-claims.mjs`) — ВЫДАЁТ пункт. Ей передают каталог прогона,
// она читает список занятых файлов и пропускает пункты, чьи файлы держит чужой.
// · `afterItemDone` (здесь) — говорит, ЕСТЬ ЛИ ВООБЩЕ ЧТО БРАТЬ и не пора ли гаснуть.
//
// ЧЕГО ЭТА РАБОТА НЕ ЗНАЕТ: занятых файлов. Ей не передают ни каталога прогона, ни списка
// занятий — и передать нечем, в её подписи такого входа нет. Она смотрит только на план
// и на список закрытых пунктов.
//
// ОТ ЧЕГО ОНА НЕ ЗАЩИЩАЕТ: от того, что названный ею пункт прямо сейчас правит сосед.
// Замер 02.08.2026: w-1 занял `app/a.php` (пункт 1) и сидит на нём, w-2 спрашивает —
// `nextFreeItem` отвечает «пункт 2», `afterItemDone` отвечает «пункт 1», то есть тот,
// на котором сидит сосед. Возьми кто-нибудь этот ответ как выдачу пункта — двое молча
// правили бы одни файлы всю ночь при зелёных проверках, а замок занятий не сработал бы
// ни разу: его просто не спросили.
//
// 🔴 КАК ЗВАТЬ ПРАВИЛЬНО (так зовёт план задачи 5, проверено 02.08.2026): у этой работы
// спрашивают «пусто или нет» и «гаснуть ли», а САМ ПУНКТ берут через `nextFreeItem`.
// Ответ `next_item` тут — это «работа ещё есть», а не «вот твой пункт».
//
// 🪤 Собрался передать сюда каталог прогона или занятия — ОСТАНОВИСЬ и иди к владельцу.
// Подписи обеих работ ждёт задача 5, и менять разделение ролей — его решение, не твоё.
// Границу сторожит проверка «две двери на один вопрос» в `items.test.mjs`: она покраснеет
// и на попытку научить эту работу занятиям, и на попытку отучить от них `nextFreeItem`.
export function afterItemDone({ items, done, answered_during_day = false }) {
if (!Array.isArray(items)) {
throw new Error('afterItemDone: подан не список пунктов. Тихий ответ «пункты кончились» погасил бы всех работников задачи на ровном месте, и владелец прочёл бы утром «работа сделана», когда её никто не делал.');
}
if (!Array.isArray(done)) {
throw new Error('afterItemDone: не подан список закрытых пунктов. Тихое «ничего не закрыто» вернуло бы работнику тот самый пункт, который он только что закрыл.');
}
if (answered_during_day) {
return {
next_item: null,
extinguish_reason: 'Работник дождался ответа владельца днём — доделал свой пункт и гаснет, нового не берёт: иначе он жёг бы недельный запас вместе с владельцем.',
task_note: 'Остаток плана сам не подхватывается — задача продолжится только по слову владельца «продолжай».',
};
}
const zakryto = done.map(odinNomer);
const next = items.find(i => !zakryto.includes(odinNomer(i?.id))) ?? null;
return {
next_item: next,
extinguish_reason: next ? null : 'Пункты плана кончились — брать больше нечего.',
task_note: null,
};
}
export function afterTaskDone({ task_id, workers }) {
if (!Array.isArray(workers) || workers.length === 0) {
throw new Error('afterTaskDone: не сказано, каких работников гасить. Тихий пустой список прочёлся бы как «все погашены», и задача считалась бы законченной при живых работниках.');
}
return {
task_id,
// Список копируется: правка снаружи не должна менять то, что уже решено.
extinguish_workers: [...workers],
// Проверка 25: следующая задача в том же прогоне не начинается — задача упирается
// в приёмку владельца.
start_next_task: false,
// 🟡 Флаг поднят и ляжет в `run.json` (задача 5). САМОГО приёмщика по нему заводит
// кусок 7 (`startReviewer`) — здесь заводить его нечем и незачем. Пока по флагу
// никто ничего не запускает, и это названо вслух, чтобы правило не сочли работающим.
reviewer_starts: true,
note: 'Задача закончена: все её работники погашены, следующая задача в этом прогоне не начинается. Дальше — приёмка владельца, приёмщика по этому флагу заводит кусок 7.',
};
}
// ── Кусок 4, задача 3: ворота возврата отложенной задачи ───────────────────────
//
// 🔴 Проверка 28: задача не влезла в прогон — следующим прогоном САМА не подхватывается.
// 🔴 Проверка 29: не прошла приёмку — возвращается только по слову владельца, с тем же
// планом и со списком брака, и делает её НОВЫЙ работник.
// 🔴 Проверка 30: план отложенной задачи сначала заново проходит проверку — и в случае
// «не влезла», и в случае «не прошла приёмку». Пока задача лежала, продукт менялся.
//
// 🪤 Слово `reason` тут значит две разные вещи, и договор о стыках их не развёл: НА ВХОДЕ
// это вид случая (закрытый список), НА ВЫХОДЕ — текст отказа владельцу. Имена заданы
// договором дословно и менять их нельзя; столкновение названо вслух.
import { DEFER_REASONS } from './deferred.mjs';
// 🔴🔴 СРАВНИВАТЕЛЬ ПУТИ ЗДЕСЬ НЕ ЗАВОДИТСЯ СВОЙ, и это отступление от плана задачи 3,
// названное вслух. План (01.08) велел завести в этом файле свою мелочь `odinVidPuti`,
// сводящую только косые черты. К 02.08 в `paths.mjs` уже живут ДВЕ работы с этим же
// вопросом — `samePath` («это один и тот же путь?») и `odinVidPuti` (свой вид имени
// файла), и на второй копии сравнивателя там стоят два сторожа: «сравниватель пути —
// ОДИН на всю затею». Третья копия под тем же именем `odinVidPuti`, но с другим
// поведением — ровно тот класс, на котором уже обжигались.
//
// 🔴 И дело не только в чистоте: своя мелочь плана ВРАЛА БЫ ЖИВЬЁМ. Замерено 02.08.2026
// (`node` на трёх сравнивателях): владелец запускает прогон ПОЛНЫМ путём
// (`night run C:\…\план.md` — так сказано в примечании к `samePath`), а в след ложится
// путь ОТ КОРНЯ хранилища. Сведение одних косых даёт «план не тот» на ОДНОМ И ТОМ ЖЕ
// плане: ворота отказали бы владельцу на его же работе, отложенная задача не вернулась
// бы никогда, и починить это он не смог бы ничем. То же и с `./план.md`.
// `samePath` оба случая проходит верно (замерено), а разные планы по-прежнему разводит.
import { samePath } from './paths.mjs';
export function deferredTaskGate({
reason, owner_said_continue, plan_revalidated, defect_list = [],
// 🔴 «ТОТ ЖЕ ПЛАН» (проверка 29 дословно). `plan_path` — путь плана ИЗ СЛЕДА, тот,
// по которому задачу откладывали. `plan_path_now` — путь плана в ЭТОМ запуске.
// Не сравни их — задачу можно было бы «доделать» по совсем другому плану, и доделано
// было бы НЕ то, что не доделали. Оба умолчания пустые: не дали — не сравниваем
// и молчим, а не выдумываем совпадение.
plan_path = null,
plan_path_now = null,
}) {
if (!DEFER_REASONS.includes(reason)) {
// 🟡 Отступление от плана, названное вслух: план велел ЭТУ строку не трогать, потому
// что её проверяют только словами `not_finished|rejected`. Но после третьего слова
// (`impossible`, шаг 9ж) она стала ВРАТЬ владельцу: говорила «одним из двух», а список
// принимает три. Врущее сообщение об отказе — ровно тот класс, от которого вся затея
// и строится, поэтому счёт исправлен. Сами слова в строке не тронуты.
throw new Error(`Ворота возврата: вид отложенной задачи должен быть одним из трёх — not_finished, rejected или impossible. Дано: ${JSON.stringify(reason)}. Молчаливое «это не rejected» отменило бы требование списка брака, и задача вернулась бы в работу без него.`);
}
// 🔴 ТОТ ЖЕ ПЛАН (проверка 29). Стоит ПЕРВЫМ из отказов, и это нарочно: слово владельца
// «продолжай» относится к ТОЙ задаче, которую отложили. Подмени план — и «продолжай»
// сказано уже про другую работу, а владелец об этом не знает.
if (plan_path && plan_path_now && !samePath(plan_path, plan_path_now)) {
return {
may_run: false,
reason: `План не тот. Задачу откладывали по плану ${plan_path}, а запускают её по плану ${plan_path_now} — это разные планы, и доделано будет не то, что не доделали. Запустите с тем же планом; если план вправду сменился, это новая задача, а старый след снимите руками.`,
fresh_worker_required: false,
defect_list: [...defect_list],
};
}
// Проверка 28/29а: без слова владельца — никуда. Молчание не согласие.
if (!owner_said_continue) {
return {
may_run: false,
reason: 'Отложенная задача сама не подхватывается. Нужно слово владельца «продолжай» — ключ --continue в строке запуска.',
fresh_worker_required: false,
defect_list: [...defect_list],
};
}
// Проверка 30: план перепроверяется заново — в ОБОИХ случаях, и «не влезла»,
// и «не прошла приёмку».
if (!plan_revalidated) {
return {
may_run: false,
reason: 'План отложенной задачи должен заново пройти проверку: пока задача лежала, продукт менялся, и вчера годный план сегодня может не годиться вовсе.',
fresh_worker_required: false,
defect_list: [...defect_list],
};
}
// Проверка 29в: у вернувшейся с приёмки список брака обязателен. У той, что просто
// не успела, брака не было — и спрашивать его не с кого.
if (reason === 'rejected' && defect_list.length === 0) {
return {
may_run: false,
reason: 'Задача возвращена с приёмки владельца, но список брака пуст — работать не с чем. Подайте его ключом --defects <файл>. (До куска 7 список подаётся с руки: настоящую приёмку, откуда он возьмётся сам, строит кусок 7.)',
fresh_worker_required: false,
defect_list: [],
};
}
return {
may_run: true,
reason: null,
// Проверка 29г: делает её НОВЫЙ работник — прежний погас вместе с концом задачи.
fresh_worker_required: true,
// Список брака идёт ДАЛЬШЕ тем же составом: иначе ворота его прочли, кивнули
// и потеряли, а работник получил бы тот же план без единого слова о том, что в нём
// не так.
defect_list: [...defect_list],
};
}
// ── Долг куска 5 (Р14) и куска 7 (блок 6а), внесён 01.08 ───────────────────────
//
// 🔴 Пункт, который владелец ответом «нет» велел признать НЕВЫПОЛНИМЫМ (кусок 5, Р14),
// не проходит через `closeItem`: у него никогда не будет ни коммита, ни зелёных проверок —
// он и не должен их иметь. Без этого правила такой пункт НАВСЕГДА остаётся «не среди
// засчитанных», задача не закрывается никогда, и приёмщик (кусок 7, блок 6а) не заводится
// вовсе — молча (находка куска 7 СВ-6).
//
// 🪤 Списки `verified` и `impossible` по смыслу не пересекаются (пункт либо закрыт зелёными
// проверками, либо признан невыполнимым — не оба сразу), но эта функция того не проверяет:
// она не судья, а чистое сложение множеств. Судят в разных местах: замок (эта же задача,
// `closeItem`) — про «закрыт»; надзиратель круга (кусок 5, блок 2в) — про «невыполним».
//
// 🔴🔴 Откуда взять живой список невыполнимых — здесь НЕ решено. `item-verdict.json` кусок 5
// кладёт в ящик РАБОТНИКА одним файлом, и следующий вердикт затирает предыдущий; копящего
// ленджера невыполнимых пунктов затея на 02.08 не заводит нигде. Значит правило готово,
// а живой провод к нему — нет: кто будет его делать, обязан сперва завести такой ленджер,
// иначе звать это правило будет нечем.
export function reshyonnyeSNevypolnimymi(verified = [], impossible = []) {
return [...new Set([...verified, ...impossible].map(String))];
}