Files
portal/tools/night/items.mjs
T

121 lines
8.5 KiB
JavaScript

// Команды снаружи. Панель, бот или сама рука владельца кладут файл в `.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;
}