Перейти к основному содержимому

ADR 0148 — Артефакты агентов, спроецированные в файлы, в папку docs/

Контекст​

Каждый артефакт, который производят агенты, — brief Criativo, бизнес-правила PO, диаграмма C4 и маршрутизация модулей Arquiteto, решения шести разговорных агентов — живёт исключительно в event log. Engine.Harness.Tools.EmitArtifact (apps/engine/lib/engine/harness/tools/emit_artifact.ex:3-5) говорит прямо: "não há tabela de artefatos". Артефакт ЕСТЬ событие artifact.<tipo>, неизменяемое, с автором и seq.

Это правильное решение для ИСТОЧНИКА, и здесь его не трогают. Проблема в другом: эта память не попадает ни в одно место, которое открывает человек. Она читаема через timeline сессии, по строке за раз, внутри продукта. Кто открывает папку проекта в редакторе — а именно там будет код, — не находит ничего из рассуждения, которое произвело этот код.

Дыра стала больше с решением отложить git (отложенное provisioning, в отдельном PR): между созданием проекта и handoff Arquiteto к Dev Lead проект существует вообще без репозитория. В этом промежутке всё, что продукт накопил о проекте, лежит в строках базы, а папка пользователя пуста.

В продукте есть точный прецедент решения этого, и он уже обсуждён и принят: граф Neo4j (ADR 0099/ADR 0101), который заявленно является производной памятью от event log — никогда не источником, его можно выбросить и пересобрать. GraphProjector (apps/api/src/application/graph-projection/graph-projector.ts) осушает outbox_events под собственным aggregate_type и пишет во внешнее место назначения, деградируя, но никогда не роняя того, кто его питает.

Вопрос этого решения не в том, должна ли существовать папка, — владелец продукта попросил буквально "criar apenas uma pasta por ora chamada docs, onde os artefatos estarão de cada um dos agentes". Вопрос — каким механизмом и что это обязывает гарантировать.

Решение​

Папка docs/ — это ПРОЕКЦИЯ event log, в том же строгом смысле, который ADR 0101 дал графу. Источником остаётся событие; папку можно удалить целиком и пересобрать; и ничто в продукте не читает из неё, чтобы что-либо решить. Она существует, чтобы её открывал человек.

Из этого следует всё остальное.

1. Механизм — как у GraphProjector, деталь за деталью​

ArtifactProjector (apps/api/src/application/artifact-projection/artifact-projector.ts) копирует форму, которая уже решила эту задачу: собственный poller (setInterval с .unref(), очищаемый в onModuleDestroy), публичный drainOnce(), независимый от таймера (точка входа тестов), флаг draining против перекрывающихся циклов, партия по 50 и markProcessed только после успеха.

Это не переиспользование кода, а переиспользование дизайна — два проектора пишут в места назначения, не имеющие ничего общего. Копирование формы — то, что позволяет читателю, знающему один, понять другой, не перечитывая.

2. Собственный aggregate_type, artifact_projection​

AppendSessionEventUseCase пишет третью строку outbox, в ТОЙ ЖЕ транзакции, для типов из ARTIFACT_PROJECTABLE_EVENT_TYPES. aggregate_type новый по той же причине, что и у графа (задокументировано в domain/graph/graph-projection-events.ts:7-26): Engine.Outbox.Drain.run_once/0 осушает только session, task и container, а строку 'session', которая есть у каждого события, он помечает processed_at каждые ~2 с. Читать тот же aggregate_type значило бы соревноваться с осушителем engine и почти всегда проигрывать.

Одно намеренное отличие от графа: aggregateId — это проект, а не сессия. Папка принадлежит проекту, а исходное событие несёт только sessionId — запись projectId здесь избавляет проектор от запроса на каждый артефакт, чтобы узнать то, что use case уже держал в руках.

3. Список РАЗРЕШЁННЫХ и четыре типа, намеренно оставленные вне​

ARTIFACT_PROJECTABLE_EVENT_TYPES (apps/api/src/domain/artifacts/artifact-projection-events.ts) — это список разрешённых, а не исключённых: новый тип артефакта рождается вне проекции и входит, когда кто-то решит, что он является документом, — а не появляется сам в папке, которую читает пользователь.

Вне остаются qa_verdict, secops_verdict, task_blocked и infra_delegation_files. Это операционные исходы, а не документы продукта: вердикт gate на каждую task, в проекте с десятками tasks, заполнил бы docs/qa-lead/ файлами, которые никто не открывает, и папка потеряла бы свойство, делающее её полезной, — помещаться в один взгляд. Они остаются в event log, который является источником, и остаются в timeline.

4. По агенту, и версионируемое перезаписывается​

Раскладка — docs/<agente>/<arquivo>.md. Навигация по папке отвечает на «что произвёл Arquiteto?», ничего не открывая.

Четыре версионируемых типа (module_map, module_routing, project_image, c4_diagram — те, что читаются сведением к наибольшему version, см. obter-container-do-projeto.use-case.ts:22-25) всегда пишут в один и тот же файл, перезаписывая: папка показывает ДЕЙСТВУЮЩИЙ, а вся история остаётся в event log. Проецировать их как файл на версию дало бы c4-diagram-v1.md, c4-diagram-v2.md и заставило бы открывающего выяснять, какой действует, — ровно ту работу, от которой избавляет сведение по version.

