ADR 0115 — Pull модели из Hugging Face: собственная таблица, подтверждение в два шага и allowlist официальных издателей — никогда proposed_actions
- Статус: Принят
- Дата: 2026-08-26
- Контекст: позволяет owner/maintainer workspace искать в Hugging Face Hub и делать pull GGUF-модели в локальный демон Ollama из Configurações do Projeto/Workspace, закрывая пробел, на который у пути локальной LLM не было ответа (получить модель, которой нет среди немногих встроенных в entrypoint compose)
- Ссылки (без редактирования): ADR 0042 («никогда не активировать автоматически обнаруженную модель» — правило, которое это решение применяет к новой поверхности), ADR 0100 (Ollama как локальный демон, принимающий pull), исходный ADR FASE 1, который ввёл
proposed_actionsиdecide()
Контекст
У продукта уже есть конвейер «агент предлагает, человек решает»:
proposed_actions, permissions.json/decide() и три экрана, которые читают
из него (Aprovações, чат сессии, Insights). Pull модели из
Hugging Face в Ollama на поверхности казался тем, что мог бы туда поместиться, — это
действие с реальным внешним эффектом (загрузка в несколько гигабайт, затем
активация модели, на которую другие агенты могут тратить), и продукт
хочет обдуманного подтверждения в два шага перед его выполнением, а не одного
клика.
Не помещается, и несоответствие видно только тогда, когда прослеживаешь, вокруг чего конвейер был на самом деле построен.
Решение
huggingface_model_pull_requests — новая маленькая таблица, а не proposed_actions
Прежде чем что-либо строить, механизм проследил четыре вещи, которых
требует proposed_actions, и проверил, действуют ли они здесь:
proposed_actions.session_id—NOT NULL(FK наsessions, cascade). У этой функциональности нет сессии — она вызывается из Configurações do Projeto/Workspace, а не из хода агента внутри сессии. Втиснуть её значило бы сфабриковать сессию для действия, не имеющего к ней отношения.actor_kind/actor_idфиксируют, кто предложил (user/agent/system), аresolved_policy— это всегда вывод запускаdecide()противpermissions.json/agent_autonomy— этап разрешения политики, построенный для уровней автономии (auto_approve/require_approval/deny) над поведением агента. Здесь актор, запрашивающий pull, и есть человек — уже ограниченный@RequireRole('maintainer')на маршруте (apps/api/src/interfaces/http/llm/huggingface-models.controller.ts:63-72), — и нет агента, за которым надо надзирать. Пропуск черезdecide()применил бы семантику уровня автономии к случаю, у которого нет уровня автономии: maintainer, повторно подтверждающий собственный запрос.action_typeпроверяется по закрытому объединениюActionTypeвdecide.ts, и правило самого CLAUDE.md для этого объединения прямолинейно: новому типу нужна фраза вapps/web/src/lib/aprovacoes.ts, и он появляется на трёх экранах, построенных для надзора за агентами. Сфабриковать там запись для «человек нажал подтвердить в собственном действии в Configurações» значило бы выставить её в UI, построенном для ответа на другой вопрос («что агент просит меня одобрить»), ради действия, которое никогда этот вопрос не задаёт.- «Второе явное подтверждение», которое нужно этой функциональности, — это барьер UX/безопасности над прямым действием человека, а не барьер одобрения предложения агента — другая форма «вы уверены», а не та же.
Учитывая это, загнать это в proposed_actions означало бы сгибать
механизм, построенный для надзора за недоверенным агентом, над случаем без агента —
связывая функциональность с семантикой
permissions.json/agent_autonomy, которая ей не нужна и никогда не
проектировалась для ответа на неё.
Новая таблица, huggingface_model_pull_requests (миграция
0053_optimal_sauron.sql): id, workspace_id (FK workspaces, cascade),
requested_by (FK users), repo_id, estimated_size_bytes (nullable —
никогда не угадывается, когда Hub его не публикует), status
(huggingface_model_pull_status: pending_confirmation → confirmed →
pulling → active | failed), confirmed_at, failed_reason (всегда
с префиксом из словаря источников ADR 0020 — infra/modelo/código/
política), метки времени и индекс по workspace_id
(apps/api/src/db/schema.ts:2371-2393).
Подтверждение в два шага ЕСТЬ машина состояний, а не лишний флаг
RequestModelPullUseCase
(apps/api/src/application/use-cases/llm/huggingface/request-model-pull.use-case.ts:23)
создаёт строку в pending_confirmation и больше ничего не делает — ни одного сетевого
вызова, ни одного побочного эффекта. Только отдельный вызов
ConfirmModelPullUseCase
(.../huggingface/confirm-model-pull.use-case.ts:45), достижимый
исключительно через POST .../pull-requests/:id/confirm, переводит строку в
confirmed → pulling и действительно вызывает OllamaProvider.pullModel. Нет ни
третьего флага, ни колонки, фиксирующей «это было подтверждено», — сам
переход pending_confirmation → confirmed и есть второе подтверждение, а
ConfirmModelPullUseCase отказывается (409, ConflictException) выполнять его дважды:
строку, которая больше не в pending_confirmation, нельзя подтвердить
снова.
repoId передаётся в теле запроса, а не в маршруте
Реальный формат id в Hub — <publisher>/<model> (напр.:
meta-llama/Llama-3.1-8B-Instruct-GGUF) — / внутри того, что было бы
сегментом пути :repoId, ломает сопоставление маршрута с одним сегментом в
Nest/Express. POST .../pull-requests получает его в теле
(RequestModelPullDto), тот же выбор, что уже сделан для путей файлов в
apps/api/src/interfaces/http/git/code.controller.ts по той же причине.
Pull выполняется синхронно внутри подтверждающего HTTP-запроса
ConfirmModelPullUseCase ждёт всю загрузку, прежде чем вернуться. У api
нет исполнителя фоновых jobs, достижимого из неё, — Oban
живёт в engine на Elixir, и синхронного командного канала из api к нему
для этого нет. Запустить pull и сразу вернуться означало бы потерять
необработанное исключение в момент возврата вызывающей функции, и
markFailed в catch use case никогда бы не выполнился.
GET .../pull-requests/:id существует для тех, кто не хочет держать это соединение
открытым (обратный прокси или шлюз часто истекают по времени раньше, чем
завершится загрузка в несколько гигабайт), — сам pull продолжает выполняться на
сервере, опрашивает его кто-то или нет.
Allowlist официальных издателей — правило ADR 0042, применённое к новому знаку
HUGGINFACE_OFFICIAL_PUBLISHERS
(apps/api/src/domain/llm/huggingface-official-publishers.ts:17-26) — это
короткий, вручную отобранный и чувствительный к регистру список организаций Hub, признанных
фактическим производителем того, что они публикуют (meta-llama, google,
mistralai, microsoft, Qwen, deepseek-ai, openai, nvidia), — та же
позиция «никогда не активировать автоматически обнаруженную модель, курирование всегда
ручное», которую ADR 0042 уже держит для каталога LLM, применённая здесь к знаку
«официальный» вместо активации. isOfficialPublisher сравнивает сегмент
издателя в repoId точно, без нормализации регистра: сам Hub
чувствителен к регистру (Qwen, а не qwen), а нормализация позволила бы перезаливке
в нижнем регистре — которая не является официальной организацией — случайно получить знак.
SearchHuggingFaceModelsUseCase
(.../huggingface/search-huggingface-models.use-case.ts:28) фильтрует до
результатов official: true по умолчанию; includeCommunity: true возвращает
всех издателей, каждый помечен official/нет, и никогда не скрывает различие.
Сторона web (apps/web/src/components/HuggingFaceModelBrowser.tsx:190-203)
рисует этот выбор как переключатель, выключенный по умолчанию, плюс постоянный
явный баннер-предупреждение в тоне опасности (Alert tone="danger"), который появляется
только пока показываются результаты сообщества, — opt-in и
предупреждение о риске — один и тот же жест, никогда не настройка, которую кто-то включает
однажды и забывает, что она включена.
Последствия
Отклонение формата маршрута, которое должен соблюдать вызывающий. repoId — это
поле тела, никогда не интерполируется в путь URL, — задокументировано на маршруте и
повторено здесь, чтобы будущая правка не «упростила» его обратно в
параметр пути и не вернула дефект / внутри сегмента.
Реальный и объявленный срез: без очереди. Синхронный pull — намеренный обмен, а не небрежность, — отмечен в самом комментарии use case как достойный ADR, который этот ADR закрывает. Настоящая фоновая очередь в api — естественный следующий шаг, если объём pull когда-либо оправдает дополнительную сложность; ничто здесь не мешает построить её позже.
Модели, загруженные через pull, получают цену и активацию так же, как
любая другая локальная модель Ollama. ConfirmModelPullUseCase вставляет в
models с ценой 0/manualPricing: false (повторяя трактовку, которую
sync-model-catalog.use-case.ts даёт любой другой локальной записи Ollama) и
активирует модель только для запросившего workspace через
WorkspaceModelRepository.setActive. Это не нарушение правила «никогда не
активировать автоматически обнаруженную модель» из ADR 0042 — то правило
касается фоновой синхронизации, обнаруживающей то, о чём никто не просил; здесь активация —
прямое и прослеживаемое следствие того, что человек дважды запросил
эту конкретную модель (запрос, затем подтверждение).
Отображаемое имя — сырой repoId. displayName загруженной модели — это
то, как её назвал Hub (напр.: meta-llama/Llama-3.1-8B-Instruct-GGUF), никогда не
переформатированное — оставлено как вопрос продукта/UX на потом, здесь не решён.
Ответ поиска Hub сегодня не несёт оценки размера.
Оценка, показываемая при подтверждении, берётся только из того, что клиент уже знал, когда
вызвал POST .../pull-requests (estimatedSizeBytes, всегда null, когда
не сообщено), — endpoint поиска её не заполняет, поэтому экран
подтверждения обычно будет показывать «não informado pelo Hub» вместо реального
числа. Это согласуется с объявленным правилом продукта никогда не фабриковать
оценку; это не баг, а реальное текущее ограничение того, что открывает ответ поиска
Hub.
Рассмотренные альтернативы
- Переиспользовать
proposed_actions. Отклонено по причинам, прослеженным в Решении выше: нет сессии, к которой её привязать, этап разрешения политики, построенный для уровней автономии агента, неприменим к запросу самого человека, закрытый словарьActionType, чьи UI-поверхности построены для другого вопроса, и «второе подтверждение», которое является барьером безопасности прямого действия, а не барьером одобрения/отклонения чужого предложения. - Булев флаг
confirmedв одношаговом запросе вместо машины состояний. Отклонено: форма из двух запросов (create, а затем отдельный вызовconfirm, ограниченный ролью на обоих маршрутах) — это то, что делает подтверждение явным и пересматриваемым: флаг, переключённый в том же запросе, что и создание, схлопнул бы два шага, которые продукт явно хочет разделить, а отказConfirmModelPullUseCaseподтверждать повторно прямо вытекает из моделирования этого как состояний, а не флага. - Сопоставление издателя без учёта регистра для официального знака. Отклонено: сам Hub трактует имена издателей как чувствительные к регистру, а нормализация отдала бы знак «официальный» перезаливке имени официальной организации в нижнем регистре — ровно тот тип имени, который менее аккуратный сторонний издатель выбрал бы намеренно.