Fronta a worker: webhook odpovi hned, praci udelaji workeri

Webhook uz nic nevykonava v requestu. Zapise udalost do fronty a odpovi 202
do jednotek milisekund; strom vykona worker na pozadi. Za konektory nerucime,
takze cekat na cizi sluzbu v requestu znamena ztracet udalosti pri timeoutu.

Fronta ma opakovani s rostouci prodlevou (30 s, 2 min, 10 min, hodina),
spravedlive poradi po firmach (jedna firma s tisicem udalosti nezablokuje
ostatni), navrat zaseknutych behu po restartu a uklid hotovych. Marna chyba
se neopakuje - chybejici skript za minutu existovat nezacne.

Tri druhy spoustecu: push (webhook), vnitrni udalost (vznik a zmena ticketu)
a pull, tedy pravidelne dotazovani u sluzeb bez webhooku (posta, zpravy).
Planovac jen rekne "je cas", samotny dotaz je prvni krok stromu, takze ma
zaznam v logu a opakuje se pri chybe jako cokoliv jineho.

Kontrakt tela webhooku: kazdy parametr ma cestu (data.order.id,
errors.0.message), takze jde napojit i odesilatel s vnorenym modelem.
U adresy je metoda, ukazka tela a kopiruje se cela adresa vcetne domeny.

Vnitrni kroky, ktere sahaji do naseho uloziste: ticket/upsert (zaloz nebo
dopln podle externiho ID), assign-least-busy, assign-by-external, set-type,
set-stage, add-tags, set-status, incident/create, flow/pause a flow/log.

Faze ticketu jako treti osa vedle stavu a stitku. Stav je zivotni cyklus
a pocitaji se z nej statistiky, faze je workflow daneho typu a muze byt jen
jedna, takze se na ni da spolehnout v podmince.

ID z cizich aplikaci u resitele: voicebot posle voicebotId a ticket skonci
u toho, komu patri. Vazba je na jednom miste, ne v kazde automatizaci.

Kazda chyba zaklada incident se dvema urovnemi: impact cte klient a je
srozumitelny, detail cte admin a je v nem cely beh, ktery krok selhal, cele
hlaseni a data na vstupu. Detail vidi jen spravce platformy.

Ochrana proti smycce: automatizace navazana na zmenu ticketu ticket meni,
cimz se spousti znovu - pri vyvoji to server polozilo. Resi to oznaceni behu
pres AsyncLocalStorage a strop peti behu na jeden ticket za minutu.

Upozorneni pri prideleni prace vcetne cisla u zalozky Tickety. Zivy dashboard:
dlazdice nad nasimi daty na udalost, data z konektoru podle ttlSec s moznosti
vynutit nacteni znovu.

Opraveno: path a intervalSec u spoustece se pri ulozeni zahazovaly; nad
seznamem neslo pouzit contains, takze na stitky neslo postavit podminku;
novejsi vystup kroku ted prekryje starsi misto hlaseni konfliktu.