Остальные по природе только дополняются (решение не заменяет предыдущее: оба произошли), и каждое становится собственным файлом, с seq события в имени. seq не имеет пропусков в пределах сессии и уже упорядочивает event log, поэтому это разрешение ничьей, не требующее нового состояния, — а без него два артефакта одного типа с одним заголовком породили бы один и тот же файл, и второй стёр бы первый: единственный способ, которым эта проекция могла бы потерять информацию, имеющуюся в источнике.

5. Два барьера против файловой системы, и второй не избыточен​

Имя файла выводится из заголовка, написанного моделью, — недоверенный ввод во всём смысле термина. Защита не в том, чтобы экранировать пришедшее, а в том, чтобы вывести новое имя из закрытого алфавита: у slugDeArquivo нет пути возврата, на котором выжил бы разделитель, и он возвращает null, когда ничего не остаётся (пустое имя столкнулось бы со всеми другими пустыми именами).

FsArtifactFileStore проверяет снова, через relative(), перед открытием файла. Это не паранойя: они отвечают на разные вопросы — первый «какое имя я из этого вывожу», второй «что я на самом деле открою». Полагаться только на первый значило бы сделать безопасность этой записи зависимой от того, что ни одно будущее изменение slug никогда не пропустит разделитель, — а это ровно та форма изоляции, которую ADR 0130 отвергает принципиально: та, что зависит от корректности вызывающего.

6. В режиме runner папка отклоняется в управляемый корень​

pastaDeArtefatosDoProjeto (apps/api/src/infrastructure/filesystem/project-workspaces-root.ts) — сестра permissionsFilePath и делает то же отклонение, по той же физической причине: пишет api, изнутри своего контейнера, а проект runner намеренно без bind-mount. Канал runner (exec/exec_result) передаёт только КОМАНДУ — примитива записи содержимого в файл нет, — так что даже делегирование записи не помогло бы без изобретения нового механизма.

7. Запись НЕ проходит через proposed_action​

И это не упущение. proposed_action — источник каждого внешнего эффекта агента, просящего действовать. Здесь нет агента, который что-то просит: артефакт уже выпущен и уже лежит в event log, когда этот класс выполняется. То, что происходит дальше, — это система, материализующая по производному пути читаемую копию того, что уже является фактом.

Пропуск проекции через очередь одобрения дал бы обратный задуманному эффект: заполнил бы очередь рутинными решениями — по одному на артефакт — и разъел бы именно тот потолок, который придаёт смысл клику на действиях, которые являются внешним эффектом.

Последствия​

У пользователя в папке проекта появляется папка docs/, организованная по агентам, с рассуждением, которое произвело код. Она появляется до того, как существует git (отложенное provisioning), — именно в тот промежуток, когда сегодня на диске ничего нет.

Проекция никогда не роняет источник. Ни один throw не выходит из drainOnce: каждый элемент падает изолированно, логируется и остаётся processed_at IS NULL для следующего цикла. Полный диск, отказ в доступе или недостижимая папка не могут помешать артефакту быть ВЫПУЩЕННЫМ. У outbox_events нет ни attempts, ни dead-letter — retry неявный и бесконечный, ровно как у графа.

Намеренное отличие от GraphProjector: здесь нет аналога GraphUnavailableError, останавливающего всю партию. Отказ записи обычно относится к ЭЛЕМЕНТУ (странное имя, проект, чья папка исчезла), а не к месту назначения; остановка партии из-за одного элемента заблокировала бы все, что стоят за ним.

Событие, исчезнувшее из event log, помечается обработанным, а не повторяется вечно — повторять то, чего больше нет, не дало бы никакого эффекта, а строка оставалась бы в очереди бесконечно.

Объявленная цена 1: в режиме runner docs/ не лежит рядом с кодом. Кто откроет папку проекта на своей машине, её не найдёт; она находится в <PROJECT_WORKSPACES_ROOT>/<workspace_dir_name>/docs/. Это РЕАЛЬНАЯ потеря в этом режиме, потому что папка существует именно для того, чтобы её открывали вместе с кодом. Она избегает худшей альтернативы: проекция просто не происходила бы или молча падала бы на каждом артефакте.

Объявленная цена 2: Markdown обобщённый. У файла есть заголовок (тип, агент, когда, seq) и payload как блок JSON. Рендерера по типу нет — это было бы тринадцать мест, которые устаревали бы при изменении схемы, а эта папка должна доставлять читаемое и прослеживаемое содержимое, а не вёрстку. Более богатый формат по типу — отдельное решение, когда появится реальное чтение, которое его попросит.

Объявленная цена 3: папка может расходиться с event log между циклами, а после устойчивого отказа может оставаться неполной бесконечно, без какого-либо предупреждения. Это та же цена, которую уже принимает граф, и она терпима по той же причине: источник цел, а проекция пересобираема. Сегодня нет команды перепроецирования — удаление docs/ её не пересобирает, потому что строки outbox уже помечены. Это тот же пробел, который BRB-018 уже фиксирует для графа, теперь со вторым потребителем.

Чего этот ADR НЕ касается. decide.ts и абсолютных потолков; proposed_action как источника каждого внешнего эффекта агента (см. пункт 7 — проекция к ним не относится); неизменяемости event log; схем артефактов engine, которые ничуть не меняются; и затвора образа (ADR 0135).