# Fronta, worker a spouštěče Jak se událost dostane od webhooku k vykonanému stromu. Rozbor kapacity je v [19-kapacita-200-firem.md](19-kapacita-200-firem.md), model ticketu v [18-ticketovaci-system.md](18-ticketovaci-system.md). ## Webhook odpoví hned, práci udělá worker ``` POST /webhook/ -> kontrola těla podle kontraktu -> zápis do fronty -> 202 Accepted (do jednotek milisekund) worker (na pozadí) -> vezme z fronty -> vykoná strom -> zapíše výsledek do logu ticketu -> při chybě naplánuje další pokus, nebo založí incident ``` Odesílatel **nikdy nečeká** na cizí službu. Důvody: - Za konektory neručíme. iDoklad může odpovídat pět sekund nebo být hodinu mimo. Kdyby se čekalo v requestu, odesílateli vyprší timeout a událost je pryč, přestože jsme ji dostali. - Pád procesu nesmí ztratit práci. Záznam ve frontě restart přežije, rozdělaný běh v paměti ne. - Bez fronty není kam si poznamenat, že se to má za minutu zkusit znovu. Odpověď 202 znamená "převzali jsme to", ne "hotovo". Výsledek se hledá v `GET /api/dashboard/runs` nebo v logu ticketu. ## Co frontu plní | Druh | Kdo to spustí | Příklad | | --------------- | ---------------------- | ----------------------------- | | Push | cizí služba zavolá nás | e-shop pošle novou objednávku | | Vnitřní událost | něco se stalo u nás | vznikl nebo se změnil ticket | | Pull | ptáme se sami | e-mail, zprávy z Messengeru | ### Pull, tedy pravidelné dotazování Většina služeb webhooky nemá. U e-mailu a schránek zpráv se **musíme ptát**. Dělá to plánovač: každých 30 sekund projde automatizace, jejichž spouštěč je "musí se obvolávat", a u těch, kterým uplynula perioda, zařadí běh. Plánovač **sám nic nevolá**. Jen řekne "je čas" a samotný dotaz je první krok stromu. Díky tomu se dotazování chová stejně jako cokoliv jiného: má záznam v logu, opakuje se při chybě a jde ho změnit bez zásahu do kódu. Výchozí periody: pošta 60 s, zprávy 30 s, plánovač 60 s. Automatizace si to může přepsat polem `intervalSec` u spouštěče, minimum je 10 sekund - kratší už není dotazování, ale útok na cizí službu. Jeden čekající dotaz na automatizaci: když předchozí ještě běží, další se nezařadí. Jinak by se fronta zaplnila dotazy na službu, která stejně nestíhá. ## Kontrakt webhooku Odesílatelé posílají různé tvary. Jeden `{"a":"aaa"}`, druhý celý model s vnořenými objekty a poli. Proto má každý parametr spouštěče **cestu**: ```json { "document": { "id": "D-99" }, "errors": [{ "code": "OCR_FAIL", "message": "Nepodařilo se přečíst částku" }] } ``` | Parametr | Cesta | Typ | | -------------- | ------------------ | ------ | | `docId` | `document.id` | string | | `errorMessage` | `errors.0.message` | string | Ve stromu se pak píše `{{docId}}` bez ohledu na to, jak hluboko to odesílatel schoval. Celé tělo je navíc pod `_body`, takže se nic neztratí. Chybějící povinný parametr vrací 400 s tím, který to je a kde se hledal. Přebytek v těle nevadí - odesílatel často posílá víc, než potřebujeme, a odmítnout ho kvůli tomu by znamenalo, že webhook nejde zapojit. `GET` na tutéž adresu vrátí nápovědu: co se čeká, na jakých cestách a ukázku těla. V portálu je u adresy vidět totéž včetně metody, a kopíruje se **celá adresa včetně domény**. ## Worker je pool `CONCURRENCY` (4) behu naraz, ale **nezavisle na sobe**. Worker drzi mnozinu `active`, kazde kolo si vezme `CONCURRENCY - active.size` behu (`claimBatch(limit, active)` to, co uz bezi, preskoci) a spusti je bez cekani na ostatni. Prvni verze brala davku a cekala, az dobehne cela: jeden pomaly krok MCP na deset minut blokoval tri prazdne sloty. Bezici beh posila kazdou minutu **tlukot** (`touchClaim`). Za zaseknuty se povazuje az 30 minut od posledniho tlukotu (`STUCK_AFTER_MS` v `queue.ts`), ne od vzeti z fronty. Driv to bylo deset minut od vzeti, takze beh s dlouhou ulohou MCP se vratil do fronty a **vykonal se podruhe**, i kdyz porad bezel. Planovac zarazuje behy s triggerem `poll`, ne `manual`, aby slo v seznamu behu poznat, co spustil clovek a co cas. ## Opakovani a vzdani se | Pokus | Kdy | | ----- | --------- | | 1. | hned | | 2. | za 30 s | | 3. | za 2 min | | 4. | za 10 min | | 5. | za hodinu | Pak beh skonci jako `failed` a zustane k nahlednuti. Nemaze se: bez zaznamu by nikdo nezjistil, ze se neco nestalo. **Opakuje se jen to, co samo rekne `retryable`.** Vychozi je "ne". Pravidlo je stejne ve vsech vrstvach a je napsane v komentari nad `StepResult` v `executor.ts`: | Opakuje se | Konci hned a zaklada incident | | ------------------------------------------------------- | ---------------------------------------------------------- | | chyba spojeni, timeout pred odeslanim | 401, 403, 404 | | 5xx a 429 od cizi sluzby | spatny vstup, chybejici vystup, `ctx.fail` | | vypadek uloziste konektoru | chybejici nebo pozastavena automatizace, chybejici skupina | | MCP: chyba spojeni, 408, 425, 429, 502, 503, 504 | MCP: timeout uz odeslaneho `tools/call`, chyba v kodu | Driv se opakovala **kazda** chyba skriptu, petkrat za 72 minut. Spatny vstup tak petkrat zopakoval tutez hlasku a u kroku, ktery neni idempotentni, mohl cizi sluzbu zavolat podruhe. Timeout uz odeslaneho volani MCP je proto neopakovatelny: MCP nema idempotencni klic a jestli druhy pokus znamena druhou objednavku, vi jen server, ktery neni nas. ## Dva stropy na velikost behu | Strop | Co pocita | | -------------------- | --------------------------------------------------- | | `MAX_STEPS = 50` | kroky ve stromu, kontroluje se pri ulozeni | | `MAX_ACTIONS = 1000` | vykonane kroky vcetne pruchodu smyckou, za behu | Jeden strop nestacil: smycka se dvema kroky nad 26 polozkami je 52 vykonanych kroku a beh padal na limitu 50, i kdyz strom mel kroky ctyri. Kdyz se strop vycerpa, beh se zastavi a rekne to; tise useknout smycku by vypadalo jako uspech. ## Podminka nad datem Pole typu `date` a hodnoty, ktere vypadaji jako ISO datum, se v podmince porovnavaji pres `Date.parse`. Driv slo vsechno pres `Number()`, ISO retezec vysel jako `NaN` a `gt` i `lt` nad datem byly **vzdycky nesplnene**, bez chyby a bez radku v logu. ## Vystupy kroku `publishOutputs()` v `executor.ts` je jedno misto pro vestavene kroky i skripty. Vystup se zapise pod jmenem kroku (`st_x.status`) vzdycky, **hole jmeno (`status`) jen kdyz v kontextu jeste neni**. Nastroj MCP, ktery vraci `status`, driv prepsal `status` spoustece a podminka za nim se ptala na spatnou hodnotu. ## Incident z chyby Každá chyba, kterou už nemá smysl zkoušet, založí incident se **dvěma úrovněmi**: - `title` a `impact` čte **klient**. Bez názvů kroků a ID běhů: "Automatizace u ticketu TK-4822 nedoběhla do konce, data jsme neztratili." - `detail` čte **admin**. Je v něm všechno: která automatizace, který běh, kolik pokusů, který krok selhal, celé hlášení, výpis všech kroků a data, která přišla na vstupu. `detail` se vrací **jen správci platformy**. Je to naše diagnostika, ne informace pro zákazníka. Stejná příčina nezakládá druhý incident, dokud je první otevřený. Jinak by deset stejných chyb znamenalo deset incidentů a nikdo by se v tom nevyznal. Incident patri **firme behu**. `findOpenIncident(source, tenantIds)` hleda jen v ni a krok `incident/create` predava `tenantId` - prvni verze zakladala globalni incident, ktery videly vsechny firmy. Neocekavana vyjimka v kroku (chyba v kodu) je taky koncova: neopakuje se a zaklada incident, protoze za minutu nezmizi. ## Ochrana proti smyčce Automatizace navázaná na změnu ticketu ticket změní, čímž se spustí znovu. Bez ochrany to server položí, což se při vývoji stalo. Dvě pojistky: 1. **Označení běhu.** Změna, kterou udělala automatizace X, nespustí automatizaci X. Používá se na to `AsyncLocalStorage`, protože běží čtyři běhy naráz a obyčejná proměnná by patřila všem. 2. **Strop na ticket.** Jedna automatizace smí nad jedním ticketem běžet nejvýš pětkrát za minutu. Chytí to i smyčku mezi dvěma automatizacemi, kterou první pojistka nepozná. Překročení se zaloguje, aby to šlo spravit. ## Spravedlivost mezi firmami Z každé firmy se na jedno kolo vezme nejvýš jeden běh. Jedna firma s tisícem událostí tak nezablokuje ostatní - bez toho stačí jeden rozbitý e-shop a servicedesk stojí všem. ## Kroky, které děláme my Založit ticket nebo přehodit ho na člověka není volání cizí služby, takže to nejde přes skript - sahá to do našeho úložiště. Pro uživatele je to v katalogu operace jako každá jiná. | Krok | Co dělá | | -------------------------- | --------------------------------------------------------------------- | | `ticket/upsert` | podle externího ID založí ticket, nebo na existující navěsí událost | | `ticket/assign-least-busy` | předá nejvolnějšímu ze skupiny, při shodě rozhoduje podíl ke kapacitě | | `ticket/set-type` | nastaví typ, za kterým stojí vlastní pole | | `ticket/add-tags` | přidá štítky, existující nechá | | `ticket/set-status` | změní stav v životním cyklu | | `incident/create` | založí incident | | `flow/pause`, `flow/log` | pauza a zápis do logu | ## Tři osy na ticketu | Osa | Kdo ji určuje | K čemu | | -------- | ---------------------------------------------- | -------------------------------------------------------- | | `status` | odesílatel, nabídku dává `TicketType.statuses` | kde ticket je: čeká na zabalení, předáno dopravci | | `closed` | výslovně, krok nebo člověk | co už nikdo neřeší, z toho se počítá fronta a statistiky | | `tags` | kdokoliv, volně | označení, která spolu nemusí souviset | Stav může být **jen jeden**, proto se na něj dá spolehnout v podmínce. Přes štítky by to fungovalo taky, ale ticket by mohl mít "čeká na zabalení" i "expedováno" naráz a nikdo by nepoznal, co platí. Fáze jako třetí osa tady byla a **je zrušená**. Když je stav volný řetězec, je druhé pole na tutéž věc jen zmatení. V katalogu po ní zbýval krok "Posunout do další fáze" a pole Fáze u založení ticketu, jenže model fázi neměl - krok neměl co vykonat a pole se tiše zahazovalo. ## Živý dashboard Dlaždice nad našimi daty se překreslí na událost ze streamu, tedy hned. Data z konektorů ne: server je drží v mezipaměti podle `ttlSec` u widgetu. Přehled se po uplynutí té doby zeptá znovu a dokud je mezipaměť čerstvá, dostane ji zpátky bez volání cizí služby. U dlaždice je vidět stáří dat a tlačítko, které vynutí načtení znovu. Bez mezipaměti by otevření přehledu znamenalo volání cizího API za každou dlaždici, a to má limity a někdy se za to platí. ## Ověřený scénář Scénář jedné firmy: sklad (3 lidi), expedice (2), IT (3), typy ticketu objednávka a chyba, dvě automatizace. 1. Web pošle `{"kind":"order.created","data":{"order":{"id":"5001"}}}`. 2. Webhook odpoví **202 za 12 ms**, nic nečeká. 3. Worker založí ticket, dá mu typ objednávka a štítek čeká na zabalení. 4. Změna ticketu spustí druhou automatizaci, ta podle typu a štítku předá práci **nejvolnějšímu ze skladu**. 5. Druhá objednávka jde **jinému člověku**, protože první už jednu má. 6. Chyba z převodníku dokladů přijde s vnořenou cestou `errors.0.message`, vznikne ticket typu chyba, dostane ho IT a **založí se incident**. Ověřeno 19 kontrolami proti běžícímu serveru. ## Co zbývá - **Víc instancí.** Výběr z fronty je v paměti jednoho procesu. Nad Postgresem to musí být `SELECT ... FOR UPDATE SKIP LOCKED`, jinak si dva workery vezmou tentýž běh. Místo je označené v `runtime/queue.ts`. - **Strop souběžných volání na dvojici firma a služba** a vypnutí služby po sérii chyb. Rozliseni "zkusit znovu / marne" uz plati ve vsech vrstvach. - **Dlouhé čekání** (pošli e-mail za tři dny) přes běh naplánovaný na později. Krok `flow/pause` umí nejvýš minutu, protože blokuje běh. ## Podminka nad parametrem, ktery jeste nedorazil `evaluate` prevadi chybejici hodnotu na **prazdny retezec**. Pro porovnani je tedy "nedorazilo" totez co "prazdne", a to je u nepovinneho parametru past: ```text result neq "Chybějící informace" ``` znamena "vsechno ostatni **vcetne toho, co jeste nevime**". Prvni zprava hovoru vysledek nenese, podminka sedne a strom udela to, co mel udelat az na konci. **Ptat se kladne a napred na existenci:** `isNotEmpty`, a teprve pak `eq`. Od zari 2026 to nemusi byt dve vnorene podminky. Jedna podminka se muze ptat na **vic veci naraz**: ```ts match: 'all' // a zaroven rules: [ { fieldId: 'f_result', operator: 'isNotEmpty' }, { fieldId: 'f_result', operator: 'eq', value: 'Chybějící informace' }, ] ``` `any` je "nebo", tedy tri hodnoty, ktere maji dopadnout stejne, na jednom radku misto tri urovni stromu. Je to **jedna uroven**, zavorky ne - viz zaznam zmen k 7. 9. 2026. Stara podoba (`fieldId` primo na kroku) se dal cte, prevadi ji `rulesOf`. Radek podminky v logu proto nese i to, s cim se porovnavalo, a rozlisuje `nedorazilo` od `prázdné`: ```text Podmínka: result isNotEmpty: nesplněno | result = nedorazilo Podmínka: result eq Chybějící informace | result = "Přesměrování", porovnáno s "Chybějící informace" ```