ADR 0113 — Локальная папка, приложенная через браузер, становится chunks.scope = 'local', читаемым БРАУЗЕРОМ, никогда путём хоста
Статус
Принят.
Ссылается (без правки): ADR 0072, ADR 0079, ADR 0080, ADR 0107, ADR 0108.
Контекст
Запрос
Позволить пользователю приложить к проекту локальную папку со своей машины
как СПРАВОЧНЫЙ материал ТОЛЬКО ДЛЯ ЧТЕНИЯ для агентов — явно БЕЗ запуска CLI
brabo-runner (ADR 0103). Это НЕ исполнение проекта и не workspace:
execution_mode: runner (ADR 0104) существует именно потому, что исполнению
нужен РЕАЛЬНЫЙ путь ХОСТА, куда контейнер/engine может маршрутизировать
команды, подтверждённый runner, который подключается и его проверяет
(RN-423). Эта фича ближе к «приложить файлы к разговору», чем к
«смонтировать workspace».
Что на самом деле отвергли ADR 0072 и ADR 0107, и почему здесь иначе
Оба решения касаются разрешения абсолютного ПУТИ ХОСТА из браузера. ADR 0072 отверг селектор папки при создании проекта, потому что «селектор требовал бы, чтобы api перечисляла браузеру файловую систему КОНТЕЙНЕРА», — новая поверхность лишь ради эргономики, да ещё и файловая система не того контекста. ADR 0107 решил навигацию по папке, маршрутизируя чтение через Runner (процесс с привилегиями самого пользователя на его собственной машине, а не api), именно потому, что api никогда не видит реальную файловую систему пользователя.
Оба касаются того, ОТКУДА БЕРЁТСЯ ПУТЬ НА ДИСКЕ ПОЛЬЗОВАТЕЛЯ, и оба важны,
потому что путь питает либо создание проекта (workspace_path), либо
выполнение команды (cwd у TerminalExecutor) — контексты, где НЕВЕРНЫЙ путь
— проблема безопасности и корректности: он решает, против чего агент может
выполнять.
Эта фича никогда не разрешает путь хоста. <input webkitdirectory> браузера
возвращает объекты File; код читает их содержимое (File.text()) и
относительный путь внутри выбранной папки (File.webkitRelativePath) —
никогда абсолютный путь, потому что File API браузера не раскрывает его, по
замыслу, ни одному сайту. По сети идёт ТЕКСТ, который браузер уже имел законное
право читать, — ровно та же модель доверия, что у любой обычной загрузки
через <input type="file"> на любом сайте. Нет пути, который нужно
проверять, изолировать или маршрутизировать на runner, потому что пути нет
вовсе. Строить для этого ретранслятор в стиле runner значило бы решать
проблему, которой у этой фичи нет.
Почему переиспользовать конвейер RAG, а не параллельный механизм
У продукта уже есть работающий конвейер chunking/embedding/гибридного поиска
(ADR 0079/0080): таблица chunks, RagEmbeddingService,
chunkText/chunkMarkdownDocument, ChunkRepository.searchByVector/
searchByLexicalQuery и отрисовка цитат вкладки Chat RAG (RagCitationCard,
origemDoChunk). Локально приложенный файл структурно имеет ту же форму, что
и проиндексированный файл документации/ADR: путь плюс содержимое, которое
должно быть доступно поиску и цитированию. Построение второго параллельного
хранилища «приложенных файлов» и второго UI цитат продублировало бы конвейер,
который уже делает ровно эту работу, без иной причины, кроме того, что
источник байтов — загрузка из браузера, а не blob git.
Решение
chunks.scope получает четвёртое значение, 'local', добавленное через
ALTER TYPE ... ADD VALUE (миграция 0052) — аддитивно, без миграции данных,
без нового CHECK: существующие ограничения
chunks_session_id_casa_com_escopo/chunks_source_path_casa_com_escopo
(миграция 0045) уже записаны как «это session или нет», поэтому local
попадает на ту же сторону, что docs/adr (есть source_path, нет
session_id), с нулём изменений схемы, кроме значения enum.
IndexLocalFolderUseCase близко отражает IndexProjectDocsUseCase: делит
каждый файл на chunks (с учётом Markdown через chunkMarkdownDocument для
.md/.mdx, chunkText для прочих), вызывает тот же RagEmbeddingService,
записывает chunks с embedding: null, когда embedding падает (тот же образец
честности, что уже используют docs/adr — RN-233), и делает полный
deleteByScope(projectId, 'local') + пересоздание при каждом вызове.
origemDoChunk потребовал НОЛЬ изменений: chunks local несут sourcePath
ровно как docs/adr, поэтому попадают в существующую ветку kind: 'file' —
UI цитат их уже обрабатывает.
Отказ сразу, никогда молчаливое усечение. В отличие от docs/adr
(фоновое сканирование, на которое никто не смотрит), приложение папки — это
однократный жест пользователя с селектором папки перед ним.
IndexLocalFolderUseCase ОТКЛОНЯЕТ (400) всю загрузку целиком, когда
совокупные потолки превышены (число файлов, сумма байт), — тот, кто нажал
«Anexar», смотрит на экран и может выбрать папку поменьше. Отдельный слишком
большой файл или файл с нераспознанным расширением лишь ПРОПУСКАЕТСЯ
(filesSkipped), никогда не роняет партию — то же различие, которое
IndexProjectDocsUseCase уже делает неявно, фильтруя только .md.
Никогда не связан с общим «Reindexar agora». ReindexProjectUseCase
перечитывает из источника, к которому СЕРВЕР может вернуться, — репозитория
проекта (docs/adr) и event log (session). У области local такого
источника нет: текст живёт только в браузере того, кто его приложил, а сервер
никогда не сохранял путь хоста (его не существует). Вызов
deleteByScope(projectId, 'local') общей кнопкой переиндексации стёр бы
приложенный пользователем справочный материал без возможности его воссоздать
— повторная загрузка папки — ЕДИНСТВЕННЫЙ механизм ресинхронизации, и это
намеренно отдельная кнопка (POST .../rag/local, maintainer, та же роль,
что у reindex, потому что он тоже задействует provider embedding и заменяет
проиндексированное). Оба use case несут явный комментарий в коде, указывающий
друг на друга и на этот ADR, чтобы будущее «исправление» не связало их и не
стёрло молча справочный материал пользователей одним несвязанным кликом.
Покрытие говорит правду о том, чем local на самом деле является.
RagCoverage.local — НОВАЯ форма (RagLocalCoverage), а не переиспользование
RagFileCoverage: нет «всего файлов в репозитории», с чем сравнивать, —
у загрузки из браузера нет репозитория для повторного сканирования. Она
сообщает, что проиндексировано СЕЙЧАС (filesIndexed, folderName,
lastAttachedAt). lastAttachedAt — ЕДИНСТВЕННОЕ объявленное исключение из
правила продукта «никогда не показывать угаданную метку времени»
(RN-237/ADR 0080): это реальный MAX(chunks.created_at) по области, а не
вычисленное или оценочное значение, — та же дисциплина честности, применённая
к области, где реальная метка времени случайно имеет ответ.
Чего этот ADR НЕ делает
- Не трогает
execution_mode/workspace_path/runner. Они остаются ровно такими, как описывают ADR 0072/0104/0107: как ИСПОЛНЯЕТСЯ код проекта. Эта фича добавляет справочный материал в индекс RAG; у неё нет мнения о том, где живёт или работает код проекта. - Не добавляет пятое понятие «приложенных файлов». Никакой новой таблицы,
никакого параллельного формата цитат —
local— значение того жеChunkScope, на котором уже говорит остальной конвейер. - Не индексирует репозиторий самого проекта второй раз.
docs/adrпо-прежнему читаются из git-репозитория проекта черезReadProjectCodeUseCase;local— намеренно ОТДЕЛЬНЫЙ источник, для материала, не имеющего ничего общего с репозиторием самого проекта (спецификация, эталонная реализация, документация конкурента — что угодно, что пользователь хочет сделать доступным поиску агента).
Последствия
- Вторая загрузка заменяет первую целиком. Нет «добавить к
приложенному» —
IndexLocalFolderUseCaseстирает всю областьlocalперед записью новой. Это ТА ЖЕ дисциплина полной идемпотентной пересборки, которой уже следуютdocs/adr/session(RN-231), применённая согласованно, а не изобретённая специально для этой области. - Бинарные и слишком большие файлы молча отсутствуют в поиске, объявленные
через
filesSkipped. Allowlist расширений (RAG_LOCAL_ALLOWED_EXTENSIONS) — реальное ограничение содержимого, а не просто отделка UX — сервер навязывает его независимо от того, что предварительно отфильтровал браузер, потому что фильтр на стороне клиента — удобство, а не гарантия. - Граница доверия — ровно граница обычной загрузки файла. Без изоляции,
без allowlist путей, без тикета runner — потому что в этой фиче пути нет
вовсе, только байты, которые у браузера уже были. Кто может вызвать
POST .../rag/local(рольmaintainer), может положить произвольный текст в поисковый индекс проекта; это та же власть, которую маршрут уже требует дляreindex.
Рассмотренные альтернативы
- Маршрутизировать чтение через Runner (ADR 0103/0107), как навигацию по папке. Отвергнуто: Runner существует, чтобы дать ENGINE реальный путь хоста, против которого выполнять команды, на машине, которую пользователь явно настроил с CLI. Эта фича явно задумана работать БЕЗ CLI, для справочного материала только для чтения — маршрутизация через Runner сделала бы CLI требованием, которого эта фича задумана не иметь.
- Новая таблица
attachments, независимая отchunks. Отвергнуто: продублировала бы chunking, embedding, гибридный поиск и отрисовку цитат без какой-либо пользы — форма «путь + содержимое, доступно поиску, цитируемо» — ровно то, что уже моделируетchunks. - Связать
localсReindexProjectUseCase. Отвергнуто: для этой области на стороне сервера нечего перечитывать; связывание заставило бы общую кнопку переиндексации молча стирать приложенный пользователями справочный материал, без возможности его вернуть.