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, який хтось має підтримувати». Перевантаження системи без профіту: немає жодної задачі, яку тут база робила б краще за файли.
- Гарантії конкурентності, заради яких зазвичай беруть базу, дає 33-рядковий м'ютекс: сервер один, процес один.
- Дані локальні й маленькі: навіть великий проєкт — це сотні вузлів, мегабайти YAML. Повне читання ієрархії дешевше за похід у базу мережею.
- Історія, бекап і рев'ю змін плану — це просто git, безкоштовно. З базою те саме довелося б будувати руками.
- Інсталяція тривіальна: ні демона, ні міграцій. Склонував, запустив, працює.
Ціна рішення — дисципліна: кожна мутація зобов'язана йти через withProjectLock і atomicWrite. Цю дисципліну охороняють тести, зараз їх 746, і серед них є саме той сценарій, який мене вкусив: 50 паралельних updateNode призначають реліз, тест перевіряє, що вижили всі 50. Раніше виживали сім-вісім.
Якщо завтра знадобиться багатокористувацький сервер із тисячами проєктів, візьму Postgres без жалю — на тому масштабі в бази з'являється робота. Але для інструмента, який живе поруч із кодом і чий основний користувач — агент, база розв'язувала б проблеми, яких немає, і відгороджувала б дані від того, хто з ними працює.
Далі
У частині 3 розберу модель планування: ієрархію вузлів, вимоги зі спадкуванням вниз по дереву і User Story Map з релізами. Тобто те, заради чого вся ця файлова машинерія взагалі існує. Спробувати My Architect можна на my-architect.app.