← Wstecz Harupa
2026-06-05RU EN UA BY PL

My Architect, część 2: architektura bez bazy danych

To drugi tekst z serii o My Architect. W części 1 opowiedziałem, po co agentowi pamięć między sesjami i jak jeden projekt obsługuje dwa interfejsy: canvas dla człowieka i MCP dla agenta. Teraz o tym, na czym to wszystko stoi. Spoiler: bazy danych tam nie ma — i to świadoma decyzja, za którą kiedyś zapłaciłem utraconymi danymi.

Storage: po prostu pliki

Cały projekt na dysku wygląda tak:

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

Hierarchia i wymagania leżą w YAML-u, dokumentacja w Markdownie, diagramy są przechowywane jako metadane YAML plus tekstowy DSL. Wszystko da się przeczytać gołym okiem i poprawić dowolnym edytorem. Można otworzyć hierarchy.yaml, ręcznie zmienić nazwę węzła — i system to podchwyci. Można wrzucić workspace do gita i dostać historię wszystkich decyzji za darmo: każda zmiana planu jest widoczna w diffie. I żadnego lock-inu: jeśli My Architect jutro zniknie, wasze dane pozostaną zwykłymi plikami tekstowymi.

Stack wokół plików jest skromny. Backend na Fastify i TypeScripcie nasłuchuje na localhost:3100, frontend to React 19, a serwer MCP rozmawia z agentem przez stdio. Ważny szczegół: narzędzia MCP nie grzebią w plikach bezpośrednio — chodzą do tego samego HTTP API co przeglądarka. Jedna logika domenowa, jeden zestaw reguł walidacji, dwóch klientów.

Bug, który zmusił mnie do napisania mutexa

Pliki zamiast bazy oznaczają, że transakcje to teraz mój problem. Wiedziałem to w teorii. W praktyce dopadło mnie to na narzędziu plan_release.

Scenariusz: agent planuje release i przypisuje do niego z pięćdziesiąt węzłów. Pierwsza wersja plan_release uczciwie wysyłała po jednym requeście PUT na węzeł, wszystkie równolegle. Każdy request robił klasyczny read-modify-write: przeczytał cały hierarchy.yaml, zmienił jeden węzeł, zapisał plik z powrotem. Pięćdziesiąt requestów odczytało ten sam snapshot pliku niemal jednocześnie i każdy zapisał swoją wersję, nadpisując cudzą. Last-writer-wins w czystej postaci: przeżyły tylko przypisania tych węzłów, których zapis przyszedł ostatni. Około 85% przypisań po prostu wyparowało. Agent zaraportował sukces, człowiek otworzył User Story Map i zobaczył w połowie pusty release.

Leczenie wyszło dwuwarstwowe. Po pierwsze, pojawił się bulk endpoint PUT /hierarchy/nodes/bulk: plan_release wysyła teraz wszystkie przypisania jednym requestem, czyli jeden read-modify-write zamiast pięćdziesięciu. Po drugie — i to jest sedno — pojawił się project-mutex: asynchroniczny lock na klucz projektu, przez który przechodzą wszystkie mutacje.

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
}

Cały plik to 33 linijki. Operacje na jednym projekcie ustawiają się w kolejce FIFO przez łańcuch promise'ów; różne projekty działają równolegle i nie czekają na siebie. Lock jest reentrantny: właściciel jest śledzony przez AsyncLocalStorage, więc funkcja domenowa trzymająca lock może wywołać inną funkcję domenową tego samego projektu i nie zawiśnie w deadlocku. Bez tego trzeba by płodzić „wewnętrzne” wersje każdej funkcji bez locka — a to pewna droga do tego, że ktoś kiedyś wywoła nie tę, co trzeba.

Plikowy storage nie zwalnia z myślenia o współbieżności. On tylko czyni jej skutki widocznymi: oto plik, oto dwóch piszących, oto brakujące linie w diffie.

Atomowy zapis i file watcher

