Runtime vykonava strom, prokliky z widgetu, oprava ukladani rozlozeni

Runtime: `src/runtime/executor.ts` jde krok po kroku, u podminky se vetvi,
do poli dosadi parametry, akci pusti pres runScript a vystupy pripise do
kontextu pro dalsi krok. Cely prubeh jde do logu ticketu vcetne toho, co
sluzba vratila. Pouzivaji ho obe cesty: akce na ticketu i webhook.

Opraveno: rozlozeni dashboardu s vlastnim widgetem se NEDALO ULOZIT.
`validateLayout` znala jen vestaveny katalog, takze kazdy pokus skoncil
hlaskou "widget v katalogu neexistuje" - presne to, co hlasil uzivatel.
Katalog je ted jedna funkce a pouziva ji nabidka i kontrola. Zaroven je
za konkretni firmu, driv slo polozit dlazdici jedne firmy na dashboard druhe.

Prokliky: z widgetu lidi na cloveka, ze seskupeni na vyfiltrovany seznam
ticketu. Odkazy sklada server, protoze on jediny zna filtr widgetu. Seznam
ticketu cte filtr z adresy a umi filtrovat na typ, tag a skupinu.

Tabulky: spolecna `TicketTable` pro seznam i detail osoby. Na mobilu se
neposouva do strany, uzka obrazovka dostane karty. Detail osoby ma velkou
tabulku se zalozkami "ma u sebe" a "vyresil" a prepinacem pohledu.

Odebrano: simulace vcetne tlacitka, dialogu i endpointu. Trojice pohledu
nad tickety - vyber firmy je select, "moje" je prepinac, driv to delalo
totez dvakrat.

Pridan zmereny rozbor kapacity pro 200 firem (19-kapacita-200-firem.md):
soucasny stav to nezvladne, protoze data jsou v pameti a vypis je linearni.
Zmereno na 5 000 ticketech, vcetne toho, co s tim a kolik serveru to chce.

