ADR 0066 — Читаемое имя папки workspace
Статус
Принят.
Этот ADR пересматривает ADR 0055,
который остаётся принятым и не редактируется. 0055 ввёл projectScopeRoot
как ЕДИНСТВЕННУЮ функцию, выводящую <PROJECT_WORKSPACES_ROOT>/<projectId>, —
общую, потому что permissions.json и область терминала (RN-075)
должны соглашаться в том, где находится папка проекта. Этот документ меняет
то, ЧТО подставляется вместо <projectId>, сохраняя гарантию того, что
два вывода (api и engine) продолжают совпадать.
Контекст
Физическая папка каждого проекта на диске называлась чистым UUID
(<raiz>/3f2b1c8e-0a5d-4f6b-9c1e-2d7a8b3c4d5e/) — тем же projectId, что
идентифицирует строку в базе. Просматривая папку в Finder/Explorer (что
docs/getting-started.md стал позволять, направляя
PROJECT_WORKSPACES_HOST_DIR на реальный путь на диске), пользователь видел
список UUID, не имея способа узнать, какая папка какому проекту соответствует, не открывая
каждую.
Запрос: ЧИТАЕМОЕ имя папки, основанное на slug проекта, с сохранением уникального id. Были рассмотрены два механизма (см. «Рассмотренные альтернативы»); пользователь явно выбрал переименование физической папки вместо symlink, указывающего на нетронутую папку с UUID.
PROJECT_WORKSPACES_ROOT — ОДИН корень на весь экземпляр,
ОБЩИЙ для всех workspaces: в двух workspaces может быть
по проекту со slug api. Один slug не уникален глобально, и
именно это делает «уникальный id рядом с читаемым именем» реальным требованием, а не
капризом.
Решение
1. projects.workspace_dir_name — имя папки, записанное в базу
Новый столбец workspace_dir_name (text, NOT NULL, UNIQUE) в
projects. projectScopeRoot (ADR 0055) начинает получать это значение и
БОЛЬШЕ НИКОГДА голый projectId — оба потребителя, которых защищает функция
(permissions.json и область терминала), продолжают выводить ОДИН И ТОТ ЖЕ
корень, потому что продолжают вызывать одну и ту же функцию, только с
правильным аргументом.
2. Новый проект рождается с <slug>-<8 chars do id>; старый проект сохраняет UUID
workspaceDirNameFor(id, slug) (чистая, в
project-workspaces-root.ts) составляет имя — то же соглашение из 8
символов, которое apps/web/src/lib/session-label.ts уже использует для метки
сессии. CreateProjectUseCase начал генерировать id в КОДЕ
(crypto.randomUUID(), а не defaultRandom() Postgres): имени
папки нужен id ДО INSERT, и есть лишь два способа получить его —
сгенерировать id вне базы или сделать два обращения (вставить, прочитать сгенерированный id,
UPDATE). Первый проще и имеет прецедент в самом коде
(token-factory.ts, emitir-sessao.use-case.ts).
Проект, созданный ДО этой миграции, сохранил физическую папку, которая у него
уже была: миграция ставит workspace_dir_name = id для каждой существующей строки
— то же значение, что уже было правдой на диске, — и никогда не переименовывает
ни одного каталога. Переименование рабочего дерева, возможно открытого активным
агентом, — реальный риск без необходимости: старый проект продолжает
работать ровно так, как работал.
3. Имя ЗАМОРАЖИВАЕТСЯ при создании и никогда не пересчитывается
UpdateProjectUseCase позволяет редактировать slug позже — и это не трогает
workspace_dir_name. Альтернатива (пересчитывать имя при смене slug)
потребовала бы перемещать каталог с рабочим деревом и worktrees агентов,
возможно используемыми, а ценность «заморозки» как раз в том, чтобы не
обрабатывать этот случай: физическая папка определяется один раз и навсегда.
4. Trigger BEFORE INSERT — страховочная сеть, а не основной путь
CreateProjectUseCase всегда записывает workspace_dir_name явно,
ДО INSERT, — это реальный путь. Trigger
(projects_workspace_dir_name_default_trg) применяет ТОТ ЖЕ fallback
(id::text) для любого INSERT, пришедшего без поля. Решение
добавить trigger — первый за всю историю migrations
продукта — не входило в исходный запрос, и стоит зафиксировать причину: более
пятидесяти тестовых spec api вставляют projects прямо в
базу, не зная (и не нуждаясь знать) понятия имени папки.
Без trigger сделать столбец NOT NULL сломало бы их все, а
альтернатива — отредактировать пятьдесят файлов, выдумывая правдоподобный workspace_dir_name
в каждом и заботясь о ГЛОБАЛЬНОЙ уникальности между ними, — обменяла бы
один долг на другой, больший, не доказав ничего сверх о самой функциональности.
Trigger никогда не перезаписывает ненулевое значение: тот, кто записывает явно,
сохраняет контроль.
5. Engine читает ТОТ ЖЕ столбец и никогда не вычисляет имя заново
Engine.Projects.Project.workspace_dir_name/1 запрашивает столбец
напрямую. Engine.Actions.Workspace.workspace_dir/1 (который раньше был
Path.join(root, project_id), чистой функцией) начал определять имя
этим запросом, с fallback на голый project_id, когда запрос
не находит строку, когда у project_id нет формы UUID или когда
запрос падает по любой причине (широкие rescue/catch — деградация
всегда предпочтительнее распространения сбоя определения ПУТИ тому, кому
нужен был лишь каталог).
Это то место, которое гарантирует, что инвариант ADR 0055 переживает изменение: api и engine не реализуют две формулы, которые должны совпадать случайно, — обе читают ОДНУ строку ОДНОЙ базы. Если однажды они разошлись бы, то потому, что одна перестала запрашивать, а не потому, что формула изменилась с одной стороны.
workspace_dir/1 не горячий путь: цикл инструментов dev agent
(search_workspace, write_file, read_file) уже получает
ГОТОВЫЙ ctx[:workspace_root] — определённый один раз, когда
Engine.Dev.WorktreeManager.create/3 собирает worktree агента, — и лишь
обращается к workspace_dir/1 как к fallback для вызывающих, которые сегодня не
проходят через этот ctx (worktree_manager внутренне,
instruction_files, TerminalExecutor через ensure_remoto). Ни один
из них не выполняется на каждый вызов инструмента.
Engine.Dev.WorktreeCleanup (периодическая обрезка осиротевших worktrees) был
исключением, потребовавшим собственного дизайна: до RN-109 имя папки БЫЛО
project_id, и сканирование диска (File.ls(root)) с трактовкой каждой записи как
id было допустимым прочтением. С читаемым именем папки папка перестала
быть id, и вернуть одно к другому без запроса невозможно.
Исправление меняет источник итерации: вместо сканирования диска оно запрашивает
Project.all_workspace_dirs/0 (все {id, workspace_dir_name} одним
запросом) и использует разрешённый work_dir напрямую — ни эта функция, ни
WorktreeManager не делают второго запроса на проект, чтобы снова узнать
имя.
Последствия
Принятые. Один новый столбец, один новый trigger (первый в продукте — см.
пункт 4), две чистые функции (workspaceDirNameFor в api,
workspace_dir_name/1 в engine) и расширение Workspace.workspace_dir
из чистой в DB-aware-с-fallback. WorktreeCleanup сменил источник
итерации с диска на базу — та же цена в ОДИН запрос, никогда не на
проект.
Вне охвата. Переименование папки существующего проекта в читаемый формат не реализовано и не запрашивалось: это потребовало бы перемещать рабочее дерево, возможно открытое активным агентом, а ценность читаемого имени косметическая, она не стоит риска. Кто хочет читаемую папку, создаёт новый проект.
Что НЕ меняется. RN-075 (область терминала) и RN-092 (чтение кода, вкладка Code) продолжают указывать на ТУ ЖЕ папку, которую engine действительно использует, — именно это согласие, а не форму имени, защищают оба ADR.
Рассмотренные альтернативы
- Symlink, указывающий на нетронутую папку с UUID — меньший риск (физическое рабочее дерево никогда не меняет места, лишь получает читаемый ярлык рядом). Это была первоначальная рекомендация. Пользователь явно выбрал другой вариант: по-настоящему переименовать папку, без двух записей на проект на диске.
- Пересчитывать имя при каждой смене slug — отвергнуто: это перемещало бы потенциально используемый каталог ради косметического выигрыша. «Заморожено при создании» исключает целую категорию ошибок.
- Столбец
NOT NULLбез trigger, с редактированием пятидесяти тестовых spec — отвергнуто из-за цены/риска: пятьдесят файлов, отредактированных вручную, чтобы выдумать правдоподобные имена, с дополнительной заботой о ГЛОБАЛЬНОЙ уникальности между ними, не доказывают о функциональности ничего сверх того, что trigger уже доказывает тремя тестами.