Druga pułapka plikowego storage'u: czytelnik może zobaczyć plik zapisany w połowie. Rozwiązanie stare jak POSIX: pisać do pliku tymczasowego i zmieniać nazwę.

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

rename na systemach POSIX jest atomowy: plik pod docelową ścieżką jest albo w całości stary, albo w całości nowy — stan pośredni nie istnieje. Przez tę funkcję atomicWrite przechodzą wszystkie zapisy: hierarchia, wymagania, dokumenty, diagramy. A watcher jest skonfigurowany tak, żeby ignorować maskę */.tmp.*, więc pliki tymczasowe nie generują fałszywych zdarzeń.

Synchronizacja na żywo

Watcher jest zbudowany na chokidarze i obserwuje cały workspace. Kiedy plik się zmienia, zdarzenie jest klasyfikowane po ścieżce: hierarchy/ znaczy „przeczytaj drzewo od nowa”, requirements/ znaczy „przeczytaj wymagania od nowa”, i tak dalej. Potem debounce 100 ms, odczyt świeżych danych i broadcast do przeglądarki przez WebSocket. Canvas aktualizuje się bez przeładowania strony.

To właśnie ten schemat daje efekt z pierwszej części: agent zamknął zadanie przez MCP, plik się zmienił, człowiek widzi szarzejący węzeł jeszcze w trakcie sesji. Działa to też w drugą stronę: ręczna edycja pliku albo git checkout innej gałęzi również lądują w przeglądarce.

Jedna subtelność: serwer sam pisze pliki i sam je obserwuje, więc bez zabezpieczenia każdy własny zapis wracałby echem. Hook markOwnWrite oznacza plik przed zapisem, a watcher ignoruje zdarzenia dla niego przez 200 ms. Zewnętrzne edycje przechodzą, własne echo jest wygaszane.

Pliki to agent-native design

Odruch starej szkoły: narzędzie przechowuje projekty, zadania i wymagania — czyli pod spodem siedzi baza danych. My Architect był projektowany z innego punktu wyjścia: jego głównym użytkownikiem jest agent, a naturalnym środowiskiem agenta są pliki tekstowe. Claude Code i każdy agent MCP potrafią czytać, grepować i edytować tekst bezpośrednio; YAML z hierarchią i Markdown z dokumentacją agent rozumie bez żadnego pośrednika. Zwykły git diff planu projektu to gotowy materiał do review, który agent czyta równie swobodnie jak człowiek.

Baza danych w tym obrazku niczego nie dodaje, za to sporo zabiera. Dane uciekają za SQL i schemat, a wokół nich wyrasta warstwa utrzymania: migracje, połączenia, docker-compose, backupy, panel admina. Dla agenta to wszystko jest nieprzejrzyste — zamiast „otwórz plik i przeczytaj” pojawia się „idź przez API, które ktoś musi utrzymywać”. Dociążenie systemu bez żadnego zysku: nie ma tu ani jednego zadania, które baza rozwiązywałaby lepiej niż pliki.

Ceną tej decyzji jest dyscyplina: każda mutacja musi iść przez withProjectLock i atomicWrite. Tej dyscypliny pilnują testy — w tej chwili jest ich 746 — a wśród nich dokładnie ten scenariusz, który mnie ugryzł: 50 równoległych wywołań updateNode przypisuje release, a test sprawdza, że przeżyło wszystkie 50. Kiedyś przeżywało siedem-osiem.

Jeśli jutro będzie potrzebny wieloużytkownikowy serwer z tysiącami projektów, wezmę Postgresa bez żalu — w tej skali baza ma realną robotę do wykonania. Ale dla narzędzia, które żyje obok kodu i którego głównym użytkownikiem jest agent, baza rozwiązywałaby problemy, których nie ma, i odgradzałaby dane od tego, kto z nimi pracuje.

Co dalej

W części 3 rozbiorę model planowania: hierarchię węzłów, wymagania dziedziczone w dół drzewa i User Story Map z release'ami. Czyli to, dla czego cała ta plikowa maszyneria w ogóle istnieje. My Architect można wypróbować na my-architect.app.