Overeno dvema scenari proti bezicimu serveru, 34 kontrol: firma se skladem,
expedici a IT, a hovory z voicebota (callSid do externiho ID, status do faze,
prirazeni podle voicebotId, tri zpravy = jeden ticket se tremi udalostmi).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 16:41:02 +02:00
co-authored by Claude Opus 5
parent 5d186dcd2e
commit a57eca123e
38 changed files with 2942 additions and 112 deletions
+6 -1
View File
@@ -47,7 +47,11 @@ 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 | castecne | strom se vykona, ale synchronne a bez fronty |
| Beh automatizaci | hotovo | fronta, worker, opakovani, ochrana proti smycce |
| Prijem udalosti do fronty | hotovo | webhook odpovi 202, praci dela worker |
| Pravidelne dotazovani sluzeb | hotovo | planovac pro postu a zpravy, perioda u spoustece |
| Upozorneni na pridelenou praci | hotovo | cislo u zalozky a hlaska v portalu |
| Incident z chyby | hotovo | popis pro klienta, podrobnosti pro admina |
| 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 |
@@ -127,4 +131,5 @@ pro frontu, beh kroku a rozpocet na 150 klientu, a
| [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 |
| [20-fronta-a-runtime.md](20-fronta-a-runtime.md) | fronta, worker, spoustece, ochrana proti smycce |
| [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore |
+18
View File
@@ -36,6 +36,9 @@ Vyzaduji `Authorization: Bearer <token>`:
| GET | `/api/dashboard/intake` |
| POST | `/api/dashboard/intake/regenerate` |
| GET | `/api/dashboard/settings/actions/:id/scope` |
| GET | `/api/dashboard/notifications` |
| POST | `/api/dashboard/notifications/read` |
| GET | `/api/dashboard/runs` |
| GET | `/api/dashboard/tickets` |
| GET | `/api/dashboard/tickets/workload` |
| GET | `/api/dashboard/tickets/:id` |
@@ -227,6 +230,21 @@ tentyz klic.
Neznamy `typeId` se zahodi a zaloguje, ticket vznikne bez typu. Odmitnout celou
udalost kvuli jednomu poli by znamenalo ztratu dat.
## Fronta behu
Popis je v [20-fronta-a-runtime.md](20-fronta-a-runtime.md).
`POST /webhook/:token` vraci **202**, ne 200: data jsme prevzali a strom se
vykona na pozadi. Vysledek se hleda v `GET /api/dashboard/runs` nebo v logu
ticketu. Cekat na cizi sluzbu v requestu nejde - za jeji rychlost nerucime
a odesilateli by vyprsel timeout.
`GET /webhook/:token` vraci **kontrakt**: co se v tele ceka, na jakych cestach
a ukazku. Bez toho by musel ten, kdo webhook zapojuje, hadat.
`GET /api/dashboard/runs` ma u kazdeho behu cele chybove hlaseni, pocet pokusu
a kdy se to zkusi znovu.
## Prava a navigace
`GET /api/dashboard/access` vraci `permissions` (efektivni prava po slouceni
+7
View File
@@ -37,6 +37,13 @@ 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. |
| `enqueue(input)` | `src/runtime/queue.ts` | Zařadí běh. Klíč proti dvojímu zařazení drží jeden běh na jednu událost. |
| `claimBatch(limit)` | `src/runtime/queue.ts` | Vezme další práci, spravedlivě po firmách. Místo, kde nad Postgresem musí být SKIP LOCKED. |
| `onTicketEvent(kind, ticket)` | `src/runtime/triggers.ts` | Změna ticketu zařadí navázané automatizace, včetně ochrany proti smyčce. |
| `withRun(marker, work)` | `src/runtime/context.ts` | Označí, který běh práci způsobil. Bez toho automatizace spouští sama sebe. |
| `findBuiltinStep(...)` | `src/runtime/builtinSteps.ts` | Kroky, které sahají do našeho úložiště, ne ven přes HTTP. |
| `findPersonByExternalId(...)` | `src/data/people.ts` | Řešitel podle ID z cizí aplikace, například voicebotId. |
| `notify(input)` | `src/data/notifications.ts` | Upozorní člověka. Nečeká se a nevyhazuje chyby, stejně jako audit. |
| `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. |
+210
View File
@@ -0,0 +1,210 @@
# 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/<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**:
```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**.
## Opakování a vzdání se
| Pokus | Kdy |
| --- | --- |
| 1. | hned |
| 2. | za 30 s |
| 3. | za 2 min |
| 4. | za 10 min |
| 5. | za hodinu |
Pak běh skončí jako `failed` a zůstane k nahlédnutí. Nemaže se: bez záznamu
by nikdo nezjistil, že se něco nestalo.
**Marná chyba se neopakuje vůbec.** Chybějící skript nebo neexistující skupina
za minutu existovat nezačne, takže se běh rovnou vzdá. Opakuje se jen to, co
může pominout: nedostupná služba, timeout.
## 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.
## 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/set-stage` | posune do další fáze workflow daného typu |
| `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` | pevná čtveřice (nový, v řešení, čeká, vyřešeno) | životní cyklus, počítají se z něj statistiky a fronta |
| `stage` | firma u typu ticketu (`TicketType.statuses`) | postup uvnitř typu: čeká na zabalení, předáno dopravci |
| `tags` | kdokoliv, volně | označení, která spolu nemusí souviset |
Fáze může být **jen jedna**, proto se na ni 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 mimo workflow typu se odmítne. Překlep by jinak tiše vyřadil podmínku,
která na fázi stojí.
## Ž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. Timeout a rozlišení "zkusit znovu / marné" už ve
`scripts/http.ts` je.
- **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.
+67
View File
@@ -2,6 +2,73 @@
Nejnovejsi nahore.
## 2026-08-13 - fronta, worker a spoustece
Popis v [20-fronta-a-runtime.md](20-fronta-a-runtime.md).
### Zmeneno zasadne
- **Webhook uz nic nevykonava v requestu.** Zapise udalost do fronty a odpovi
202 do jednotek milisekund. Strom vykona worker na pozadi. Za konektory
nerucime, takze cekat na cizi sluzbu v requestu znamena ztracet udalosti.
### Pridano
- `runtime/queue.ts`: fronta behu v ulozisti. Opakovani s rostouci prodlevou
(30 s, 2 min, 10 min, hodina), spravedlive poradi po firmach, navrat
zaseknutych behu po restartu, uklid hotovych.
- `runtime/worker.ts`: bere praci z fronty, ctyri behy naraz.
- `runtime/triggers.ts`: tri druhy spoustecu. Push (webhook), vnitrni udalost
(vznik a zmena ticketu) a **pull, tedy pravidelne dotazovani** u sluzeb,
ktere webhooky nemaji - posta, zpravy. Planovac jen rekne "je cas", samotny
dotaz je prvni krok stromu.
- **Kontrakt tela webhooku.** Kazdy parametr ma cestu (`data.order.id`,
`errors.0.message`), takze jde napojit i odesilatel s vnorenym modelem.
U adresy je videt metoda, ukazka tela podle parametru a kopiruje se cela
adresa vcetne domeny.
- Vnitrni kroky: `ticket/upsert` (zaloz nebo doplň podle externiho ID),
`assign-least-busy`, `assign-by-external`, `set-type`, `set-stage`,
`add-tags`, `set-status`, `incident/create`, `flow/pause`, `flow/log`.
- **Faze ticketu** (`stage`) jako treti osa vedle stavu a stitku. Stav je
zivotni cyklus a pocitaji se z nej statistiky, faze je workflow daneho typu
a muze byt jen jedna, takze se na ni da spolehnout v podmince.
- **ID z cizich aplikaci u resitele** (`externalIds`). Voicebot posle
`voicebotId` a ticket skonci u toho, komu patri. Vazba je na jednom miste,
ne v kazde automatizaci.
- **Upozorneni**: komu prijde ticket, ten to vidi hned, vcetne cisla u zalozky.
- **Incident z kazde chyby** se dvema urovnemi: `impact` cte klient a je
srozumitelny, `detail` cte admin a je v nem cely beh, ktery krok selhal,
cele hlaseni a data na vstupu. `detail` se vraci jen spravci platformy.
- **Ochrana proti smycce.** Automatizace navazana na zmenu ticketu ticket
meni, cimz se spousti znovu - pri vyvoji to server polozilo. Resi to
oznaceni behu (`AsyncLocalStorage`) a strop peti behu na ticket za minutu.
- Zivy dashboard: dlazdice nad nasimi daty se prekresli na udalost, data
z konektoru drzi server podle `ttlSec` a jde vynutit nacteni znovu.
### Opraveno
- `path` a `intervalSec` u spoustece se pri ulozeni zahazovaly, takze kontrakt
webhooku nefungoval.
- Nad seznamem neslo pouzit `contains`, takze na stitky neslo postavit
podminku. Prave na tom stoji prideleni prace.
- Novejsi vystup kroku ted prekryje starsi se stejnym jmenem. Driv to builder
hlasil jako konflikt i tam, kde zadny nebyl.
- Marna chyba (chybejici skript, neexistujici skupina) se uz neopakuje petkrat.
### Overeno
Dva scenare proti bezicimu serveru, 34 kontrol celkem:
1. **Firma se skladem, expedici a IT.** Webhook odpovedel za 12 ms, worker
zalozil ticket, dal mu typ a stitek, druha automatizace ho podle typu
a stitku predala nejvolnejsimu ze skladu. Druha objednavka sla jinemu
cloveku. Chyba z prevodniku dokladu prisla vnorenou cestou, skoncila u IT
a zalozila incident.
2. **Hovory z voicebota.** Telo `{callSid, status, voicebotId}`: callSid do
externiho ID, status do faze, prirazeni podle voicebotId. Tri zpravy
o tomtez hovoru daly **jeden ticket** se tremi udalostmi. Neznamy voicebot
neskoncil tise - je videt ve fronte i jako incident.
## 2026-08-13 - runtime, prokliky z widgetu a kapacitni rozbor
### Pridano