Overeno 7 kontrolami proti bezicimu serveru.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 15:54:17 +02:00
co-authored by Claude Opus 5
parent 29de584df8
commit 5d186dcd2e
27 changed files with 1357 additions and 1201 deletions
+10 -7
View File
@@ -16,7 +16,6 @@ React aplikaci ze slozky `dist/public`.
| Prihlaseni | hotovo | JWT, demo ucty |
| Dashboard | hotovo | prehled, tickety, incidenty, automatizace, nastaveni |
| Zivy dashboard pres SSE | hotovo | zmeny se projevi bez obnoveni stranky |
| Simulace provozu | hotovo | tlacitko v postrannim menu portalu |
| Katalog sluzeb | hotovo | 29 sluzeb, 7 kategorii vcetne Obecne |
| Builder automatizaci | hotovo | strom akci, vetveni podminkou |
| Webhook s registrovanou adresou | hotovo | token generuje server, verejny endpoint validuje data |
@@ -48,7 +47,7 @@ React aplikaci ze slozky `dist/public`.
| Telo akce jako strom | hotovo | tentyz editor jako automatizace |
| Audit a prepnuti na jiny ucet | hotovo | prepnuti je vychozi jen pro cteni, vse v auditu |
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
| Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime |
| Beh automatizaci | castecne | strom se vykona, ale synchronne a bez fronty |
| Uloziste konektoru | hotovo | Postgres, nebo JSON soubor. Udaje vzdy sifrovane |
| Uloziste pro zbytek | hotovo | tickety, automatizace, incidenty, rozlozeni, entity |
| Monetizace a cena za krok | navrh | popis v 16-monetizace.md, neni naprogramovane |
@@ -66,9 +65,11 @@ v portalu i v `/health/ready` a rozhoduje o nem jedno misto, viz
| `file` | neni databaze, je `DATA_DIR` | prezije restart, ne redeploy |
| `memory` | neni ani `DATA_DIR` | ztrati se pri restartu |
Beh automatizaci zatim neexistuje: ulozeny strom se nevykonava, provoz se dela
simulaci. Popis, jak to ma vypadat, je v
[10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
Beh automatizaci uz existuje, ale je **synchronni v requestu**: webhook ceka,
nez cely strom dobehne, a pri padu procesu se rozdelany beh ztrati. Neni fronta
ani opakovani. Co to znamena pro vetsi provoz a co s tim, je zmerene
v [19-kapacita-200-firem.md](19-kapacita-200-firem.md), navrh fronty
v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
Obsah verejneho webu je ukazkovy. Nazev firmy, reference, tym i cisla jsou
vymyslene a pred ostrym pouzitim se musi nahradit. Firemni udaje jsou na jednom
@@ -77,8 +78,9 @@ miste v `web/src/config/brand.ts`.
Zivy stream drzi seznam posluchacu v pameti jedne instance. Pri vice instancich
by ho musel nahradit sdileny kanal, napriklad Redis pub/sub.
Log ticketu zatim plni simulace, ne skutecny beh. Zaznamy jsou realisticke,
ale nevznikly vykonanim ulozeneho stromu - runtime neexistuje.
Log ticketu uz plni skutecny beh: kazdy krok stromu se do nej zapise vcetne
toho, co sluzba vratila. Ukazkova sada ticketu ma log psany rucne, aby bylo
co ukazat i na prazdne instanci.
## Dalsi krok
@@ -124,4 +126,5 @@ pro frontu, beh kroku a rozpocet na 150 klientu, a
| [16-monetizace.md](16-monetizace.md) | **navrh**: cena za krok a balicky |
| [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md) | prava, typy, akce, widgety, prepnuti uctu |
| [18-ticketovaci-system.md](18-ticketovaci-system.md) | udalosti, externi ID, statistiky, pohledy |
| [19-kapacita-200-firem.md](19-kapacita-200-firem.md) | zmereno, co zvladne soucasny stav |
| [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore |
-16
View File
@@ -80,7 +80,6 @@ Vyzaduji `Authorization: Bearer <token>`:
| POST | `/api/admin/impersonate/stop` |
| GET | `/api/admin/impersonate/candidates` |
| GET | `/api/admin/audit` |
| POST | `/api/simulate` |
Sprava zaznamu ma u kazde entity stejnou petici (seznam, detail, vytvoreni,
uprava, mazani) na `/api/dashboard/settings/<entita>`, protoze ji dela jedna
@@ -246,21 +245,6 @@ s 403. Kazde prepnuti i ukonceni je v auditu vcetne toho, kdo to byl doopravdy.
Svuj puvodni token si klient odklada do `sessionStorage`, server o nem nic nevi.
## Simulace
`POST /api/simulate` vyvola provozni udalost pro nahled ziveho dashboardu.
Zamerne meni skutecna data, ne jen posila falesnou notifikaci.
Akce: `ticket.created`, `ticket.resolved`, `incident.started`,
`incident.resolved`, `automation.run`.
U `ticket.created` urcuje `channel` (whatsapp, facebook, instagram, email, voice,
form, portal), odkud pozadavek prisel, a podle toho se poskladá i log ticketu. `knownCustomer: false` znamena, ze CRM firmu nedohleda -
ticket zustane bez zakaznika i bez resitele a v logu je videt proc.
Nevyplnena pole server doplni ukazkovou hodnotou. U akci s "resolved" se bez
zadaneho id pouzije prvni nevyrizeny zaznam.
## Sluzby a konektory
Popis modelu je v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md), tady jen API.
+3
View File
@@ -37,6 +37,8 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět.
| `hasPermission(...)` | `src/data/permissions.ts` | Jedna kontrola. Používá ji `crudRouter` i ruční handlery. |
| `navFor(...)` | `src/data/tenantFeatures.ts` | Průnik toho, co firma má, a toho, na co má člověk právo. Navigace chodí ze serveru. |
| `recordAudit(input)` | `src/data/audit.ts` | Zápis do auditu. Nevrací chybu a nečeká se - rozbitý audit nesmí rozbít aplikaci. |
| `runFlow(steps, context, options)` | `src/runtime/executor.ts` | Vykoná strom kroků. Nikdy nevyhodí výjimku, chyba je výsledek. Používá to akce na ticketu i webhook, aby se strom choval všude stejně. |
| `widgetCatalog(tenantIds, userId)` | `src/data/widgets.ts` | Jediná definice toho, co jde položit na dashboard. Používá ji nabídka i kontrola ukládaného rozložení. |
| `intakeEvent(input)` | `src/data/ticketStore.ts` | Přijme událost zvenku: podle externího ID buď založí ticket, nebo ji navěsí na existující. Jediná cesta, kterou se událost stává ticketem. |
| `getAgentStats(...)` | `src/data/ticketStore.ts` | Výkon řešitelů: odbavené, mediány časů, vrácené, fronta. Používá to widget i detail osoby, aby čísla seděla. |
| `findByExternalId(...)` | `src/data/ticketStore.ts` | Ticket firmy podle externího ID. Klíč je dvojice firma a ID. |
@@ -69,6 +71,7 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
| `TicketActions` | `components/dashboard/TicketActions.tsx` | CTA akcí na ticketu plus typ, tagy a vlastní pole. Seznam akcí chodí ze serveru už vyfiltrovaný. |
| `ErrorDetail` | `components/dashboard/ErrorDetail.tsx` | Rozbalovací celé chybové hlášení s kopírováním. Chyba se nikdy nezkracuje. |
| `CustomWidgetCard` | `components/dashboard/widgets/CustomWidget.tsx` | Vykreslí widget, jehož data počítá server: číslo, pruhy, tabulka výkonu, časová řada, seznam, data z konektoru. |
| `TicketTable` | `components/dashboard/TicketTable.tsx` | Tabulka ticketů pro všechna místa. Na mobilu se místo posouvání do strany kreslí karty. |
| `TicketEvents` | `components/dashboard/TicketEvents.tsx` | Příchozí události ticketu včetně celého přijatého JSONu. |
| `ViewSwitch` | `components/dashboard/ViewSwitch.tsx` | Přepínač tabulka nebo dlaždice. Používají ho všechny seznamy. |
| `FlowCanvas` s `start` | `components/dashboard/flow/FlowCanvas.tsx` | Tentýž strom kroků i bez spouštěče - pro tělo akce, které spouští člověk. |
+160
View File
@@ -0,0 +1,160 @@
# Kapacita: 200 firem, 10 automatizací, 15 lidí na firmu
Otázka zněla: zvládne to současný stav s Postgresem, a co to bude chtít
za server. Odpověď je změřená, ne odhadnutá.
**Krátce: v současném stavu ne.** Runtime běží, ale datová vrstva je psaná
na stovky až tisíce ticketů, ne na miliony. Co konkrétně to zastaví a v jakém
pořadí to opravit, je níž.
## Co se měřilo
Jeden proces, režim souboru, tickety se posílaly přes příjem událostí.
Měřeno na vývojovém stroji, tedy horní hranice latence, ne serveru.
| Ticketů | Výpis seznamu | Statistiky řešitelů | Soubor |
| --- | --- | --- | --- |
| 103 | 1,9 ms | 1,6 ms | 153 kB |
| 503 | 6,1 ms | 12,1 ms | 729 kB |
| 1 003 | 14,2 ms | 2,7 ms | 1,4 MB |
| 2 003 | 18,8 ms | 21,1 ms | 2,9 MB |
| 5 003 | 52,9 ms | 1,7 ms | 7,2 MB |
Příjem událostí: **130 až 190 událostí za sekundu** včetně celého kola
HTTP, uložení a zápisu do logu.
Z toho plyne:
- **jeden ticket = 1,4 kB** v uložišti,
- **výpis roste lineárně**, zhruba 10 mikrosekund na ticket, protože se
filtruje pole v paměti,
- v režimu souboru se **při každé změně přepisuje celý soubor**. Při pěti
tisících ticketech je to 7 MB zápisu kvůli jednomu komentáři. S Postgresem
to odpadá, tam je to jeden řádek.
## Kolik toho bude
200 firem, 10 automatizací každá, 15 lidí. Odhad provozu:
| Veličina | Výpočet | Za den | Za měsíc |
| --- | --- | --- | --- |
| Běhy automatizací | 2 000 automatizací, 100 běhů denně | 200 000 | 6 mil. |
| Kroky | 3 kroky na běh | 600 000 | 18 mil. |
| Tickety | 200 firem, 200 denně | 40 000 | 1,2 mil. |
| Řádky logu | 5 na ticket plus kroky | ~800 000 | 24 mil. |
Špička není průměr. 7 kroků za sekundu v průměru znamená ve špičce klidně
100 za sekundu, protože e-shopy neposílají objednávky rovnoměrně.
## Co to v současném stavu zastaví
### 1. Všechna data jsou v paměti procesu
`withMirror` drží tickety, automatizace a incidenty v poli a při startu je
**všechny načte**. Při 1,2 milionu ticketů měsíčně je to 1,7 GB jen na tickety,
a to bez logu. Aplikace se nenastartuje.
Opravit se musí tak, že se tickety čtou dotazem s indexem a stránkováním,
ne z pole. `withMirror` má zůstat na tom, co je malé a čte se pořád (rozložení
dashboardu), ne na provozních datech.
### 2. Výpis prochází všechno
`listTickets` filtruje pole přes všechny firmy. Změřeno 10 mikrosekund na
ticket, takže při milionu ticketů je jeden výpis 10 sekund. Musí to být
`WHERE tenant_id = ... AND status = ... ORDER BY updated_at LIMIT 50` nad
indexem, tedy jednotky milisekund bez ohledu na objem.
### 3. Log je uvnitř ticketu
Ticket si nese `trace` i `events` jako součást jednoho záznamu. Každý řádek
logu tím přepisuje celý ticket i s celým dosavadním logem. Po padesáti krocích
je to padesát zápisů, každý delší než ten předchozí.
Log patří do vlastní tabulky s cizím klíčem na ticket a s retencí. Při 24
milionech řádků měsíčně a plné odpovědi služby u každého je to řádově
**stovky GB za měsíc**, takže retence není detail, ale podmínka provozu.
### 4. Runtime běží v požadavku
`runFlow` vykoná strom rovnou v HTTP požadavku. U ruční akce je to správně,
u webhooku ne:
- odesílatel čeká, než doběhnou všechny kroky,
- při pádu procesu se rozdělaný běh ztratí, protože není kde by byl zapsaný,
- není retry, není omezení souběhu na jednu službu, nic to nebrzdí.
### 5. Statistiky se počítají celé
`getAgentStats` projde všechny tickety firmy při každém zobrazení widgetu.
Při desítkách tisíc na firmu to jde, při milionu ne. Patří to do agregace
počítané dávkově, nebo aspoň do dotazu s `GROUP BY` nad indexem.
### 6. Jedna instance
Živý stream (SSE), mezipaměť widgetů nad konektory i kopie konfigurace
v paměti jsou vázané na proces. Při dvou instancích za load balancerem se
rozejdou. Řeší to `LISTEN/NOTIFY` na obnovu kopií a sdílený kanál na stream.
## Konektory nejsou v naší moci
Za rychlost a výsledek cizí služby neručíme, a proto se s tím musí počítat
v návrhu, ne v provozu:
| Riziko | Co s tím |
| --- | --- |
| Služba odpovídá pomalu | Timeout na krok, ne na celý běh. Běh se uspí a pokračuje. |
| Služba je chvíli mimo | Opakování s rostoucí prodlevou, ne hned a ne donekonečna. |
| Služba je mimo dlouho | Vypnout ji po sérii chyb a nezkoušet každý běh znovu, ať netrpí ostatní. |
| Služba má limit volání | Strop souběžných volání **na dvojici firma a služba**, ne globálně. |
| Služba odpoví dvakrát jinak | Klíč proti dvojímu provedení u kroku, aby se nevystavila druhá faktura. |
| Služba je pomalá jen pro jednu firmu | Fronta po firmách, aby jedna firma nezablokovala ostatní. |
Timeout a rozlišení "zkusit znovu" a "marné" už v `scripts/http.ts` je,
klíč proti dvojímu provedení taky. Chybí to, co je nad tím: fronta, opakování
a vypínání služby po sérii chyb.
## Co je potřeba udělat, v pořadí
1. **Tickety, běhy a log do Postgresu jako tabulky**, ne jako JSON v paměti.
Indexy na `(tenant_id, updated_at)`, `(tenant_id, external_id)` unikátní,
`(tenant_id, assignee_id, status)`. Stránkování na všech výpisech.
2. **Log a události zvlášť** od ticketu, s retencí. Bez toho to zaroste.
3. **Fronta běhů.** Tabulka `run_queue`, výběr přes `SELECT ... FOR UPDATE
SKIP LOCKED`, worker jako druhý proces téhož obrazu. Webhook jen zapíše
událost a odpoví, běh se stane na pozadí.
4. **Opakování a vypínání služby** kolem kroku.
5. **Agregace statistik** dávkově, ne při každém zobrazení.
6. **`LISTEN/NOTIFY`** na obnovu kopií v paměti a sdílený kanál na živý stream.
Body 1 až 3 jsou podmínka, aby to vůbec šlo pustit. Zbytek je o tom, aby to
bylo použitelné.
## Kolik serveru to bude chtít
Po těch úpravách, pro zadaných 200 firem:
| Část | Kolik | Proč |
| --- | --- | --- |
| Web a API | 2 instance, 1 vCPU a 1 GB každá | Požadavky jsou krátké, jde hlavně o dostupnost při restartu. |
| Worker běhů | 2 instance, 1 vCPU a 512 MB každá | Kroky čekají na cizí službu, procesor se skoro nepoužije. Jeden proces zvládne stovky souběžných kroků. |
| Postgres | 4 vCPU, 8 GB RAM, 200 GB disku | 10 až 20 zápisů za sekundu v průměru je málo, disk sežere log. |
| Celkem | ~8 vCPU, ~11 GB RAM | |
Kritické číslo není procesor, ale **disk pod databází** a retence logu.
S plnou odpovědí služby u každého kroku je to zhruba 20 GB měsíčně na
každých 20 milionů řádků, takže 200 GB je půl roku provozu. Buď se odpovědi
po měsíci zahazují a nechá se jen shrnutí, nebo se počítá s tím, že disk
poroste.
Škálování je vodorovné: víc událostí znamená víc workerů, ne větší stroj.
Jediné, co se škáluje svisle, je databáze, a ta má u tohoto objemu ještě
velkou rezervu.
## Co funguje už teď
Aby to nevypadalo hůř, než to je: runtime **běží**. Webhook vykoná strom,
akce na ticketu vykoná strom, kroky si předávají výstupy, podmínka větví
a celý průběh se zapíše do logu ticketu i s tím, co která služba vrátila.
Na jednu firmu s desítkami ticketů denně je současný stav použitelný.
Na 200 firem ne.
+44
View File
@@ -2,6 +2,50 @@
Nejnovejsi nahore.
## 2026-08-13 - runtime, prokliky z widgetu a kapacitni rozbor
### Pridano
- `src/runtime/executor.ts`: **strom se konecne vykonava**. Jde krok po kroku,
u podminky se vetvi, do poli dosadi `{{parametry}}`, akci pusti pres
`runScript` a vystupy pripise do kontextu, aby na ne mohl dalsi krok
odkazat. Cely prubeh jde do logu ticketu vcetne toho, co sluzba vratila.
Pouzivaji ho **obe** cesty: akce na ticketu i webhook automatizace.
- Z widgetu se da prokliknout na to, co je za cislem: z lidi na cloveka,
ze seskupeni na uz vyfiltrovany seznam ticketu. Odkazy sklada **server**,
protoze on jediny zna filtr widgetu.
- Seznam ticketu cte filtr z adresy (`?typeId=`, `?tag=`, `?assignee=`, ...),
takze proklik vede na spravny vyber a odkaz jde poslat kolegovi.
- Seznam ticketu umi filtrovat na typ, tag a skupinu.
- Spolecna `TicketTable`: jedna tabulka pro seznam i pro detail osoby.
**Na mobilu se neposouva do strany**, uzka obrazovka dostane karty.
- [19-kapacita-200-firem.md](19-kapacita-200-firem.md): zmereny rozbor toho,
co se stane pri 200 firmach, a co to bude chtit za server.
### Opraveno
- **Rozlozeni dashboardu s vlastnim widgetem se nedalo ulozit.** `validateLayout`
znala jen vestaveny katalog, takze kazdy pokus skoncil hlaskou, ze widget
v katalogu neexistuje. Katalog je ted jedna funkce (`widgetCatalog`) a pouziva
ji nabidka i kontrola.
- Katalog widgetu je za konkretni firmu, ne za vsechny firmy uzivatele. Driv
slo polozit dlazdici jedne firmy na dashboard druhe, kde k ni data nikdy
neprisla.
### Odebrano
- **Simulace** vcetne tlacitka, dialogu i endpointu `/api/simulate`. Provoz se
ted dela prijmem udalosti, ktery je skutecny.
- Trojice pohledu nad tickety. Vyber firmy je select, "moje" je prepinac -
driv to delalo totez dvakrat.
### Overeno
7 kontrol proti bezicimu serveru: ulozeni rozlozeni s vlastnim widgetem,
oddeleni katalogu po firmach, vykonani stromu akce i webhooku, zapis behu do
logu, odkazy z widgetu a filtrovani podle nich. K tomu mereni na 5 000
ticketech, ze ktereho vychazi rozbor kapacity.
## 2026-08-13 - ticketovaci system: udalosti, externi ID, statistiky, widgety
Popis v [18-ticketovaci-system.md](18-ticketovaci-system.md).