← Назад Harupa
2026-06-05RU EN UA BY PL

My Architect, часть 2: архитектура без базы данных

Это вторая статья серии про My Architect. В части 1 я рассказал, зачем агенту память между сессиями и как один проект обслуживает два интерфейса: canvas для человека и MCP для агента. Теперь о том, на чём всё это стоит. Спойлер: базы данных там нет, и это осознанное решение, за которое я однажды заплатил потерянными данными.

Хранилище: просто файлы

Весь проект на диске выглядит так:

workspace.yaml
projects/<id>/
  project.yaml              # метаданные, релизы
  hierarchy/hierarchy.yaml  # дерево узлов
  requirements/             # FR, NFR, SAR, CON
  diagrams/<id>.yaml + .dsl # метаданные + текстовый DSL
  docs/<id>.md              # документы узлов

Иерархия и требования лежат в YAML, документация в Markdown, диаграммы хранятся как YAML-метаданные плюс текстовый DSL. Всё читается глазами и правится любым редактором. Можно открыть hierarchy.yaml, переименовать узел руками, и система это подхватит. Можно положить workspace в git и получить историю всех решений бесплатно: каждое изменение плана видно в диффе. И никакого lock-in: если My Architect завтра исчезнет, ваши данные останутся обычными текстовыми файлами.

Стек вокруг файлов скромный. Бэкенд на Fastify и TypeScript слушает localhost:3100, фронтенд на React 19, MCP-сервер общается с агентом по stdio. Важная деталь: MCP-инструменты не лезут в файлы напрямую, они ходят в тот же HTTP API, что и браузер. Одна доменная логика, одни правила валидации, два клиента.

Баг, который заставил написать мьютекс

Файлы вместо базы означают, что транзакции теперь моя проблема. Я это знал теоретически. Практически меня догнало на инструменте plan_release.

Сценарий: агент планирует релиз и назначает на него полсотни узлов. Первая версия plan_release честно выпускала по одному PUT-запросу на узел, все параллельно. Каждый запрос делал классический read-modify-write: прочитал hierarchy.yaml целиком, поменял один узел, записал файл обратно. Пятьдесят запросов прочитали один и тот же снимок файла почти одновременно, и каждый записал свою версию поверх чужой. Last-writer-wins в чистом виде: выжили назначения только тех узлов, чья запись пришла последней. Около 85% назначений просто испарились. Агент отчитался об успехе, человек открыл User Story Map и увидел полупустой релиз.

Лечение получилось двухслойным. Во-первых, появился bulk-эндпоинт PUT /hierarchy/nodes/bulk: plan_release теперь отправляет все назначения одним запросом, и это один read-modify-write вместо пятидесяти. Во-вторых, и это главное, появился project-mutex: асинхронный лок на ключ проекта, через который проходят все мутации.

const chains = new Map<string, Promise<unknown>>();
const owners = new AsyncLocalStorage<Set<string>>();

export async function withProjectLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const held = owners.getStore();
  if (held?.has(key)) return fn(); // re-entrant: лок уже наш

  const prev = chains.get(key) ?? Promise.resolve();
  // ... встаём в FIFO-очередь за prev
}

Весь файл занимает 33 строки. Операции над одним проектом выстраиваются в FIFO-очередь через цепочку промисов, разные проекты работают параллельно и друг друга не ждут. Лок реентерабельный: владелец отслеживается через AsyncLocalStorage, поэтому доменная функция под локом может вызвать другую доменную функцию того же проекта и не повиснет в дедлоке. Без этого пришлось бы плодить «внутренние» версии каждой функции без лока, а это верный путь к тому, что кто-то однажды вызовет не ту.

Файловое хранилище не освобождает от конкурентности. Оно лишь делает её последствия наглядными: вот файл, вот два писателя, вот пропавшие строки в диффе.

Атомарная запись и file watcher

Вторая ловушка файлового хранилища: читатель может увидеть наполовину записанный файл. Решение старое как POSIX: писать во временный файл и переименовывать.

const tmpPath = `${filePath}.tmp.${Date.now()}.${random}`;
await fs.writeFile(tmpPath, content, 'utf-8');
await fs.rename(tmpPath, filePath);

rename на POSIX-системах атомарен: файл по целевому пути либо старый целиком, либо новый целиком, промежуточного состояния не существует. Через эту функцию atomicWrite идут все записи: иерархия, требования, документы, диаграммы. А watcher настроен игнорировать маску */.tmp.*, так что временные файлы не порождают ложных событий.

Живая синхронизация

Watcher построен на chokidar и следит за всем workspace. Когда файл меняется, событие классифицируется по пути: hierarchy/ означает «перечитать дерево», requirements/ означает «перечитать требования», и так далее. Дальше дебаунс 100 мс, чтение свежих данных и broadcast в браузер по WebSocket. Canvas обновляется без перезагрузки страницы.

Эта схема и даёт эффект из первой части: агент закрыл задачу через MCP, файл изменился, человек видит посеревший узел прямо во время сессии. Работает и в обратную сторону: правка файла руками или git checkout другой ветки тоже прилетают в браузер.

Одна тонкость: сервер сам пишет файлы и сам же за ними следит, поэтому без защиты каждая собственная запись возвращалась бы эхом. Хук markOwnWrite помечает файл перед записью, и watcher игнорирует события по нему в течение 200 мс. Внешние правки проходят, собственное эхо гасится.

Файлы — это агентно-нативный дизайн

Рефлекс старой школы: инструмент хранит проекты, задачи и требования — значит, под ним база данных. My Architect проектировался от другой печки: его главный пользователь — агент, а родная среда агента — текстовые файлы. Claude Code и любой MCP-агент умеют читать, грепать и править текст напрямую; YAML с иерархией и Markdown с доками агент понимает без единого посредника. Тот же git diff по плану проекта — готовый материал для ревью, который агент читает так же свободно, как человек.

База данных в этой картине ничего не добавляет, зато забирает. Данные уезжают за SQL и схему, и к ним вырастает слой обслуживания: миграции, коннекты, docker-compose, бэкапы, админка. Для агента это всё непрозрачно — вместо «открыл файл и прочитал» появляется «сходи через API, который кто-то должен поддерживать». Перегрузка системы без профита: ни одной задачи, которую здесь решала бы база, файлы не решают хуже.

Цена решения — дисциплина: каждая мутация обязана идти через withProjectLock и atomicWrite. Эту дисциплину охраняют тесты, сейчас их 746, и среди них есть именно тот сценарий, который меня укусил: 50 параллельных updateNode назначают релиз, тест проверяет, что выжили все 50. Раньше выживали семь-восемь.

Если завтра понадобится многопользовательский сервер с тысячами проектов, возьму Postgres без сожалений — на том масштабе у базы появляется работа. Но для инструмента, который живёт рядом с кодом и чей основной пользователь — агент, база решала бы проблемы, которых нет, и отгораживала бы данные от того, кто с ними работает.

Дальше

В части 3 разберу модель планирования: иерархию узлов, требования с наследованием вниз по дереву и User Story Map с релизами. То есть то, ради чего вся эта файловая машинерия вообще существует. Попробовать My Architect можно на my-architect.app.