844 lines
62 KiB
JavaScript
844 lines
62 KiB
JavaScript
// Рабочий угол работника. Замок — отсутствие возможности, а не программа:
|
||
// боевых ключей в углу просто нет, имя своей базы подставлено сразу.
|
||
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { workerSettingsPath } from './paths.mjs';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { readdirSync, symlinkSync, linkSync, copyFileSync } from 'node:fs';
|
||
import { resolveBin, needsShell, workerInboxDir, runsDir } from './paths.mjs';
|
||
import { readJson, writeJsonAtomic } from './state.mjs';
|
||
import { FINAL_STATUSES } from './supervisor.mjs';
|
||
|
||
const LIVE_MARKERS = [
|
||
/mdb\.yandexcloud\.net/i,
|
||
/ANTHROPIC_API_KEY\s*=\s*\S/,
|
||
/YOOKASSA_SECRET_KEY\s*=\s*\S/,
|
||
/DADATA_TOKEN\s*=\s*\S/,
|
||
/SMSC_PASSWORD\s*=\s*\S/,
|
||
/MAIL_PASSWORD\s*=\s*\S/,
|
||
/"pass"\s*:\s*"[^"]+"/, // пароль почтового ящика из night.config.json
|
||
];
|
||
|
||
export function workerDbName(runId, workerId) {
|
||
const safe = s => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
||
return `night_${safe(runId)}_${safe(workerId)}`;
|
||
}
|
||
|
||
// Доступы к местной базе БЕРУТСЯ ИЗ РАБОЧИХ НАСТРОЕК, а не выдумываются: проверено живьём —
|
||
// вход под postgres/postgres местная база отвергает. Имя базы отсюда НЕ берётся: у работника своя.
|
||
export function dbCredsFromAppEnv(repoRoot) {
|
||
const path = join(repoRoot, 'app', '.env');
|
||
if (!existsSync(path)) {
|
||
throw new Error(`Нет рабочих настроек ${path} — доступы к местной базе взять негде, а выдумывать их нельзя: база отвергнет вход посреди ночи.`);
|
||
}
|
||
const text = readFileSync(path, 'utf8');
|
||
const pick = key => {
|
||
const m = new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, 'm').exec(text);
|
||
if (!m) return null;
|
||
return m[1].trim().replace(/^["']|["']$/g, '');
|
||
};
|
||
const username = pick('DB_USERNAME');
|
||
if (!username) {
|
||
throw new Error(`В ${path} нет DB_USERNAME — доступов к базе нет. Выдуманных доступов у затеи не бывает.`);
|
||
}
|
||
return {
|
||
host: pick('DB_HOST') ?? '127.0.0.1',
|
||
port: pick('DB_PORT') ?? '5432',
|
||
username,
|
||
password: pick('DB_PASSWORD') ?? '',
|
||
};
|
||
}
|
||
|
||
export function workerEnvFile({ dbName, creds, appUrl = 'http://127.0.0.1:8000' }) {
|
||
if (!creds?.username) {
|
||
throw new Error('Не даны доступы к местной базе (из app/.env) — угол не заводится: выдуманные доступы дают отказ базы посреди ночи.');
|
||
}
|
||
// Ровно то, что нужно для проверок, и ни одного боевого ключа.
|
||
//
|
||
// 🔴 Пустая строка `APP_KEY=` тут ОБЯЗАТЕЛЬНА, и вот почему. Ключ заводит
|
||
// `php artisan key:generate` в самом углу (задача 7), но эта команда не ДОПИСЫВАЕТ строку,
|
||
// а ЗАМЕНЯЕТ уже существующую. Нет строки — заменять нечего: ключ не запишется, команда
|
||
// напечатает ошибку и вернёт при этом НУЛЕВОЙ код (проверено по живому коду Laravel
|
||
// в этом хранилище, `KeyGenerateCommand.php`: `return;` без кода возврата). Заведение угла
|
||
// сочло бы это успехом, а работник всю ночь бился бы в «No application encryption key
|
||
// has been specified» — ровно та беда, от которой шаг и поставлен.
|
||
return [
|
||
'APP_ENV=testing',
|
||
'APP_DEBUG=true',
|
||
'APP_KEY=',
|
||
`APP_URL=${appUrl}`,
|
||
'DB_CONNECTION=pgsql',
|
||
`DB_HOST=${creds.host}`,
|
||
`DB_PORT=${creds.port}`,
|
||
`DB_DATABASE=${dbName}`,
|
||
`DB_USERNAME=${creds.username}`,
|
||
`DB_PASSWORD=${creds.password}`,
|
||
'CACHE_STORE=array',
|
||
'QUEUE_CONNECTION=sync',
|
||
'SESSION_DRIVER=array',
|
||
'MAIL_MAILER=array',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
// Угол ветвится от ВЕТКИ ЗАТЕИ. Точка ветвления передаётся явно: без неё git берёт текущую
|
||
// ветку хозяйства, а это может быть что угодно, вплоть до главной, где затеи нет вовсе.
|
||
export function worktreeArgs({ branch, workspaceRoot, baseBranch }) {
|
||
if (!baseBranch) {
|
||
throw new Error('Не названа точка ветвления рабочего угла (base_branch). От главной ветки в углу нет ничего из затеи — ни модулей, ни поручения.');
|
||
}
|
||
return ['worktree', 'add', '-b', branch, workspaceRoot, baseBranch];
|
||
}
|
||
|
||
// Пароль базы уходит psql переменной окружения. Ключа «пароль» в командной строке у psql нет,
|
||
// а без пароля он останавливается и спрашивает его у процесса, у которого нет клавиатуры.
|
||
export function psqlEnv(creds, from = process.env) {
|
||
return { ...from, PGPASSWORD: creds?.password ?? '' };
|
||
}
|
||
|
||
export function assertNoLiveSecrets(workspaceRoot) {
|
||
// 🪤 Смотреть только в тот файл, который мы сами и написали, — значит всегда отвечать
|
||
// «годен». Поэтому мест несколько: боевые настройки продукта, настройки самой затеи
|
||
// с паролем почты и всё, что могло приехать в угол вместе с веткой.
|
||
const suspects = [
|
||
join(workspaceRoot, 'app', '.env'),
|
||
join(workspaceRoot, 'app', '.env.production'),
|
||
join(workspaceRoot, '.env'),
|
||
join(workspaceRoot, '.env.production'),
|
||
join(workspaceRoot, 'night.config.json'),
|
||
];
|
||
for (const path of suspects) {
|
||
if (!existsSync(path)) continue;
|
||
const text = readFileSync(path, 'utf8');
|
||
for (const re of LIVE_MARKERS) {
|
||
if (re.test(text)) {
|
||
return {
|
||
ok: false,
|
||
reason: `В рабочем углу нашлись боевые доступы (${path}, признак ${re}). Работник не стартует: замок держится на том, что их там нет.`,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
return { ok: true, reason: null };
|
||
}
|
||
|
||
// 🔴 Сторож ключа приложения. `php artisan key:generate` ЗАМЕНЯЕТ строку `APP_KEY=`,
|
||
// а не дописывает её, и при неудаче печатает ошибку с НУЛЕВЫМ кодом возврата — то есть
|
||
// заведение угла сочло бы провал успехом, а работник всю ночь бился бы в «No application
|
||
// encryption key has been specified». Поэтому после `key:generate` файл читается глазами
|
||
// программы: ключ либо есть непустой, либо угла нет.
|
||
export function assertAppKey(workspaceRoot) {
|
||
const path = join(workspaceRoot, 'app', '.env');
|
||
const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
||
const m = /^\s*APP_KEY\s*=\s*(\S+)\s*$/m.exec(text);
|
||
if (!m) {
|
||
throw new Error(`В ${path} нет непустого APP_KEY после key:generate. Команда печатает успех и при неудаче — верить ей нельзя. Работник в таком углу всю ночь бился бы в «No application encryption key has been specified».`);
|
||
}
|
||
return m[1];
|
||
}
|
||
|
||
export function buildWorkerSettings() {
|
||
// Настройки запуска работника. На хуки НЕ опираемся: проверено живьём —
|
||
// в отдельном углу они не запускаются вовсе. Здесь запрет живёт в самом запуске.
|
||
//
|
||
// 🪤 И одних запретов МАЛО. Работник запускается безголово: согласия человека
|
||
// спросить не у кого. Без явного разрешающего списка `git commit` и прогон проверок
|
||
// упёрлись бы в отказ, и ни один пункт не закрылся бы за все восемь часов —
|
||
// при зелёных проверках на «запреты держат». Поэтому разрешено ровно то, что нужно
|
||
// для закрытия пункта: правка файлов, свой коммит, свои проверки. Запрет сильнее
|
||
// разрешения, поэтому push, слияние и «наружу» остаются закрытыми.
|
||
return {
|
||
permissions: {
|
||
defaultMode: 'acceptEdits',
|
||
allow: [
|
||
// 🔴 Правка, запись и чтение — ТОЛЬКО внутри угла. Голые `Read`/`Write`/`Edit`
|
||
// без границы по пути — это право записать любой файл на машине: и главный рабочий
|
||
// каталог, откуда идёт выкат, и `.git` главной ветки, и настройки помощника,
|
||
// и ключи в домашнем каталоге. Образцы считаются от каталога, где лежит сам этот
|
||
// файл настроек, а он лежит в корне угла — значит `./**` это ровно угол и ничего сверх.
|
||
'Read(./**)',
|
||
'Write(./**)',
|
||
'Edit(./**)',
|
||
'Glob',
|
||
'Grep',
|
||
'TodoWrite',
|
||
'Bash(git add:*)',
|
||
'Bash(git commit:*)',
|
||
'Bash(git status:*)',
|
||
'Bash(git diff:*)',
|
||
'Bash(git log:*)',
|
||
'Bash(git rev-parse:*)',
|
||
'Bash(git branch --show-current)',
|
||
'Bash(npm run test:tools:*)',
|
||
'Bash(npm run lint:vue:*)',
|
||
'Bash(npm run type-check:*)',
|
||
'Bash(npm run test:vue:*)',
|
||
'Bash(composer test:*)',
|
||
'Bash(composer pint:*)',
|
||
'Bash(composer stan:*)',
|
||
'Bash(php artisan test:*)',
|
||
'Bash(php artisan test --testsuite=Browser:*)',
|
||
'Bash(php artisan migrate:*)',
|
||
'Bash(npx vitest:*)',
|
||
// Браузерные проверки и сборка фронта: без них не закрыть ни один пункт про то,
|
||
// что видит клиент. Playwright и Pa11y — уже стоящие в хозяйстве инструменты.
|
||
'Bash(npx playwright test:*)',
|
||
'Bash(npm run a11y:*)',
|
||
'Bash(npm run build:*)',
|
||
// 🔴 Выдержать паузу. Без этого права работнику физически нечем ждать: его дело
|
||
// в пробном долгом прогоне — раз в десять минут дописать строку следа, а на этих
|
||
// отметках держится ВСЁ доказательство проверки 1 (жив через три часа). Не дать
|
||
// права — он упрётся в отказ на первой же паузе, замолчит, и надзиратель назовёт
|
||
// его вставшим. Проверка 1 провалилась бы не потому, что затея не работает,
|
||
// а потому, что работнику нечем было переждать десять минут.
|
||
'Bash(sleep:*)',
|
||
],
|
||
deny: [
|
||
'Bash(git push:*)',
|
||
'Bash(git merge:*)',
|
||
'Bash(git checkout main:*)',
|
||
'Bash(git checkout master:*)',
|
||
'Bash(git branch -f:*)',
|
||
'Bash(git remote:*)',
|
||
// 🪤 Запрет ловит НАЧАЛО строки команды. Значит одна и та же работа, написанная
|
||
// другой формой, проходит мимо запрета не таясь: `git -C <чужой каталог> push`,
|
||
// `git -c ... push`, `git switch main`, `git worktree add ... main`,
|
||
// `git update-ref refs/heads/main`, `git config remote.origin.url`. Все эти формы
|
||
// закрыты поимённо — иначе список запретов держит только честного.
|
||
'Bash(git -C:*)',
|
||
'Bash(git -c:*)',
|
||
'Bash(git --git-dir:*)',
|
||
'Bash(git --work-tree:*)',
|
||
'Bash(git switch:*)',
|
||
'Bash(git worktree:*)',
|
||
'Bash(git update-ref:*)',
|
||
'Bash(git config:*)',
|
||
'Bash(git reset --hard:*)',
|
||
'Bash(git clean:*)',
|
||
'Bash(gh :*)',
|
||
'Bash(scp:*)',
|
||
'Bash(rsync:*)',
|
||
'Bash(ssh:*)',
|
||
'Bash(curl:*)',
|
||
'Bash(wget:*)',
|
||
// Наружу из угла — ни читать, ни писать. Угол — это git worktree, он делит
|
||
// один и тот же `.git` с хозяйством, поэтому «выйти вверх» тут не отвлечённая
|
||
// опасность, а один шаг `..`.
|
||
'Read(../**)',
|
||
'Write(../**)',
|
||
'Edit(../**)',
|
||
'Read(~/**)',
|
||
'Write(~/**)',
|
||
'Edit(~/**)',
|
||
'WebFetch',
|
||
'WebSearch',
|
||
],
|
||
},
|
||
env: { NIGHT_WORKER: '1' },
|
||
};
|
||
}
|
||
|
||
// 🔴 Замки, которые никто не положил в угол, — это обещание на бумаге и ничего больше.
|
||
// Здесь они кладутся файлом, и сразу закрывается вторая дыра: рабочий угол — это worktree
|
||
// того же хранилища, и в нём лежит ОТСЛЕЖИВАЕМЫЙ хранилищем `.claude/settings.json`
|
||
// со своими разрешениями и хуками. Чей список победит в углу — вопрос, на который мы
|
||
// отвечать не хотим вовсе: поэтому проектный список угла перезаписывается ТЕМИ ЖЕ замками,
|
||
// а личный (`settings.local.json`) убирается. Тогда ответ один при любом порядке чтения.
|
||
export function writeWorkerSettings(workspaceRoot) {
|
||
const settings = buildWorkerSettings();
|
||
const text = JSON.stringify(settings, null, 2);
|
||
const own = workerSettingsPath(workspaceRoot);
|
||
writeFileSync(own, text, 'utf8');
|
||
|
||
const claudeDir = join(workspaceRoot, '.claude');
|
||
mkdirSync(claudeDir, { recursive: true });
|
||
writeFileSync(join(claudeDir, 'settings.json'), text, 'utf8');
|
||
rmSync(join(claudeDir, 'settings.local.json'), { force: true });
|
||
|
||
return { settingsPath: own, projectSettingsPath: join(claudeDir, 'settings.json') };
|
||
}
|
||
|
||
// 🪤 Запуск внешней программы в одном месте, а не в шести. `composer` и `npm` на этой
|
||
// машине — `.bat`/`.cmd`, а Node с 18.20/20.12 отказывается запускать такие файлы
|
||
// без оболочки (CVE-2024-27980, ответ EINVAL). Забудь оболочку в одном из шести вызовов —
|
||
// и угол не заведётся, а узнается это ночью.
|
||
function runTool(bin, args, opts = {}) {
|
||
return execFileSync(bin, args, { stdio: 'pipe', shell: needsShell(bin), ...opts });
|
||
}
|
||
|
||
// 🆕 ДОЛГ 13 (найдено живьём 01.08.2026, врезка в шапке задачи 7). Пре-коммитный сторож
|
||
// секретов зовётся строкой `./bin/gitleaks.exe` — путём от ТЕКУЩЕГО каталога, а сам бинарник
|
||
// (22 МБ) лежит только в главном хозяйстве и в хранилище не хранится. Без этой функции угол
|
||
// заводится «успешно», а первый же коммит работника в нём падает целиком с «нет такого файла» —
|
||
// ровно тот класс беды, который сама затея обязана ловить, а не создавать.
|
||
export function installSecretsGuard({ repoRoot, workspaceRoot, run = runTool }) {
|
||
const guardName = process.platform === 'win32' ? 'gitleaks.exe' : 'gitleaks';
|
||
const srcDir = join(repoRoot, 'bin');
|
||
const destDir = join(workspaceRoot, 'bin');
|
||
|
||
// Сторожа нет даже в главном хозяйстве — врать про «угол готов» нельзя, останавливаемся
|
||
// здесь, а не после долгой установки composer/npm.
|
||
if (!existsSync(join(srcDir, guardName))) {
|
||
throw new Error(
|
||
`Сторож секретов не найден в главном хозяйстве (${join(srcDir, guardName)}). Угол не заводим: `
|
||
+ 'без него коммит работника в углу будет падать целиком, и работа за ночь не сохранится.',
|
||
);
|
||
}
|
||
|
||
// Стрелка (junction), не копия: это ГОТОВАЯ ПРОГРАММА, а не код проекта. Запрет на ссылку
|
||
// для vendor/node_modules (абзац в шапке задачи) — про код, который Laravel/Node САМИ
|
||
// резолвят своим резолвером путей и с которым потом сверяют поведение; ссылка подсовывает
|
||
// им чужой код при зелёном результате. gitleaks.exe никто так не резолвит — программу здесь
|
||
// просто исполняют по полному пути, подменять там нечего. Копировать 22 МБ семи работникам
|
||
// каждую ночь — дорого и незачем; стрелка бесплатна.
|
||
//
|
||
// 🔴🔴 ПОПРАВЛЕНО ПО ЖИВОМУ ОБРЫВУ 02.08.2026. Прежде здесь стояло только «нет каталога —
|
||
// поставь стрелку», и заведение угла падало ВСЕГДА, у каждого работника, каждую ночь.
|
||
// Причина: каталог `bin` в углу есть ВСЕГДА — хранилище само хранит `bin/.gitkeep`
|
||
// и `bin/zap-active-scan.ps1`, и они приезжают в угол вместе с веткой. Стрелка не ставилась
|
||
// никогда, сторожа в углу не было никогда. Замерено настоящим пробным углом, а не чтением.
|
||
//
|
||
// 🪤 Каталог сносить и заменять стрелкой НЕЛЬЗЯ: в нём лежат отслеживаемые хранилищем
|
||
// файлы работника. Поэтому в готовый каталог кладётся ОДИН файл сторожа — жёсткой
|
||
// ссылкой: места она не занимает так же, как стрелка, и 22 МБ никто не копирует.
|
||
const guardPath = join(destDir, guardName);
|
||
if (!existsSync(destDir)) {
|
||
symlinkSync(srcDir, destDir, 'junction');
|
||
} else if (!existsSync(guardPath)) {
|
||
// 🔴 Запасной путь — настоящая копия: жёсткая ссылка живёт только в пределах одного
|
||
// тома, а угол может лежать и на другом диске. Молча остаться без сторожа тут нельзя.
|
||
try {
|
||
linkSync(join(srcDir, guardName), guardPath);
|
||
} catch {
|
||
copyFileSync(join(srcDir, guardName), guardPath);
|
||
}
|
||
}
|
||
if (!existsSync(guardPath)) {
|
||
throw new Error(`Сторож секретов не попал в угол: ${guardPath} не виден после стрелки на ${srcDir}.`);
|
||
}
|
||
|
||
// Мало того, что файл лежит на месте, — он обязан ЗАПУСКАТЬСЯ. Не проверить это здесь —
|
||
// и получится ровно та беда, которую вся затея ловит: угол «завёлся», а сторож в нём мёртв.
|
||
try {
|
||
run(guardPath, ['version'], { cwd: workspaceRoot });
|
||
} catch (err) {
|
||
throw new Error(
|
||
`Сторож секретов в угле не запускается (${guardPath}): ${err?.message ?? err}. `
|
||
+ 'Угол не заводим — доверять коммитам в нём нельзя.',
|
||
);
|
||
}
|
||
|
||
return { guardPath };
|
||
}
|
||
|
||
// 🔴 Проверка 46, часть «базы не досталось — работник не стартовал вовсе».
|
||
// Имя общей базы проверок названо здесь словом намеренно: это единственное место затеи,
|
||
// где оно вообще упоминается, и упоминается оно затем, чтобы ЗАПРЕТИТЬ его.
|
||
const OBSHCHAYA_BAZA_PROVEROK = 'liderra_testing';
|
||
|
||
function assertOwnDbName(name) {
|
||
const s = String(name ?? '').trim();
|
||
if (!s) {
|
||
throw new Error('Работнику не досталось своей базы, поэтому угол не заводится и работник не стартует. Общая база на семерых даёт ЗЕЛЁНЫЕ ВРУЩИЕ проверки — это хуже, чем не запуститься вовсе.');
|
||
}
|
||
if (/(^|_)(undefined|null)(_|$)/.test(s)) {
|
||
throw new Error(`Имя своей базы вышло «${s}» — значит номер прогона или номер работника не передали. Такое имя одинаково у всех, кто ошибся так же, и это та же общая база, только с правдоподобным названием.`);
|
||
}
|
||
if (s === OBSHCHAYA_BAZA_PROVEROK) {
|
||
throw new Error(`Работнику подсунули общую базу проверок «${s}». В неё пишут все проверки хозяйства сразу — семеро затрут друг другу таблицы, а проверки при этом останутся зелёными.`);
|
||
}
|
||
if (!/^[a-z0-9_]+$/.test(s)) {
|
||
throw new Error(`В имени базы «${s}» есть посторонние знаки. В имени базы допустимы только строчные буквы, цифры и подчёркивание — иначе база откажет уже посреди ночи, когда чинить будет некому.`);
|
||
}
|
||
return s;
|
||
}
|
||
|
||
// 🆕 БЕДА 7 (найдена ЖИВЫМ ОБРЫВОМ 02.08.2026, не чтением). Уборка за упавшей на полпути
|
||
// установкой угла. Убирает ТОЛЬКО то, что заведено своей рукой в этом самом вызове, —
|
||
// метки `nashe` ставятся сразу после удачи каждого заводящего шага и ни секундой раньше.
|
||
//
|
||
// 🪤 Порядок здесь не украшение, и переставлять его нельзя:
|
||
// 1) база — её снос ни от чего не зависит;
|
||
// 2) каталог угла — пока он выгружен на ветку, git ветку удалить НЕ ДАСТ;
|
||
// 3) ветка — последней.
|
||
//
|
||
// 🔴 Ни один сбой уборки не имеет права заслонить ПРИЧИНУ падения: здесь ничего не бросается
|
||
// вовсе, возвращается список того, что осталось лежать, а собирает слова сам `createWorkspace`.
|
||
// 🔴🔴 НЕНАДЁЖНЫЙ ШАГ (замерено живьём 03.08.2026). `composer install` в свежем углу упал
|
||
// ОДИН РАЗ ИЗ ТРЁХ на одном и том же коде и одних и тех же бумагах:
|
||
//
|
||
// In ClassMapGenerator.php line 169:
|
||
// realpath of …/pest-plugin-mutate/src\Subscribers\TrackMutationSuiteFinish.php
|
||
// failed to resolve, got false
|
||
//
|
||
// 🔴 ПРИЧИНА НЕ НАЙДЕНА, И Я ЕЁ НЕ ВЫДУМЫВАЮ. Стройное объяснение через предел длины пути
|
||
// Windows я построил и сам же опроверг прямым опытом (PHP берёт пути и в 270 знаков, а
|
||
// спорный файл лежал на диске). Поэтому лечим НЕНАДЁЖНОСТЬ, а не выдуманную причину.
|
||
//
|
||
// 🪤 Ненадёжный шаг хуже стены. Стена падает всегда и потому видна; этот падает через раз
|
||
// и выглядит случайностью. Работников семеро, заведение каждого ~15 минут — половина
|
||
// углов не завелась бы, и владелец получил бы утром «работники не завелись» без причины.
|
||
export const POPYTOK_SETEVOGO_SHAGA = 3;
|
||
|
||
// Отказы, которые повторять БЕССМЫСЛЕННО: они не про сеть, а про сами бумаги проекта.
|
||
//
|
||
// 🔴 ЭТОТ СПИСОК — МИНИМУМ, А НЕ ПЕРЕЧЕНЬ. В него попадает только то, чью повторяемость
|
||
// я ЗАМЕРИЛ. `ERESOLVE` замерен в круге 1 (стена 2): он падает ВСЕГДА, на каждой попытке,
|
||
// потому что это спор версий в `package.json`. Повторять его — сжечь три раза по минуте
|
||
// и написать владельцу неправду про «сеть моргнула».
|
||
//
|
||
// 🟡 Чего в списке НЕТ и почему: у отказа composer выше приметы нет ВООБЩЕ — я не знаю,
|
||
// чем он отличается от сетевого. Значит он попадает в «повторяем», и это осознанно:
|
||
// цена ошибки в эту сторону — потраченное время, в другую — несделанная ночь.
|
||
const BESSMYSLENNO_POVTORYAT = [
|
||
{ primeta: /ERESOLVE/i, chto: 'не сошлись версии в бумагах проекта — это не сеть, повтор ничего не изменит' },
|
||
];
|
||
|
||
// 🪤 ПАУЗЫ МЕЖДУ ПОПЫТКАМИ ЗДЕСЬ НЕТ, И ЭТО РЕШЕНИЕ, А НЕ ЗАБЫВЧИВОСТЬ. Пауза — обычный
|
||
// приём, но я НЕ ЗАМЕРЯЛ, помогает ли она именно этому отказу. Добавить её значило бы
|
||
// лечить выдуманную причину и потом объяснять владельцу лишние минуты. Понадобится —
|
||
// добавится по замеру.
|
||
export function sPovtorom({ run, bin, args, opts, imya, popytok = POPYTOK_SETEVOGO_SHAGA, zhurnal = null }) {
|
||
const upali = [];
|
||
const nachalo = Date.now();
|
||
const sekund = () => Math.round((Date.now() - nachalo) / 1000);
|
||
|
||
for (let n = 1; n <= popytok; n += 1) {
|
||
try {
|
||
const out = run(bin, args, opts);
|
||
// 🔴 Записываем ТОЛЬКО когда вправду пришлось повторять. Пиши мы каждую удачу —
|
||
// владелец читал бы «повторов 0» семь раз за ночь и перестал бы это замечать,
|
||
// а строка нужна ровно затем, чтобы её заметили.
|
||
if (n > 1) zhurnal?.push({ shag: imya, popytok: n, sekund: sekund(), upali: [...upali] });
|
||
return out;
|
||
} catch (err) {
|
||
const slova = `${err?.message ?? err}\n${err?.stdout ?? ''}\n${err?.stderr ?? ''}`;
|
||
upali.push(String(err?.message ?? err).split('\n')[0]);
|
||
|
||
const bes = BESSMYSLENNO_POVTORYAT.find(b => b.primeta.test(slova));
|
||
if (bes) {
|
||
zhurnal?.push({ shag: imya, popytok: n, sekund: sekund(), upali: [...upali], ne_povtoryali: bes.chto });
|
||
// 🔴 Причина идёт ЦЕЛИКОМ и первой: владелец чинит по ней, а не по нашей приписке.
|
||
throw new Error(`${err?.message ?? err}\n\nПовторять этот шаг («${imya}») не стали: ${bes.chto}.`);
|
||
}
|
||
|
||
if (n === popytok) {
|
||
zhurnal?.push({ shag: imya, popytok: n, sekund: sekund(), upali: [...upali], sdalis: true });
|
||
throw new Error(`${err?.message ?? err}\n\nШаг «${imya}» отказал ${popytok} раза подряд за ${sekund()} с — это уже не случайность, и повторять дальше нечего.`);
|
||
}
|
||
}
|
||
}
|
||
// Сюда попасть нельзя: цикл либо вернул, либо бросил. Строка нужна статанализу.
|
||
throw new Error(`Шаг «${imya}»: повторы кончились непонятным образом.`);
|
||
}
|
||
|
||
// 🆕 `snesi`/`est` подставляются по той же причине, что и `run`: запасную дорогу уборки
|
||
// надо уметь УРОНИТЬ в проверке. Живой случай, ради которого она заведена, — «каталог
|
||
// держит чужая программа», и тогда снос руками падает ТОЖЕ. Без подстановки этот случай
|
||
// не воспроизводится, и проверка сторожила бы только удачную половину.
|
||
function uberiZaSoboy({ run, git, psql, repoRoot, workspaceRoot, branch, dbName, creds, nashe, snesi = rmSync, est = existsSync }) {
|
||
const ostalos = [];
|
||
if (nashe.baza) {
|
||
try {
|
||
run(psql, ['-h', creds.host, '-p', String(creds.port), '-U', creds.username, '-d', 'postgres', '-c', `DROP DATABASE IF EXISTS ${dbName}`], {
|
||
env: psqlEnv(creds),
|
||
});
|
||
} catch (err) { ostalos.push(`база ${dbName} (${err?.message ?? err})`); }
|
||
}
|
||
if (nashe.ugol) {
|
||
let ugolUbran = true;
|
||
try {
|
||
run(git, ['worktree', 'remove', '--force', workspaceRoot], { cwd: repoRoot });
|
||
} catch (err) {
|
||
// 🔴🔴 ЗАПАСНАЯ ДОРОГА УБОРКИ — НАЙДЕНА ЖИВЬЁМ 03.08.2026. Установка упала сама,
|
||
// уборка пошла штатной дорогой и ТОЖЕ упала:
|
||
//
|
||
// git worktree remove --force … → код 128
|
||
// fatal: validation failed, cannot remove working tree: '…/.git' does not exist
|
||
//
|
||
// Угол и ветка остались в хранилище, снимал руками. То есть «беда 7» была закрыта
|
||
// не полностью: закрыт случай «упал шаг установки», не закрыт «упала сама уборка».
|
||
//
|
||
// 🪤 И отказала она ровно там, где угол ПОВРЕЖДЁН, — то есть в самом частом случае,
|
||
// ради которого уборка и заведена. Проверка «уборка работает на целом угле» была бы
|
||
// зелена всегда и не сторожила бы ничего.
|
||
//
|
||
// Запасная дорога делает то же самое руками: сносит каталог, чистит запись о нём
|
||
// в хранилище (`worktree prune` — без неё git считает угол живым и не отдаёт ветку),
|
||
// и только после этого выше снимается ветка.
|
||
ugolUbran = false;
|
||
const pervaya = String(err?.message ?? err);
|
||
try {
|
||
snesi(workspaceRoot, { recursive: true, force: true });
|
||
// 🔴 Верим не своей команде, а диску: `rmSync` с `force` молчит и когда не снёс.
|
||
if (est(workspaceRoot)) throw new Error('каталог остался на диске после сноса');
|
||
run(git, ['worktree', 'prune'], { cwd: repoRoot });
|
||
ugolUbran = true;
|
||
} catch (err2) {
|
||
ostalos.push(`каталог угла ${workspaceRoot} (штатно: ${pervaya}; запасной дорогой: ${err2?.message ?? err2})`);
|
||
}
|
||
}
|
||
// 🔴 Ветку трогаем только если каталог вправду ушёл. Иначе `branch -D` откажет сам
|
||
// («используется рабочим каталогом»), и в остатках стояла бы вторая строка про то же
|
||
// самое — владелец решил бы, что бед две.
|
||
if (ugolUbran) {
|
||
try {
|
||
run(git, ['branch', '-D', branch], { cwd: repoRoot });
|
||
} catch (err) { ostalos.push(`ветка ${branch} (${err?.message ?? err})`); }
|
||
} else {
|
||
ostalos.push(`ветка ${branch} (её нельзя снять, пока лежит каталог угла)`);
|
||
}
|
||
}
|
||
return ostalos;
|
||
}
|
||
|
||
// 🔴🔴 СТЕНА 4, НАЙДЕНА ЖИВЬЁМ 03.08.2026 В СОБРАННОМ УГЛУ — и найдена ТОЛЬКО потому, что
|
||
// в углу вправду позвали проверку продукта. Все прежние приборы смотрели внутрь и молчали.
|
||
//
|
||
// ЧТО СЛУЧИЛОСЬ. `php artisan test --filter=ExampleTest` в свежем углу отвечает:
|
||
// Expected response status code [200] but received 500.
|
||
// InvalidArgumentException: Please provide a valid cache path.
|
||
// in vendor/laravel/framework/src/Illuminate/View/Compilers/Compiler.php:75
|
||
//
|
||
// ПРИЧИНА ЗАМЕРЕНА, А НЕ ВЫВЕДЕНА. Хранилище знает про `app/storage` ровно восемь файлов
|
||
// (`git ls-files app/storage`), и это подпорки `.gitignore` для `app`, `framework`,
|
||
// `framework/cache`, `framework/cache/data` и `logs`. Подпорок для `framework/views`,
|
||
// `framework/sessions` и `framework/testing` в хранилище НЕТ. Пустых каталогов git не
|
||
// хранит — значит в любом свежем углу этих трёх каталогов не существует, и первый же
|
||
// шаблон Blade роняет запрос пятисоткой.
|
||
//
|
||
// 🔴 ЭТО ШИРЕ НОЧИ. Тем же местом ломается ЛЮБАЯ свежая выписка проекта, не только угол
|
||
// работника. Настоящее лечение — вернуть подпорки в хранилище, но `app/**` это бумаги
|
||
// основного портала, и правит их владелец. Здесь мы чиним ровно свой угол.
|
||
//
|
||
// 🪤 ПОЧЕМУ НЕ «СОЗДАТЬ ТОЛЬКО VIEWS». Пятисотку дал `views`, но недостающих каталогов
|
||
// три, и остальные два выстрелят на первом же тесте с сессией или с подставным диском —
|
||
// то есть ночью, когда чинить некому. Заводим весь набор, какой Laravel держит сам.
|
||
export const RABOCHIE_KATALOGI = [
|
||
['storage', 'framework', 'views'],
|
||
['storage', 'framework', 'sessions'],
|
||
['storage', 'framework', 'testing'],
|
||
['storage', 'framework', 'cache', 'data'],
|
||
['storage', 'logs'],
|
||
['bootstrap', 'cache'],
|
||
];
|
||
|
||
// Возвращает то, чего НЕ БЫЛО и что завели мы. Пустой ответ — значит всё уже стояло.
|
||
// Отвечать списком, а не молчанием, нужно проверке: иначе она не отличит «завели» от
|
||
// «ничего не делали» и была бы зелена в обоих случаях.
|
||
export function sozdayRabochieKatalogi(workspaceRoot, est = existsSync, sozday = mkdirSync) {
|
||
const zaveli = [];
|
||
for (const chasti of RABOCHIE_KATALOGI) {
|
||
const put = join(workspaceRoot, 'app', ...chasti);
|
||
if (est(put)) continue;
|
||
sozday(put, { recursive: true });
|
||
zaveli.push(chasti.join('/'));
|
||
}
|
||
return zaveli;
|
||
}
|
||
|
||
// 🆕 Подставной запуск внешних программ (`run`) — та же точка впрыска, что у `installSecretsGuard`
|
||
// и у `destroy` в уборке старых углов. Живой ход берёт настоящий `runTool`; проверкам нужно
|
||
// уронить установку на названном шаге и посмотреть, что позвано после падения, — а настоящий
|
||
// `git worktree add` во временном каталоге, хранилищем не являющимся, падает сам и не по делу.
|
||
export function createWorkspace({ repoRoot, runId, workerId, branch, workspaceRoot, baseBranch, dbName = null, run = runTool, snesi = rmSync }) {
|
||
// 🔴 ПЕРВОЙ строкой, до всего остального. Ниже заводится угол и минутами ставятся
|
||
// зависимости; отказ после этого означал бы, что мы уже потратили место и время,
|
||
// а проверка 46 требует «не стартовал ВОВСЕ».
|
||
const db = assertOwnDbName(dbName ?? workerDbName(runId, workerId));
|
||
// 🪤 Windows + Laravel: vendor и node_modules НЕЛЬЗЯ подтягивать ссылкой на главный
|
||
// каталог — Laravel резолвит ссылку обратно и гоняет проверки против ЧУЖОГО кода
|
||
// при зелёном результате. Ставим зависимости в углу заново.
|
||
//
|
||
// 🪤 И ни одну внешнюю программу не зовём голым именем: `composer` и `npm` в PATH —
|
||
// скрипты без расширения, из программы это ENOENT (класс, убивший 334 прогона).
|
||
const git = resolveBin('git');
|
||
const composer = resolveBin('composer');
|
||
const npm = resolveBin('npm');
|
||
const php = resolveBin('php');
|
||
const psql = resolveBin('psql');
|
||
|
||
mkdirSync(workspaceRoot, { recursive: true });
|
||
|
||
// 🔴 БЕДА 7. Что заведено СВОЕЙ рукой в этом самом вызове — и, значит, что нам можно
|
||
// убирать при падении. Метка ставится ТОЛЬКО после удачи заводящего шага.
|
||
const nashe = { ugol: false, baza: false };
|
||
// 🔴 Сюда ложатся ТОЛЬКО те шаги, которые пришлось повторять. Пустой список = ночь
|
||
// прошла гладко; непустой обязан дойти до сводки владельца, иначе мы уберём КРИК,
|
||
// а не БЕДУ, и ненадёжность спрячется навсегда.
|
||
const povtory = [];
|
||
// Доступы нужны уборке базы, а взяты они будут ниже — объявляем заранее, иначе уборка
|
||
// их не увидит. Пока их нет, метка `nashe.baza` стоять не может физически.
|
||
let creds = null;
|
||
|
||
try {
|
||
// Угол ветвится от ВЕТКИ ЗАТЕИ, а не от главной: точка ветвления передана явно.
|
||
//
|
||
// 🔴🔴 ЭТА СТРОКА СТОИТ ВНУТРИ УБОРКИ, И ЭТО ПОПРАВЛЕНО ВЫРЕЗАНИЕМ ВТОРОГО РОДА
|
||
// (02.08.2026). Сперва она стояла СНАРУЖИ — «упало заведение ветки, значит убирать
|
||
// нечего». Выглядело верно, но метка `nashe.ugol` при этом становилась МЁРТВОЙ: до
|
||
// уборки доходили только те падения, где ветка уже заведена, то есть проверка
|
||
// `if (nashe.ugol)` отвечала «да» ВСЕГДА и не сторожила ничего. Замерено: перенос
|
||
// метки на строку выше не покраснил НИ ОДНОЙ из шестидесяти проверок.
|
||
// Теперь запрет держит сама метка, и её вправду спрашивают.
|
||
run(git, worktreeArgs({ branch, workspaceRoot, baseBranch }), { cwd: repoRoot });
|
||
// 🔴 ДОКАЗАТЕЛЬСТВО, ЧТО ВЕТКА НАША, — в самой команде, а не в нашей вере. `worktree add -b`
|
||
// на уже существующей ветке ПАДАЕТ (замерено живьём 02.08.2026: код возврата 255,
|
||
// «fatal: a branch named ... already exists», каталога при этом не появляется). Значит
|
||
// удачный `add` означает ровно одно: и ветку, и каталог завели мы. Упал `add` — метка
|
||
// не встанет, и уборка не тронет ветку, которая может оказаться чужой.
|
||
nashe.ugol = true;
|
||
|
||
// ДОЛГ 13: сторож секретов коммита обязан попасть в угол СРАЗУ, до первой же записи файлов
|
||
// работником, — иначе первый его коммит в этом углу упадёт целиком с «нет такого файла»,
|
||
// а узнается это только ночью, когда чинить уже некому.
|
||
installSecretsGuard({ repoRoot, workspaceRoot, run });
|
||
|
||
// Сторож ДО нашей записи: смотрим, что приехало в угол вместе с веткой. После записи
|
||
// он смотрел бы на файл, написанный тремя строками выше нашей же рукой, и отвечал бы
|
||
// «годен» всегда — то есть не сторожил бы ничего.
|
||
const before = assertNoLiveSecrets(workspaceRoot);
|
||
if (!before.ok) throw new Error(before.reason);
|
||
|
||
creds = dbCredsFromAppEnv(repoRoot);
|
||
writeFileSync(join(workspaceRoot, 'app', '.env'), workerEnvFile({ dbName: db, creds }), 'utf8');
|
||
|
||
// Замки кладём ФАЙЛОМ, здесь. Объявить их в задаче 6 и не положить в угол — значит
|
||
// запустить работника без единого запрета при зелёных проверках на «запреты написаны».
|
||
// Заодно перебивается проектный `.claude/settings.json`, приехавший в угол с веткой.
|
||
const { settingsPath } = writeWorkerSettings(workspaceRoot);
|
||
|
||
// И ещё раз сторож — уже поверх написанного нами.
|
||
const guard = assertNoLiveSecrets(workspaceRoot);
|
||
if (!guard.ok) throw new Error(guard.reason);
|
||
|
||
// Почтовый ящик работника: модулей затеи он не видит, разговаривать с надзирателем
|
||
// может только файлами. Ящик заводим заранее, чтобы работнику не пришлось его создавать.
|
||
// Имя ящика строит `paths.mjs` — второе место, знающее его по-своему, разошлось бы
|
||
// с первым, и надзиратель искал бы ящик не там, где его завели.
|
||
mkdirSync(workerInboxDir(workspaceRoot), { recursive: true });
|
||
|
||
// 🔴 СТЕНА 4: рабочие каталоги Laravel, которых нет в хранилище. Довод целиком в шапке
|
||
// `RABOCHIE_KATALOGI`. Ставится ДО первой php-команды: `key:generate` и `migrate` ниже
|
||
// поднимают приложение, а без `bootstrap/cache` его поднять негде.
|
||
sozdayRabochieKatalogi(workspaceRoot);
|
||
|
||
// Зависимости: свои, а не ссылкой. `composer` — для PHP, `npm ci` дважды —
|
||
// в корне угла (наши проверки, pa11y) и в app/ (фронт, Vue, браузерные проверки).
|
||
// 🪤 Именно здесь и рвётся живая установка чаще всего: `npm ci` — это сеть, а сеть
|
||
// за одни сутки 02.08.2026 обрывалась трижды. Раньше такой обрыв оставлял ветку
|
||
// и полный каталог навсегда.
|
||
// 🔴 Три установки идут ЧЕРЕЗ ПОВТОР (довод — в шапке `POPYTOK_SETEVOGO_SHAGA`).
|
||
// Остальные шаги дороги — нет, и это не забывчивость:
|
||
// · `git worktree add -b` — повтор на уже заведённой ветке падает по ДРУГОЙ причине
|
||
// и стёр бы доказательство «ветка наша» (см. довод у `nashe.ugol`);
|
||
// · `CREATE DATABASE` — повтор упёрся бы в «база уже есть», то есть в свой же успех;
|
||
// · `key:generate`, `migrate`, сборка фронта — местные, сети не трогают, и ни одного
|
||
// их падения я не замерил. Повторять то, что ни разу не падало, — лечить выдумку.
|
||
sPovtorom({ run, bin: composer, args: ['install', '--no-interaction', '--quiet'], opts: { cwd: join(workspaceRoot, 'app') }, imya: 'библиотеки PHP (composer install)', zhurnal: povtory });
|
||
sPovtorom({ run, bin: npm, args: ['ci', '--no-audit', '--no-fund'], opts: { cwd: workspaceRoot }, imya: 'библиотеки JS в корне угла (npm ci)', zhurnal: povtory });
|
||
// 🔴 СТЕНА 2, ЗАМЕРЕНА ЖИВЬЁМ 03.08.2026. Установка в `app/` без `--legacy-peer-deps`
|
||
// падает ВСЕГДА и у КАЖДОГО работника, то есть живой работник не рождался ни разу:
|
||
//
|
||
// npm error code ERESOLVE
|
||
// npm error While resolving: histoire@1.0.0-beta.1
|
||
// npm error Found: vite@8.0.11 (dev vite@"^8.0.0" from the root project)
|
||
// npm error Could not resolve dependency: peer vite@"^7.3.0" from histoire@1.0.0-beta.1
|
||
//
|
||
// Бумаги основного портала (`app/package.json`) правит владелец, не мы, — поэтому спор
|
||
// о версиях мы не решаем, а ГЛУШИМ, и ровно его: `--legacy-peer-deps` пропускает только
|
||
// разбор ссылок между зависимостями, дерево же ставится строго по замку.
|
||
//
|
||
// 🔴 ПОЧЕМУ ЭТО НЕ ВРАНЬЁ ПРО СОСТАВ. Замерено тремя списками: `--legacy-peer-deps` даёт
|
||
// 409 папок верхнего уровня, `--force` даёт те же 409 (`diff` списков пуст), и ровно
|
||
// столько же лежит в главной рабочей копии портала. То есть главная копия сама собрана
|
||
// с тем же допущением — мы повторяем живущее, а не выдумываем новое.
|
||
//
|
||
// 🔴 ПОЧЕМУ НЕ `--force`. Он глушит спор о версиях И ЕЩЁ несколько защит npm разом.
|
||
// Состав вышел тот же, значит лишняя широта ничего не покупает.
|
||
//
|
||
// 🪤 Виновник спора — `histoire` (каталог картинок для страниц). Работнику он не нужен
|
||
// ни разу: среди разрешённых ему команд выше `npm run story` нет. Но `npm ci` ставит
|
||
// все девелоперские зависимости скопом, выбрать «только нужное» он не умеет.
|
||
//
|
||
// 🟢 Корневая установка (строкой выше) этого ключа НЕ получает: она замерена отдельно
|
||
// и проходит с нулём. Ключ ставится ровно там, где замерена стена.
|
||
sPovtorom({ run, bin: npm, args: ['ci', '--no-audit', '--no-fund', '--legacy-peer-deps'], opts: { cwd: join(workspaceRoot, 'app') }, imya: 'библиотеки JS в app/ (npm ci)', zhurnal: povtory });
|
||
|
||
// 🔴🔴 СТЕНА 5, НАЙДЕНА ЖИВЬЁМ 03.08.2026 — и найдена ТОЛЬКО потому, что стену 4 сняли
|
||
// и пошли дальше. Стены стоят ЦЕПОЧКОЙ: каждая следующая видна лишь из-за снятой прежней.
|
||
//
|
||
// Illuminate\Foundation\ViteManifestNotFoundException: Vite manifest not found at:
|
||
// <угол>/app/public/build/manifest.json
|
||
//
|
||
// ПРИЧИНА ЗАМЕРЕНА: `app/.gitignore:20` прячет `/public/build`, и `git ls-files
|
||
// app/public/build` не знает о нём НИ ОДНОГО файла. Значит сборки фронта в свежем углу
|
||
// нет никогда, и любая проверка, рисующая страницу через `@vite(...)`, отвечает
|
||
// пятисоткой — то есть снова «КРАСНЫЕ проверки» про исправный код.
|
||
//
|
||
// 🟢 ЦЕНА ЗАМЕРЕНА, А НЕ ПРИКИНУТА: **9 секунд** (сам vite — 4,45 с). Против 14 минут
|
||
// всей дороги это ничто, поэтому шаг и добавлен, а не вынесен владельцу на решение.
|
||
//
|
||
// 🪤 ПАПКА — `app/`, И ЭТО НЕ МЕЛОЧЬ. Наряда `build` в корневом `package.json` НЕТ:
|
||
// из корня угла `npm run build` отвечает «нет такого наряда» за одну секунду (замерено).
|
||
// Тот же класс, что стена 3, — команда сама по себе верная, а зовут её не оттуда.
|
||
run(npm, ['run', 'build'], { cwd: join(workspaceRoot, 'app') });
|
||
|
||
// Свой ключ приложения: без APP_KEY часть команд Laravel в углу встанет с «No application
|
||
// encryption key has been specified», и работник будет биться в это всю ночь.
|
||
run(php, ['artisan', 'key:generate', '--force'], { cwd: join(workspaceRoot, 'app') });
|
||
// 🔴 И ПРОВЕРИТЬ, что ключ действительно записан. `key:generate` при неудаче печатает
|
||
// ошибку и возвращает НУЛЕВОЙ код — верить её молчанию нельзя, а `stdio: 'pipe'`
|
||
// и саму ошибку прячет. Пустой строкой `APP_KEY=` мы дали ей что заменять; здесь
|
||
// проверяем, что замена случилась.
|
||
assertAppKey(workspaceRoot);
|
||
|
||
// Своя база. Пароль — переменной окружения: иначе psql повиснет, спрашивая его.
|
||
run(psql, ['-h', creds.host, '-p', String(creds.port), '-U', creds.username, '-d', 'postgres', '-c', `CREATE DATABASE ${db}`], {
|
||
env: psqlEnv(creds),
|
||
});
|
||
// 🔴 Метка СРАЗУ после удачи, и ни строкой раньше. Поставь её до команды — и упавший
|
||
// на самой команде вызов пошёл бы сносить базу, которую не заводил, то есть ЧУЖУЮ
|
||
// базу с тем же именем.
|
||
nashe.baza = true;
|
||
// Проверки пойдут в СВОЮ базу только если её имя дойдёт до самого прогона, а не только
|
||
// до .env: app/phpunit.xml:33 задаёт DB_DATABASE жёстко. Поэтому и здесь имя базы уходит
|
||
// окружением — так же, как оно уходит работнику (workerEnv, задача 5).
|
||
run(php, ['artisan', 'migrate', '--force'], {
|
||
cwd: join(workspaceRoot, 'app'),
|
||
env: { ...process.env, DB_DATABASE: db },
|
||
});
|
||
|
||
// 🟡 ЧЕГО ЭТА ПОЧИНКА НЕ ПОКРЫВАЕТ, СКАЗАНО ВСЛУХ: `povtory` я отсюда ОТДАЮ, но до
|
||
// сводки владельца их доносит `run.mjs`, а он не мой файл. Пока туда не врезано —
|
||
// повторы видны только тому, кто зовёт `createWorkspace` напрямую. Это долг, не
|
||
// готовая дорога, и выдавать его за готовую нельзя.
|
||
return { workspaceRoot, dbName: db, branch, creds, settingsPath, povtory };
|
||
} catch (err) {
|
||
// 🔴 БЕДА 7. Установка упала на полпути — убираем ровно своё и говорим об этом словами.
|
||
const ostalos = uberiZaSoboy({ run, git, psql, repoRoot, workspaceRoot, branch, dbName: db, creds, nashe, snesi });
|
||
// 🔴 ПРИЧИНА ПАДЕНИЯ ИДЁТ ПЕРВОЙ И ЦЕЛИКОМ. Заслони её отчётом уборки — и владелец
|
||
// читал бы про уборку вместо того, что вправду сломалось.
|
||
const chistota = ostalos.length
|
||
? ` Убрать за собой удалось не всё, в хранилище осталось: ${ostalos.join('; ')}. Это снимается руками.`
|
||
: ' За собой убрано: ни ветки, ни каталога угла, ни базы после этой попытки не осталось — тот же прогон можно повторить.';
|
||
throw new Error(`${err?.message ?? err}${chistota}`);
|
||
}
|
||
}
|
||
|
||
// Уборка угла. Зовётся ОДИН раз — когда задача прошла приёмку владельца (`cli.mjs accept`,
|
||
// задача 15). Не после смерти работника: его наработки могут ещё понадобиться владельцу.
|
||
export function destroyWorkspace({ repoRoot, workspaceRoot, dbName, creds = null }) {
|
||
const git = resolveBin('git');
|
||
const psql = resolveBin('psql');
|
||
const c = creds ?? dbCredsFromAppEnv(repoRoot);
|
||
const removed = { worktree: false, database: false, errors: [] };
|
||
try {
|
||
runTool(git, ['worktree', 'remove', '--force', workspaceRoot], { cwd: repoRoot });
|
||
removed.worktree = true;
|
||
} catch (err) { removed.errors.push(`угол: ${err?.message ?? err}`); }
|
||
try {
|
||
runTool(psql, ['-h', c.host, '-p', String(c.port), '-U', c.username, '-d', 'postgres', '-c', `DROP DATABASE IF EXISTS ${dbName}`], {
|
||
env: psqlEnv(c),
|
||
});
|
||
removed.database = true;
|
||
} catch (err) { removed.errors.push(`база: ${err?.message ?? err}`); }
|
||
return removed;
|
||
}
|
||
|
||
// --- принудительная уборка старых углов ---------------------------------------
|
||
//
|
||
// 🔴 Решение владельца: угол старше срока убирается ПРИНУДИТЕЛЬНО, даже если задача ещё
|
||
// не принята. Без этого каждый непринятый прогон навсегда оставлял бы до семи углов с полной
|
||
// установкой зависимостей и семь баз — и затея встала бы сама, по месту на диске.
|
||
// Тихо это делать нельзя: про каждый убранный угол в сводку идёт ОТДЕЛЬНАЯ строка.
|
||
export const DAY_MS = 86_400_000;
|
||
|
||
// 🆕 Долг перед куском 6 (решение владельца 126 от 01.08.2026). Пока владелец не решил
|
||
// судьбу работника кнопкой «продолжить» (кусок 6) или командой `accept` (кусок 7), уборка
|
||
// старше срока его угол не трогает вовсе.
|
||
//
|
||
// 🔴 Список ЗАВЕДЕН ОТ `FINAL_STATUSES` (задача 11, supervisor.mjs) — единственного места,
|
||
// где перечислены все итоговые состояния работника, — а не переписан своими словами рядом.
|
||
// Заведи надзиратель четвёртое итоговое состояние, а этот список останься прежним —
|
||
// уборка снесла бы угол работника, чью судьбу владелец ещё не решил, и он бы об этом
|
||
// не узнал вовсе.
|
||
//
|
||
// `paused` исключён из защиты НАМЕРЕННО (решение владельца, дословно из бумаги задачи 8):
|
||
// пауза — команда самого владельца, он и так знает про этот угол, второй раз спрашивать
|
||
// его не нужно. Исключение названо СПИСКОМ, а не молчаливым вычитанием из головы —
|
||
// проверка `isProtectedFromSweep` ниже держит его поимённо на два статуса.
|
||
const SWEEP_EXCLUDED_FINAL_STATUSES = new Set(['paused']);
|
||
const AWAITING_OWNER_STATUSES = new Set(
|
||
FINAL_STATUSES.filter(status => !SWEEP_EXCLUDED_FINAL_STATUSES.has(status)),
|
||
);
|
||
|
||
export function isProtectedFromSweep(card) {
|
||
return AWAITING_OWNER_STATUSES.has(card?.status) && !card?.resumed_at;
|
||
}
|
||
|
||
export function isWorkspaceStale(card, { now, maxAgeDays }) {
|
||
if (!card?.workspace) return false; // угла нет — убирать нечего
|
||
if (card.workspace_removed_at) return false; // уже убран, второй раз не трогаем
|
||
// 🆕 Долг перед куском 6: работник ждёт слова владельца — принудительная уборка
|
||
// его не трогает, сколько бы дней ни прошло. Убирается такой угол только по слову
|
||
// владельца: командой `accept` (кусок 7) либо явной уборкой. Цена принята владельцем
|
||
// вслух — углы копятся на диске, пока он не скажет «можно».
|
||
if (isProtectedFromSweep(card)) return false;
|
||
// Считаем от конца работника, а если его нет — от начала. Нет ни того, ни другого —
|
||
// не трогаем: угадывать возраст угла нельзя, а лишняя уборка уносит чужую работу.
|
||
const since = Number.isFinite(card.finished_at) ? card.finished_at : card.started_at;
|
||
if (!Number.isFinite(since)) return false;
|
||
return now - since >= maxAgeDays * DAY_MS;
|
||
}
|
||
|
||
export function sweepStaleWorkspaces({
|
||
repoRoot,
|
||
runsRoot = runsDir(),
|
||
maxAgeDays = 7,
|
||
now = Date.now(),
|
||
destroy = destroyWorkspace,
|
||
}) {
|
||
const swept = [];
|
||
if (!existsSync(runsRoot)) return swept;
|
||
for (const runId of readdirSync(runsRoot)) {
|
||
const run = readJson(join(runsRoot, runId, 'run.json'));
|
||
if (!run) continue;
|
||
if (run.status === 'accepted') continue; // принятое уже убрал `cli.mjs accept`
|
||
for (const workerId of run.workers ?? []) {
|
||
const cardPath = join(runsRoot, runId, 'workers', workerId, 'worker.json');
|
||
const card = readJson(cardPath);
|
||
if (!isWorkspaceStale(card, { now, maxAgeDays })) continue;
|
||
|
||
let r;
|
||
try {
|
||
r = destroy({ repoRoot, workspaceRoot: card.workspace, dbName: card.db_name });
|
||
} catch (err) {
|
||
// 🔴 Уборка не имеет права уронить утреннюю сводку: письмо обязано уйти ВСЕГДА.
|
||
// Отметку при неудаче не ставим — завтра попробуем снова, а владелец увидит строку.
|
||
swept.push({
|
||
run_id: runId,
|
||
worker_id: workerId,
|
||
workspace: card.workspace,
|
||
worktree: false,
|
||
database: false,
|
||
errors: [String(err?.message ?? err)],
|
||
line: `Рабочий угол работника ${workerId} (прогон ${runId}) старше ${maxAgeDays} дней, но убрать его не вышло: ${err?.message ?? err}. Место на диске не освободилось.`,
|
||
});
|
||
continue;
|
||
}
|
||
// Отметка в карточке обязательна: без неё на следующее утро тот же угол убирали бы
|
||
// снова и снова, и в сводке каждый день стояла бы одна и та же строка.
|
||
writeJsonAtomic(cardPath, { ...card, workspace_removed_at: now, workspace_removed_reason: 'старше срока, задача не принята' });
|
||
swept.push({
|
||
run_id: runId,
|
||
worker_id: workerId,
|
||
workspace: card.workspace,
|
||
...r,
|
||
line: `Убран рабочий угол работника ${workerId} (прогон ${runId}): задача не принята ${maxAgeDays} дней. Угол — ${r.worktree ? 'убран' : 'НЕ убран'}, база — ${r.database ? 'убрана' : 'НЕ убрана'}${r.errors?.length ? ` (${r.errors.join('; ')})` : ''}.`,
|
||
});
|
||
}
|
||
}
|
||
return swept;
|
||
}
|