Files
portal/tools/night/deferred.mjs
T

121 lines
10 KiB
JavaScript

// След отложенной задачи. Задача не влезла в прогон или не прошла приёмку владельца —
// она откладывается, и НАЗАВТРА САМА НЕ ПОДХВАТЫВАЕТСЯ (проверки 28 и 29). След — это
// единственное, чем следующая ночь узнаёт, что такая задача была.
//
// 🔴 Где лежит: `dirPath` — каталог следов, файл — `<task_id>.json`. Не под `runs/`:
// след обязан пережить прогон, которым он рождён, и дожить до следующей ночи. Не
// в хранилище: корень затеи закрыт от хранилища, и это состояние, а не бумага.
// Имени в договоре о стыках нет — заведено здесь и названо вслух в каркасе куска 4.
//
// 🪤 Здесь НЕЛЬЗЯ пользоваться `readJson` из `state.mjs`: на порванном файле он отдаёт
// `null`, а «null» тут читается как «следа нет» — то есть ворота пустили бы отложенную
// задачу в работу САМИ, ровно в ту ночь, когда запись порвалась. Поэтому у чтения два
// разных ответа: файла нет — следа нет (норма); файл есть и не читается — падаем громко.
import { existsSync, readFileSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { NIGHT_ROOT } from './paths.mjs';
import { writeJsonAtomic } from './state.mjs';
// Закрытый список. Незнакомое слово ворота прочли бы как «не rejected», то есть
// не спросили бы списка брака, и задача вернулась бы в работу без него.
//
// 🔴 Третье слово — долг куска 5 (Р14) и куска 7 (блок 6а), внесён 01.08. Пункт, который
// владелец ответом «нет» объявил невыполнимым, не rejected (приёмки не было) и не
// not_finished (дело не в нехватке ночи) — без своего слова он молча подпадал бы под одно
// из двух чужих, и причина отказа во владельческом слове звучала бы неправдой.
export const DEFER_REASONS = ['not_finished', 'rejected', 'impossible'];
// 🪤 Имя задачи становится именем файла. «../беда» увело бы запись из каталога следов
// вовсе, и след потерялся бы молча — то есть задача назавтра подхватилась бы сама.
//
// 🔴 Запрещаем ровно две вещи, и обе по делу: косую черту (любого вида) и имя, в котором
// нет ничего кроме точек. Две точки ПОСРЕДИ имени — не шаг вверх: черты уже запрещены,
// а к имени дописывается «.json», и «run-2026-08-01..02» остаётся файлом в том же
// каталоге. Руби мы и такие имена — владелец получал бы громкий отказ на своём же имени
// задачи, след не лёг бы вовсе, и отложенная задача назавтра подхватилась бы сама.
// Строгость шире нужной здесь не безобидна: она ломает ровно то, что стережёт.
function imyaFajla(task_id) {
const name = String(task_id ?? '').trim();
if (!name || /[\\/]/.test(name) || /^\.+$/.test(name)) {
throw new Error(`Негодное имя задачи для следа отложенной: ${JSON.stringify(task_id)}. Имя задачи становится именем файла, и косая черта или имя из одних точек увели бы след из каталога — он потерялся бы молча, а отложенная задача назавтра подхватилась бы сама.`);
}
return `${name}.json`;
}
function put(dirPath, task_id) {
return join(dirPath, imyaFajla(task_id));
}
export function markDeferred(dirPath, { task_id, plan_path, reason, left_items = [], at }) {
if (!DEFER_REASONS.includes(reason)) {
throw new Error(`Вид отложенной задачи должен быть одним из трёх: not_finished (не влезла в прогон), rejected (не прошла приёмку владельца) или impossible (пункт объявлен невыполнимым). Дано: ${JSON.stringify(reason)}. Незнакомое слово ворота возврата прочли бы как «не rejected» и не спросили бы списка брака.`);
}
const path = put(dirPath, task_id);
mkdirSync(dirPath, { recursive: true });
writeJsonAtomic(path, {
task_id: String(task_id),
plan_path: plan_path ?? null,
reason,
left_items: [...left_items],
// 🟡 Пусто нарочно: брак называет приёмка владельца, а её строит кусок 7. До него
// список подаётся с руки ключом `--defects`. Поле заведено сразу, чтобы кусок 7
// клал в него брак, не меняя вида файла.
defect_list: [],
at: at ?? Date.now(),
});
return path;
}
export function readDeferred(dirPath, task_id) {
const path = put(dirPath, task_id);
if (!existsSync(path)) return null; // следа нет — норма первой ночи любой задачи
let raw;
try {
raw = readFileSync(path, 'utf8');
} catch (e) {
throw new Error(`След отложенной задачи ${task_id} есть, но не читается: ${e.message}. Молчать нельзя: «следа нет» пустило бы отложенную задачу в работу само.`);
}
let zapis;
try {
zapis = JSON.parse(raw);
} catch (e) {
throw new Error(`След отложенной задачи ${task_id} порван и не разбирается: ${e.message}. Посмотрите файл ${path} глазами. Молчать нельзя: «следа нет» пустило бы отложенную задачу в работу само.`);
}
// 🔴🔴 НАЙДЕНО ВЫРЕЗАНИЕМ ВТОРОГО РОДА 02.08.2026 — планом не предусмотрено, дыра была
// настоящая. Защита выше стоит на том, что порванный след НЕ РАЗБИРАЕТСЯ. Но слово `null`
// разбирается прекрасно и отдаёт `null`, а `null` у этого чтения значит «следа нет» —
// то есть ворота решили бы, что задача не откладывалась, и пустили бы её в работу САМИ,
// без слова владельца. Проверки 28 и 29 отменились бы разом и молча.
// Гнаться за словом `null` бессмысленно: число, список и строка — тоже годный json
// и тоже не след; прежде они уходили дальше в ворота и падали там чужим словом.
// Поэтому спрашиваем прямо: это вообще ЗАПИСЬ? Список — не запись (`Array.isArray`).
if (zapis === null || typeof zapis !== 'object' || Array.isArray(zapis)) {
throw new Error(`След отложенной задачи ${task_id} разобрался, но это не запись, а ${Array.isArray(zapis) ? 'список' : JSON.stringify(zapis)}. Посмотрите файл ${path} глазами. Молчать нельзя: слово null тут прочлось бы как «следа нет» и пустило бы отложенную задачу в работу само.`);
}
return zapis;
}
export function clearDeferred(dirPath, task_id) {
const path = put(dirPath, task_id);
// Снимать нечего — молчим и файла на пустом месте не заводим.
rmSync(path, { force: true });
}
// 🔴🔴 ГДЕ лежат следы. Имя вывезено НАРУЖУ намеренно, и это шов с задачами 5 и 6: путь
// к следу обязан знать ровно ОДИН файл. Не вывези — надзиратель и командная строка написали
// бы каждый свой каталог следов, и разойдись они хоть на знак, след клался бы в одно
// место, а искался в другом: отложенная задача подхватилась бы назавтра САМА — ровно то,
// что запрещают проверки 28 и 29.
//
// 🔴 Корень затеи берётся ИЗ `paths.mjs` — это ПОСТОЯННАЯ `NIGHT_ROOT` (кусок 1),
// из неё же там сделан `commandsDir()`. Не функция и не из `state.mjs`: в `state.mjs`
// вывезены только `writeJsonAtomic`, `readJson`, `appendLine`, `readLines`, имени корня
// там нет вовсе. Второго корня не заводить ни в каком виде — ни своей строкой
// с этим именем, ни своим `resolve(...)`: разойдись они хоть на знак, след ляжет в одно
// место, а искаться будет в другом, и отложенная задача подхватится назавтра САМА.
// 🪤 Имя каталога строкой в этом файле не писать даже в примечании: на него смотрит
// проверка «корень НЕ заведён здесь заново», и она покраснела бы не по делу.
export function deferredDir() {
return join(NIGHT_ROOT, 'deferred');
}