Projekt srovnan se zasadami v D:\GitHubRepository\CLAUDE.md bez zmeny chovani.
Struktura: scripts/ (skripty konektoru) -> connectors/, src/scripts ->
src/runtime/scripts; src/index.ts jen startuje, novy src/app.ts s createApp();
routes/dashboard.ts a routes/settings.ts rozdeleny do slozek; openapi.ts
rozdelen na openapi/{index,helpers,components} a paths/* (98 cest overeno
shodnych); ticketStore, automationStore a services jsou fasady nad slozkami
data/tickets, data/automations a data/services/catalog. process.env se cte
jen v config.ts. Web: hooky v hooks/, sdilena ui/Table a ui/ServiceIcon,
surove inputy nahrazeny komponentami, sedm velkych souboru rozdeleno.
Nastroje: eslint (typescript-eslint, react-hooks v7), prettier, editorconfig,
nvmrc, .env.example, vitest; skripty lint, format, test. Lint je cisty bez
jedineho eslint-disable (nove hooky useLatest a useSyncFromSource, odvozeny
stav misto setState v effectu). noUncheckedIndexedAccess v obou tsconfig,
84 mist zuzeno bez non-null operatoru; odhalilo zalohu backoffu fronty pri
nule pokusu a Retry-After NaN pri max 0. Cely kod naformatovan prettierem.
Testy: 8 souboru, 105 testu (prava, viditelnost, podminky a opakovani
v executoru, redaktor tajemstvi, sitove guardy, migrace resitelu, tickety,
health a prihlaseni pres supertest). Testy odhalily dve chyby ve vyhodnoceni
podminek, obe opravene: chybejici castka se porovnavala jako nula a podminka
nad vystupem druheho kroku cetla hodnotu prvniho se stejnym nazvem.
Pojmenovane konstanty misto magickych hodnot, ctx.util.base64 pro skripty
konektoru, README a dokumentace aktualizovany vcetne znamych odchylek.
15 KiB
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, model ticketu v 18-ticketovaci-system.md.
Webhook odpoví hned, práci udělá worker
POST /webhook/<token>
-> 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:
{
"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.
Prodlevy jsou pole BACKOFF_MS v src/runtime/queue.ts a index do nej je
attempts - 1. Po neuspechu je attempts aspon 1, takze index sedi; kdyby
ale prisla nula, BACKOFF_MS[-1] je undefined a new Date(NaN) by beh
naplanoval na nikdy. noUncheckedIndexedAccess to odhalil, cteni ma proto
zalohu MAX_BACKOFF_MS. Podobna chyba byla v rateLimit: pri max: 0 bylo
Retry-After NaN, ted je aspon 1 sekunda.
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:
titleaimpactč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:
- 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. - 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.
- Web pošle
{"kind":"order.created","data":{"order":{"id":"5001"}}}. - Webhook odpoví 202 za 12 ms, nic nečeká.
- Worker založí ticket, dá mu typ objednávka a štítek čeká na zabalení.
- Změna ticketu spustí druhou automatizaci, ta podle typu a štítku předá práci nejvolnějšímu ze skladu.
- Druhá objednávka jde jinému člověku, protože první už jednu má.
- 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é vruntime/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/pauseumí 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:
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:
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.
Prvni testy executoru (tests/runtime/executor.test.ts, zari 2026) nasly
v podminkach dve skutecne chyby, obe jsou opravene:
- Prazdna hodnota se porovnavala jako nula.
ordered()delalNumber(''), a to je0, takzecastka <= 1000platilo i pro castku, ktera nikdy nedorazila. Ted prazdna strana znamena "neda se porovnat" agt,gte,lt,ltejsou nepravda. Pro "nedorazilo" plati dal pravidlo vyse: ptat seisNotEmpty. - Hole jmeno vystupu vyhravalo nad
krok.jmeno.conditionValue()hledal nejdriv hole jmeno, a to drzi vystup prvniho kroku, ktery ho zapsal. Podminka nad druhym krokem se stejnym nazvem vystupu tak cetla hodnotu z prvniho. Poradi je tedfieldId,krok.jmeno, hole jmeno.
Radek podminky v logu proto nese i to, s cim se porovnavalo, a rozlisuje
nedorazilo od prázdné:
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"