Skripty konektoru: vykonna cast s manifestem a kontrolou parametru

Konektory dostaly vykonnou cast. Jeden skript je jeden soubor, ktery nese
manifest (vstupni a vystupni parametry) i kod. Diky manifestu s nim umi
pracovat strom automatizace, aniz by o kodu cokoliv vedel.

Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se
musel prelozit a to je presne to otaceni, ktere tady nema byt. Registr
sleduje cas zmeny souboru, takze uprava v portalu, rucni uprava souboru
i novy soubor ve slozce funguji stejne a bez restartu.

Pridano:
- scripts/ se skripty konektoru, nazev souboru je zaroven ID operace
- kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany.
  Chybejici povinny vystup je chyba skriptu, ne uzivatele - jinak by strom
  veril parametru, ktery nikdy nedosel
- ctx predavany skriptu: http nad adresou napojeni, util, log, config,
  idempotencyKey, fail a retry. Skript nedostane pristupove udaje
- rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku,
  vzdy vraci vysledek vcetne retryable
- redakce tajnych hodnot pred zapisem do logu. Cizi API rado vraci prijaty
  token v chybove zprave a log ticketu vidi klient
- napojeni z environment variables vcetne iDokladu
- sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby
  services.csbot.cz/apps/idoklad, kazdy na jiny vzor
- stranka /dashboard/skripty: seznam, manifest, editor, zkusebni spusteni.
  Formular testu se sklada z manifestu, nepise se pro kazdy skript
- endpointy /api/dashboard/scripts vcetne Swaggeru

Zmeneno:
- katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji
  prekryvem v src/data/connectors.ts, takze se naraz objevi ve validaci
  stromu, ve vypoctu scope i v sablonach. Pri stejnem ID vyhrava skript
- ConnectorOperation ma implementation a scriptId
- ApiError na klientovi nese cele telo odpovedi a umi z nej vytahnout issues
- Dockerfile kopiruje scripts/ do vysledneho image

Ukladani nemuze rozbit fungujici skript: kod se nejdriv zapise do docasneho
souboru, ten se nacte a overi, a az pak prepise puvodni.

K tomu tri dokumenty navrhu dalsich kroku: 09 datove modely a prava,
10 runtime a rozpocet na 150 klientu, 11 popis skriptu konektoru.

Overeno: npm run typecheck prochazi na serveru i webu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 13:37:58 +02:00
co-authored by Claude Opus 5
parent bbc2236c0d
commit 6f6b287d7e
34 changed files with 5546 additions and 14 deletions
+10
View File
@@ -30,6 +30,7 @@ React aplikaci ze slozky `dist/public`.
| Kanaly WhatsApp, FB, Instagram | hotovo | vcetne vzorovych automatizaci na prijem |
| Firmy a prava | hotovo | tri pohledy, uzivatel muze byt ve vic firmach |
| Nastavitelny dashboard | hotovo | widgety, sirky a poradi, ulozene za uzivatele a firmu |
| Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad |
| Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu |
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
| Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime |
@@ -63,3 +64,12 @@ v [05-dashboard-a-builder.md](05-dashboard-a-builder.md).
Za rozmysleni stoji evidence bugs a wishes. Zamerne to nejsou tickety,
duvod je v [06-tickety.md](06-tickety.md).
Prvni cast navrhu uz je hotova: vykonna cast konektoru, tedy skripty
s manifestem a kontrolou parametru, viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
Runner je pripraveny, chybi nad nim fronta.
Zbytek navrhu je ve dvou souborech, oba jsou navrh k rozhodnuti, ne popis stavu:
[09-navrh-rozsireni.md](09-navrh-rozsireni.md) pro datove modely a prava,
[10-runtime-a-kapacita.md](10-runtime-a-kapacita.md) pro frontu, beh kroku
a rozpocet na 150 klientu.
+24
View File
@@ -37,6 +37,11 @@ Vyzaduji `Authorization: Bearer <token>`:
| POST | `/api/dashboard/tickets/:id/comment` |
| GET | `/api/dashboard/incidents` |
| GET | `/api/dashboard/connectors` |
| GET | `/api/dashboard/scripts` |
| GET | `/api/dashboard/scripts/:id` |
| PUT | `/api/dashboard/scripts/:id` |
| POST | `/api/dashboard/scripts/:id/test` |
| POST | `/api/dashboard/scripts/reload` |
| GET | `/api/dashboard/stream` |
| GET | `/api/dashboard/automations` |
| POST | `/api/dashboard/automations` |
@@ -166,6 +171,25 @@ 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.
## Skripty konektoru
Popis modelu je v [11-skripty-konektoru.md](11-skripty-konektoru.md), tady jen API.
Cteni smi kazdy prihlaseny, protoze builder potrebuje vedet, co skript umi.
Uprava, zkusebni spusteni a vynucene nacteni smi **jen spravce platformy** -
uprava skriptu meni chovani vseho, co ho pouziva.
`GET /api/dashboard/scripts` vraci vedle manifestu i `problems` s rozbitymi
skripty a `connections` se stavem napojeni. **Hodnoty pristupovych udaju se
nevraci nikdy**, jen jmena chybejicich environment variables.
`PUT /api/dashboard/scripts/:id` kod nejdriv nacte a overi a az pak prepise
soubor. Rozbita uprava vraci 400 s `issues` a puvodni skript dal funguje.
`POST /api/dashboard/scripts/:id/test` **vola opravdovou sluzbu**. Chyba skriptu
neni chyba API, vraci se 200 a popis v `error` vcetne toho, jestli ma smysl
zkusit to znovu.
## Pri pridani endpointu
Soucasne aktualizovat `src/openapi.ts` a tenhle soubor. Swagger musi odpovidat
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
# 10 - Runtime, vykonna cast a kapacita
Navrh, ne popis stavu. Runtime neexistuje, dnes se ulozeny strom nevykonava.
Souvisejici navrh datovych modelu je v [09-navrh-rozsireni.md](09-navrh-rozsireni.md).
Tenhle soubor odpovida na tri veci: jak se vyhodnocuji kroky, jak se prijimaji
udalosti a co to znamena pri 150 klientech.
## Nejdriv cisla, pak architektura
Zadani: 150 klientu, kazdy asi 5 systemu, z nich chodi radove desitky udalosti.
To je 750 napojeni. "Desitky udalosti" ma dve cteni a **odpoved se mezi nimi
podstatne lisi**, takze obe:
| Scenar | Desitky udalosti za | Udalosti/den | Kroku/den | Prumer | Spicka |
| ------ | ------------------- | ------------ | ---------- | ------- | -------- |
| A | den a system | 37 tisic | 375 tisic | 4 kr/s | 30-60/s |
| B | hodinu a system | 450 tisic | 4,5 mil | 52 kr/s | 200-400/s|
Pocitano s 10 kroky na udalost, coz je stredni automatizace. Spicka vychazi
z toho, ze provoz je v osmihodinovem okne a uvnitr nerovnomerny, tedy radove
osmkrat nad prumerem.
**Planovat se musi na kroky, ne na udalosti.** Jedna udalost s trisetkrokovym
stromem stoji tristakrat vic nez udalost s jednim krokem. Az bude runtime bezet,
je metrika kroku za sekundu ta jedina, podle ktere se da neco rict.
### Co je a co neni uzke misto
| Vec | Scenar A | Scenar B |
| ----------------------- | --------------- | ------------------------------ |
| Fronta v Postgresu | par procent | zvladne, ale s davkovym odberem|
| Soubezne HTTP volani | 15 soubezne | 90 soubezne, Node se nezapoti |
| **Zapis `run_step`** | 22 GB/mesic | **270 GB/mesic, nutne zkratit**|
| Limity cizich API | uzke misto | uzke misto |
Fronta nad Postgresem s `FOR UPDATE SKIP LOCKED` uklidne obslouzi radove
200 az 500 uloh za sekundu na jednom uzlu, kdyz se odebira davkove. Scenar A
je tedy nezajimavy a scenar B je v pohodlnem pasmu.
**Skutecne uzke misto je objem zapisu.** Radek `run_step` s vstupem a vystupem
v JSONB ma realne 1 az 3 kB. Pri scenari B je to 9 GB denne, coz za pul roku
nikdo neuklidi. Reseni je v sekci o retenci a je to jedina vec z celeho navrhu,
kterou **nelze odlozit na potom**.
Druhe uzke misto je za nasimi hranicemi. iDoklad, WhatsApp ani ekonomicky system
nesnesou desitky pozadavku za sekundu na jeden ucet. Skalovani workeru bez limitu
za napojeni znamena jen rychleji dojit k odpovedi 429.
### Co pri teto velikosti nepotrebujeme
Rict to nahlas, aby se to nestavelo: **zadna Kafka, zadny Redis, zadny Kubernetes,
zadne sharding.** 150 klientu je pro jeden Postgres a par procesu Node maly
provoz. Usili patri do idempotence, spravedlnosti mezi klienty a pozorovatelnosti,
ne do infrastruktury.
Odhad velikosti:
| Scenar | Postgres | Workeri | Kde to bezi |
| ------ | ------------------ | --------------------------- | ----------- |
| A | 4 vCPU, 16 GB | 2 procesy, 50 soubezne | jeden stroj |
| B | 8-16 vCPU, 32 GB, NVMe | 4-6 procesu, 100 soubezne | dva stroje |
## Moznosti u databaze
| Varianta | Verdikt pri 150 klientech |
| -------------------------------- | ------------------------------------------------ |
| Jedno DB, `tenant_id` ve sloupci | **ano, tohle** |
| Schema na klienta | ne: 150 x 20 tabulek je 3000 tabulek, migrace se stanou nespolehlivymi |
| Databaze na klienta | ne, ale nechat si dvere otevrene |
| Partitionovani podle klienta | ne, oddily by byly velikostne nesouvisle |
| Partitionovani podle casu | **ano, u pripisovacich tabulek** |
### Dvere k oddelene databazi za par korun
Jednou prijde klient, ktery bude chtit vlastni databazi, nebo bude delat
tricet procent provozu. Aby to pak nebyla prestavba, staci **jedna vec od zacatku**:
pristup k poolu jen pres `dbFor(tenantId)`, i kdyz zpocatku vraci porad tentyz
pool. K tomu **nikdy nespojovat dotazem dva klienty**, coz uz vynucuje povinny
argument `tenantIds` v ulozistich.
Splneni tehle dvou podminek znamena, ze presun jednoho klienta do vlastni
databaze je konfigurace, ne prepisovani dotazu.
### Pool a jedno pravidlo, na kterem to stoji nebo pada
**Worker nesmi drzet spojeni do databaze po dobu volani ciziho API.** Je to
nejcastejsi zpusob, jak takovy system umre. Volani do iDokladu trva 300 ms.
Kdyz drzi spojeni, znamena 90 soubeznych kroku 90 obsazenych spojeni a pool
skonci.
Spravne poradi:
```
transakce: odeber ulohu (2 ms) - spojeni drzim
uvolni spojeni
volani ciziho API (300 ms) - spojeni nedrzim
transakce: zapis vysledek (2 ms) - spojeni drzim znovu
```
Pri tomhle poradi staci na 90 soubeznych kroku 3 az 5 spojeni. Bez nej 90.
Az bude workeru vic, prijde PgBouncer v transakcnim rezimu. **Pozor: v transakcnim
rezimu nefunguje `LISTEN/NOTIFY`**, a prave na nem ma podle
[09-navrh-rozsireni.md](09-navrh-rozsireni.md) stat sbernice udalosti pro SSE.
Ta potrebuje prime spojeni mimo PgBouncer. Zjistit to az pri nasazeni znamena
rozbity zivy dashboard.
## Cesta udalosti: tri oddelene faze
```
POST /webhook/:token -> event_inbox 202, rychle a hloupe
|
dispatcher udalost na N behu
|
worker krok po kroku
```
Rozdeleni na tri faze neni akademicke. Kazda ma jinou vlastnost: prijem musi byt
rychly, rozeslani musi byt idempotentni, vykonavani musi byt prerusitelne.
### 1. Prijem: rychle a hloupe
```sql
event_inbox(id, tenant_id, token_id, source_kind, source_id,
idempotency_key, payload jsonb, received_at,
status, dispatched_at, cause_run_id, depth)
unique index on (token_id, idempotency_key)
```
Webhook **nesmi vyhodnocovat strom**. Overi token, overi velikost, zapise jeden
radek, vrati 202. Cil je p99 pod 50 ms.
Duvod je praktickeho razu: odesilatel pri timeoutu opakuje. Kdyz webhook ceka
na iDoklad, pomaly iDoklad zpusobi, ze tataz objednavka prijde tri krat.
- **Idempotence pri prijmu.** Hlavicka `Idempotency-Key`, nebo hash tela, kdyz
ji odesilatel neposila. Unikatni index nad `(token_id, idempotency_key)`
v okne 24 hodin. Duplikat vrati 202 a stejne ID udalosti, ne chybu - pro
odesilatele to je uspech, protoze jeho udalost je prijata.
- **Limit za token.** Jeden rozbity klient ve smycce nesmi zaplnit inbox.
- **Strop velikosti tela**, radove 256 kB, vetsi 413.
- **Backpressure.** Kdyz hloubka fronty prekroci hranici, vracet 429 tokenum,
ktere nejsou oznacene jako kriticke. Odesilatele 429 umi, na rozdil od tiche
latence rostouci do minut.
### 2. Vlastni udalosti nechodi pres HTTP
Podle zadani budou udalosti vznikat volanim na webhook z jinych automatizaci.
U cizich odesilatelu ano. **U nasich vlastnich ne.**
Volat vlastni HTTP endpoint na sebe pridava latenci, obsazuje spojeni a zaklada
poruchu, ktera nemusi existovat. Vnitrni udalost zapise radek do `event_inbox`
primo, prochazi tim samym dispatcherem a je v tom samem prehledu. Webhook zustava
pro to, co prichazi zvenci.
### 3. Ochrana proti smycce musi projit skrz udalost
Tohle je nejvaznejsi dusledek toho, ze automatizace vyrabeji udalosti pro jine
automatizace.
`depth` na behu chrani jen **uvnitr jednoho behu**. Kdyz automatizace A vyrobi
udalost, ktera spusti B, a B vyrobi udalost, ktera spusti A, tak kazdy jednotlivy
beh ma hloubku 1 a kontrola nikdy nezasahne. Smycka pobezi, dokud ji nekdo
nevsimne na uctu za cizi API.
Proto `event_inbox` nese `cause_run_id` a `depth`, a plati:
```
depth nove udalosti = depth behu, ktery ji vyrobil, + 1
depth > 5 -> udalost se odmitne, zapise se do logu a upozorni se
```
Bez tohohle jednoho sloupce je navrh z bodu 9 generator nekonecnych smycek.
### 4. Rozeslani
Dispatcher najde automatizace, ktere na dvojici klient a spoustec sedi, a zalozi
beh pro kazdou.
- **Filtr na spousteci se vyhodnoti tady**, jeste pred zalozenim behu. Je to
odpoved na otevrene rozhodnuti z konce [06-tickety.md](06-tickety.md) a pri
tomhle objemu to neni kosmetika: beh, ktery hned skonci, stejne zaplati zapis
do `run`, `run_step` i `ticket_trace`.
- **Jedna transakce**: oznac udalost jako rozeslanou, zaloz behy, zaloz prvni
ulohy. Bud vse, nebo nic.
- **Unikatni index nad `(event_id, automation_id)`.** Kdyz dispatcher padne
uprostred, opakovane rozeslani nezalozi druhy beh.
## Vykonna cast: co ridi prechod mezi kroky
Dve veci, oddelene.
**1. Cista funkce.** `next(tree, path, outputs): FlowPath | null` rozhodne, ktery
krok je dalsi. Zadny stav, zadne IO, testovatelne. Podminka se vyhodnoti nad
`outputs` a vybere vetev. Chuze po strome uz z poloviny existuje ve
`web/src/lib/flow.ts` a `src/data/flowScope.ts`.
**2. Radek v tabulce.** Fakticky prubeh behu drzi zaznam ulohy v databazi,
ne pamet procesu. **Nikdy `setTimeout`, nikdy dlouhy retez promisu, nikdy
rekurze drzici cely beh.** Restart containeru je bezna vec a beh ho musi prezit.
```sql
run(id, tenant_id, source_kind, source_id, source_version,
event_id, trigger_type, status, depth, queue_key,
started_at, finished_at, error)
run_step(id, run_id, path, step_id, attempt, status,
input jsonb, output jsonb, error, started_at, finished_at)
job(id, run_id, tenant_id, next_path, run_after, attempts,
queue_key, locked_by, locked_until, priority)
```
`source_kind` a `source_version` rikaji, jestli beh patri automatizaci nebo akci
a podle ktere verze jeji definice se ma dokoncit. To je to, co dela z cekaciho
kroku fungujici vec: beh cekajici tyden dobehne podle stromu, ktery platil pri
jeho spusteni.
`run_after` v tabulce `job` je zaroven **cele cekani z bodu 8**. Krok `wait` neni
v runtimu vyjimka, je to obycejny krok, ktery misto volani sluzby nastavi
`run_after` a skonci. Proto ten bod skoro nic nestoji.
### Smycka workeru
```
1. transakce: odeber DAVKU uloh
SELECT ... FROM job
WHERE run_after <= now()
AND (locked_until IS NULL OR locked_until < now())
AND tenant_id <> ALL (:klienti_na_stropu)
ORDER BY priority, run_after
FOR UPDATE SKIP LOCKED LIMIT 25
UPDATE job SET locked_by = :worker, locked_until = now() + interval '2 min'
2. uvolni spojeni, vykonej ulohy soubezne, kazda PRAVE JEDEN krok
3. za kazdou ulohu transakce: zapis vysledek kroku
smaz hotovou ulohu
zarad dalsi ulohu podle next()
```
**Krok 3 v jedne transakci je cely trik.** Bud se zapise vysledek i dalsi uloha,
nebo nic. Nikdy nevznikne beh, ktery ma hotovy krok a nema pokracovani, ani
dvakrat zarazeny stejny krok.
**Davkovy odber je nejvetsi pacidlo na propustnost.** Po jedne uloze znamena pri
300 krocich za sekundu 300 odberovych transakci za sekundu. Po dvaceti peti
je jich dvanact. Je to jedna zmena `LIMIT` a nekolikanasobne mensi zatez.
`SKIP LOCKED` znamena, ze workeru muze byt libovolne mnoho a nepotrebuji
koordinatora. Za zvazeni stoji `graphile-worker` - je to tentyz princip nad
Postgresem, overeny provozem. Rucne az kdyz bude potreba spravedlnost podle
`queue_key`, kterou hotova knihovna neresi.
### Spravedlnost mezi klienty
Ciste FIFO znamena, ze jeden vecerni import u jednoho klienta zastavi ostatnich
149. Pri 150 klientech to neni hypoteza, je to otazka casu.
Prakticky pouzitelna verze je dvojice:
- **Semafor za klienta ve workeru**: nejvyse N soubeznych kroku na klienta.
- **Odberovy dotaz preskoci klienty na stropu** (`tenant_id <> ALL (...)`).
Seznam si worker drzi sam a je aktualni na jednu davku.
Presna spravedlnost cistym SQL je slozita a nevyplati se. Tohle je odhadem
o dva rady jednodussi a rozdil nikdo nepozna.
K tomu dve dalsi hranice:
- **Limit a rychlostni strop za napojeni, ne za konektor.** Kvota je na uctu
klienta v iDokladu, ne na tom, ze iDoklad existuje. Zetonovy kosik jednim
`UPDATE ... RETURNING` nad radkem napojeni.
- **Serializace nad jednim ticketem.** `queue_key = ticket:<id>` a jen jedna
bezici uloha na klic. Bez toho dve automatizace prepisuji stav teze veci
a poradi neni dane.
### Pady
- **Lease.** Worker padne uprostred kroku, `locked_until` vyprsi, ulohu si vezme
jiny. Nic se neztrati.
- **Kroky musi byt idempotentni.** Kazdy krok dostane
`idempotencyKey = runId + ':' + path`, **stabilni pres vsechny pokusy**.
Konektory, ktere umi `Idempotency-Key`, ho dostanou a druhy pokus nevystavi
druhou fakturu. Klic za pokus by byl k nicemu, o tom to cele je.
- **Retry.** Exponencialni backoff s jitterem, radove 5 pokusu. Rozlisit
opakovatelne (timeout, spojeni, 429, 5xx) od koncovych (400, 401, 403,
validace). Koncovou chybu neopakovat, jen se tim vypali kvota.
- **Po vycerpani pokusu** dostane beh stav `failed`, zapise se do logu ticketu
a vznikne udalost. Beh zustane a **lze ho pokracovat od padleho kroku**,
protoze stav je per krok, ne per beh.
- **Limit kroku na beh** a globalni timeout behu.
- **Exactly-once neexistuje.** Cil je at-least-once plus idempotence. Kdo slibi
exactly-once, jen jeste nenasel pripad, kdy to nedrzi.
## Kde bezi skripty
Skripty z bodu 9 jsou cisty prevod dat, ale i tak maji vlastni provozni pravidlo,
a je dulezite:
**Skript nesmi bezet v hlavnim vlakne workeru.** Skript, ktery pocita
pet set milisekund, zablokuje smycku udalosti a s ni **vsechny ostatni soubezne
kroky toho workeru**. Jeden nepovedeny cyklus u jednoho klienta tim zastavi
provoz vsech ostatnich, a v logu to vypada jako pomala cizi API.
Navrh:
- Bazen `worker_threads`, v kazdem `isolated-vm`. Radove tolik vlaken, kolik je
jader.
- Tvrdy timeout 50 az 200 ms a **zabiti vlakna** pri prekroceni, ne zdvorile
preruseni. Prerusit smycku `while (true)` jinak nejde.
- Zkompilovany skript se drzi v cache za verzi, kontext se po N spustenich zahodi
kvuli unikum pameti.
- Rezie kontextu je radove milisekunda, takze tisic skriptovych kroku za sekundu
neni problem.
Kdyz nekdy bude potreba skript, ktery neco vola nebo dlouho pocita, nedostane
vic pravomoci. Stane se **vlastni sluzbou v AppFactory** a v katalogu konektorem,
ktery ji vola. Tim pro nej zacne platit retry, rate limit i audit jako pro kazdy
jiny krok.
Skripty se nikdy nespousti v procesu API. Portal nesmi zpomalit kvuli tomu,
ze nekdo ulozil spatny cyklus.
## Retence a objem, hned pri navrhu schematu
Tohle je jedina vec, ktera pri scenari B rozhoduje o tom, jestli to za pul roku
jde provozovat.
**Zkracovani obsahu.** Plny vstup a vystup kroku se uklada jen u kroku, ktere
selhaly, plus u male vzorku uspesnych. U ostatnich se uklada velikost, hash
a prvnich radove 512 bajtu. Snizi to objem radove desetkrat a neztrati to nic,
co by nekdo cetl - do uspesneho kroku se nikdo nechodi divat.
**Partitionovani po mesicich** u pripisovacich tabulek: `run_step`,
`ticket_trace`, `event_inbox`, `audit`. Mazani stareho oddilu je pak
`DROP TABLE`, ne `DELETE` bezici pres noc.
**Retence** podle toho, kdo to cte:
| Data | Jak dlouho |
| --------------------- | ----------------- |
| Vstupy a vystupy kroku| 30 dni |
| Souhrn behu | 12 mesicu |
| Log ticketu | 90 dni |
| Audit | dele, dane pravni potrebou |
Cisla patri do nastaveni za klienta, protoze delsi retence je dobry duvod
pro drazsi tarif.
## Co se monitoruje
Ne CPU. Ctyri veci, a kazda odpovida na jinou otazku:
| Metrika | Odpovida na |
| ----------------------------------- | --------------------------------- |
| Hloubka fronty | stiha se to |
| **Vek nejstarsi pripravene ulohy** | je to zahlcene, nebo zaseknute |
| Kroku za sekundu, p95 za konektor | kde to drhne |
| Padle behy za hodinu, podil opakovani| co je rozbite |
| Podil kroku za klienta | kdo je hlucny soused |
| Zpozdeni inboxu (prijato az rozeslano) | stiha dispatcher |
Bez veku nejstarsi ulohy se neda odlisit "je hodne prace" od "nic se nedeje",
a to jsou dva uplne jine problemy se stejnou hloubkou fronty.
## Kdy zmenit architekturu
Aby se to nemuselo rozhodovat dopredu. Do te doby plati navrh vyse.
| Signal | Co udelat |
| ---------------------------------------- | ------------------------------------- |
| Fronta zere nad 30 % CPU databaze | vetsi davky, pak fronta v Redisu (BullMQ) |
| Zapisy `run_step` prevalcuji IO | zkratit obsah, vzorkovat, velka tela do objektoveho uloziste |
| Jeden klient dela nad 30 % provozu | vlastni bazen workeru, pak vlastni databaze |
| Fronta roste kazdy den ve spicce | pridat workery, jsou bezstavove |
| Prevazuji chyby 429 z cizich API | limity za napojeni, pak vyjednat kvoty|
| Cekajici behy jdou do stovek tisic | oddelena fronta pro dlouha cekani, aby nezdrzovala bezny odber |
## Jeden container, dve role
AppFactory nasazuje jednu aplikaci, takze worker nebude zvlastni sluzba.
Rozdelit ho **procesne uvnitr image** pres `APP_ROLE=api|worker|both`
s vychozim `both`. Az bude spicka takova, ze behy zpomaluji portal, nasadi se
druha instance s `APP_ROLE=worker`. Zmena je jedna environment variable, zadny
zasah do infrastruktury AppFactory.
Migrace pri startu potrebuji poradovy zamek (`pg_advisory_lock`), aby je pri
soubeznem nasazeni nespustilo vic instanci najednou.
## Poradi, v jakem to stavet
1. `event_inbox`, webhook, idempotence pri prijmu, `depth` skrz udalost.
Bez toho zbytek nema co zpracovavat a smycky jsou otevrene.
2. `run`, `run_step`, `job`, `executeStep`, smycka po jedne uloze.
Nejmensi verze, ktera vykona strom.
3. Retry, lease, idempotency key ke konektorum.
4. Dispatcher s filtrem na spousteci.
5. Krok `wait` a prehled cekajicich behu.
6. Davkovy odber, semafor za klienta, limity za napojeni.
7. Zkracovani obsahu, partitionovani, retence.
8. Bazen vlaken pro skripty.
Body 1 az 3 jsou nutne, aby vubec neco bezelo. Body 6 a 7 jsou to, co odlisuje
scenar A od scenare B, a **daji se dodelat pozdeji bez prestavby** - vyzaduji ale,
aby uz od zacatku existovaly sloupce `tenant_id` a `queue_key` v tabulce `job`
a partitionovani u `run_step`. Pridat oddily do nejvetsi tabulky v systemu az
potom je ta jedina cast, ktera by opravdu bolela.
+315
View File
@@ -0,0 +1,315 @@
# 11 - Skripty konektoru
Tohle uz neni navrh, je to naprogramovane. Navrh, ze ktereho to vzniklo, je
v [09-navrh-rozsireni.md](09-navrh-rozsireni.md), bod 9.
## Co to je
Skript je **vykonna cast konektoru**. Jeden soubor, ktery nese dve veci:
- **manifest** - jak se operace jmenuje, co potrebuje na vstupu, co vraci na vystupu,
- **kod** - co se ma opravdu udelat.
Diky manifestu s nim umi pracovat strom automatizace, aniz by o kodu cokoliv
vedel. Builder z manifestu vykresli pole kroku a podminka za krokem se muze
zeptat na jeho vystupy.
## Kde to je
```
scripts/ soubory skriptu, obycejny JavaScript
_sablona.js sablona ke zkopirovani (podtrzitko = nenacita se)
idoklad.get-issued-invoice.js
...
src/scripts/types.ts co je skript, zod schema manifestu
src/scripts/values.ts kontrola vstupu a vystupu
src/scripts/util.ts pomocne funkce pro skripty, redakce tajemstvi
src/scripts/connections.ts kam se vola a cim se to autorizuje
src/scripts/http.ts HTTP klient predany skriptu
src/scripts/manifest.ts overeni manifestu, prevod na operaci katalogu
src/scripts/registry.ts nacitani ze souboru, hot reload, ukladani
src/scripts/runner.ts spusteni jednoho skriptu
src/routes/scripts.ts API
web/src/pages/dashboard/Scripts.tsx stranka /dashboard/skripty
```
## Nic se neotaci
Soubory jsou zamerne **obycejny JavaScript, ne TypeScript**. TypeScript by se
musel prelozit, a to je presne to otaceni, ktere tady nema byt.
Registr si drzi cas zmeny souboru a pri zmene ho nacte znovu. Prohledava
nejvyse jednou za sekundu, takze cteni katalogu neznamena stat na kazdy dotaz.
Uprava tedy funguje trema cestami a vzdy stejne:
| Kudy | Co se stane |
| -------------------------------- | -------------------------------------------- |
| Editor v portalu | ulozi soubor, registr ho nacte hned |
| Rucni uprava souboru na serveru | registr si zmeny vsimne pri dalsim dotazu |
| Novy soubor ve slozce | objevi se jako nova operace v katalogu |
`POST /api/dashboard/scripts/reload` to jen vynuti hned, bez cekani.
## Nazev souboru je ID
Soubor se jmenuje `<konektor>.<operace>.js` a `manifest.id` musi byt stejne.
Nesoulad je chyba, ne varovani - jinak by se skript ulozil pod jednim jmenem
a nacetl pod druhym.
```
scripts/idoklad.get-issued-invoice.js
\_____/ \________________/
konektor operace
```
Z ID se dopocita, do ktereho konektoru operace patri, takze se to nepise
dvakrat. Konektor **musi existovat** v `src/data/connectors.ts`, jinak se skript
ohlasi jako problem.
## Manifest
```js
export const manifest = {
id: 'idoklad.get-issued-invoice',
name: 'Získat vydanou fakturu',
description: 'Načte vydanou fakturu z iDokladu podle jejího ID.',
timeoutMs: 15000, // nepovinne
inputs: [ /* ScriptField */ ],
outputs: [ /* ScriptField */ ],
};
```
Parametr je pro vstup i vystup **tentyz tvar**. Kontrola je pak jedna funkce,
ne dve skoro stejne, ktere by se casem rozesly.
| Klic | K cemu |
| ----------- | ------------------------------------------------------------- |
| `id` | pouziva se v sablonach jako `{{id}}`, jen pismena a podtrzitka |
| `label` | co vidi uzivatel v builderu |
| `type` | `string`, `number`, `boolean`, `date` |
| `required` | u vstupu: bez hodnoty se skript nespusti. U vystupu: musi ho vratit |
| `hint` | napoveda pod polem |
| `options` | vyber z hodnot, jina neprojde |
| `pattern` | dalsi kontrola regularnim vyrazem (jen `string`) |
| `multiline` | pole na vic radku (jen `string`) |
| `default` | dosadi se, kdyz hodnota chybi a parametr neni povinny |
Schema manifestu je `.strict()`. Preklep v nazvu klice (`outputFileds`) se ohlasi,
ne tise ignoruje.
## Kontrola vstupu a vystupu
Poradi je vzdy stejne: **overit vstup, spustit, overit vystup**.
Overeni vystupu neni pridavek. Bez nej by strom veril parametru, ktery nikdy
nedosel, a podminka za krokem by se rozhodovala podle `undefined`.
Pravidla:
- povinny parametr bez hodnoty je chyba, ne prazdny retezec,
- nepovinny parametr bez hodnoty dostane `default`, jinak `null`,
- hodnota se prevede na deklarovany typ, kdyz to jde bez hadani. Ceska
desetinna carka projde, `"ano"` u typu boolean taky,
- parametr, ktery v manifestu neni, se zahodi a zaloguje. Stejne jako u webhooku:
odesilatele posilaji i vlastni data a odmitat je by rozbijelo integrace,
- chyby se vraci **vsechny najednou**, ne jen prvni.
Chybejici povinny vystup je chyba **skriptu**, ne uzivatele, a hlasi se jinym
druhem (`output`).
## Co skript ma a co nema
Skript ma jen `ctx`. Zadny import, zadny pristup na sit mimo `ctx.http`
a **zadne pristupove udaje**.
```js
export async function run(inputs, ctx) { /* ... */ }
```
| Na kontextu | K cemu |
| ------------------ | ---------------------------------------------------------- |
| `ctx.http` | `get`, `post`, `patch`, `put`, `del` nad adresou napojeni |
| `ctx.util` | pomocne funkce, viz nize |
| `ctx.log` | radek do logu behu, vzdy zredigovany a zkraceny |
| `ctx.config` | necitliva cast nastaveni napojeni |
| `ctx.idempotencyKey` | stabilni pres vsechny pokusy tehoz kroku |
| `ctx.fail` | koncova chyba, neopakuje se |
| `ctx.retry` | docasna chyba, ma smysl zkusit znovu |
Adresu i autorizacni hlavicky doplnuje runtime podle napojeni. Skript rika
`GET /issued-invoices/12` a nic vic. Duvod je v bodu 9 navrhu: kdyby skript
znal tajemstvi, staci jeden `ctx.log` a je v logu, ktery vidi klient.
### Pomocne funkce
Cizi API vraci pokazde jinak. iDoklad pouziva velka pocatecni pismena a nekde
obaluje odpoved do `Data`. Bez tehle sady by to kazdy skript resil znovu a jeden
z nich by to resil spatne.
| Funkce | Co dela |
| -------------------------- | -------------------------------------------------- |
| `unwrap(body)` | rozbali `{ Data: x }` i `{ data: x }` |
| `pick(obj, ...names)` | prvni existujici pole bez ohledu na velikost pismen |
| `first(value)` | prvni prvek pole, nebo null |
| `text`, `num`, `bool`, `date` | prevody s fallbackem |
| `round(value, decimals)` | zaokrouhleni, uctuje se v halerich |
| `need(value, label)` | vrati hodnotu, nebo skonci citelnou chybou |
## Chyby: opakovatelne a koncove
Rozdeleni je to podstatne. Timeout nebo 503 ma smysl zkusit znovu, spatny vstup
nebo 403 ne - opakovat koncovou chybu jen vypali kvotu u cizi sluzby.
| Druh | Kdy | Opakovat |
| ------------ | ------------------------------------------ | -------- |
| `not_found` | skript neexistuje | ne |
| `config` | chybi pristupove udaje, 401, 403 | ne |
| `validation` | vstup neprosel kontrolou | ne |
| `output` | skript nevratil deklarovany vystup | ne |
| `terminal` | 400, 404, jina koncova odpoved sluzby | ne |
| `retryable` | 408, 429, 5xx, chyba spojeni | ano |
| `timeout` | skript nedobehl v limitu | ano |
| `internal` | neocekavana vyjimka ve skriptu | ne |
Runner **nikdy nevyhodi vyjimku**. Vzdy vrati vysledek s `ok`, `outputs`, `logs`,
`durationMs`, `httpCalls` a pripadne `error` vcetne `retryable`. Az bude
existovat runtime automatizaci, bude tohle jeho jediny vstupni bod na kroku.
## Napojeni
Zatim jedno napojeni na konektor, sestavene z environment variables. Cilovy stav
je napojeni za firmu v databazi, viz bod 9 navrhu. Az to bude, prepise se vnitrek
`resolveConnection` a nic dalsiho.
| Promenna | K cemu |
| --------------------------- | --------------------------------------------------- |
| `SERVICES_BASE_URL` | zaklad adres, vychozi `https://services.csbot.cz/apps` |
| `<KONEKTOR>_BASE_URL` | presmerovani jednoho konektoru |
| `IDOKLAD_CLIENT_ID` | povinne pro iDoklad, jde do `X-ClientId` |
| `IDOKLAD_CLIENT_SECRET` | povinne pro iDoklad, jde do `X-ClientSecret` |
| `IDOKLAD_APPLICATION_ID` | jen partnerske aplikace |
| `SCRIPTS_DIR` | jina slozka se skripty |
| `SCRIPT_TIMEOUT_MS` | vychozi strop na beh, 15000 |
| `SCRIPT_MAX_RESPONSE_BYTES` | strop na velikost odpovedi, 1000000 |
| `ALLOW_PRIVATE_TARGETS` | povoli volani na localhost, **jen pro lokalni vyvoj** |
Autorizace konektoru je popsana v `authSpecs` v `src/scripts/connections.ts`.
Novy konektor s pristupovymi udaji znamena jeden zaznam v teto tabulce.
**Hodnoty se z API nikdy nevraci.** `GET /api/dashboard/scripts` posila jen jmena
chybejicich promennych a jmena vyplnenych hlavicek, nikdy hodnoty (AGENTS.md).
## Redakce tajemstvi
Cizi API rado vraci prijaty token v chybove zprave. Log ticketu ukazuje, co
sluzba vratila, a zobrazuje se klientovi. Proto vsechno, co jde do logu nebo do
chyby, projde nahradou znamych tajnych hodnot za hvezdicky.
Neni to volitelne dolazeni, je to soucast zapisu.
## Bezpecnostni hranice a co jeste chybi
Skripty ve slozce jsou **nase**, prosly gitem a code review. Bezi proto v procesu
serveru, ne v sandboxu. Plati pro ne:
- **nemaji sit mimo `ctx.http`** a v nem nesmi mirit do vnitrni site
(`ALLOW_PRIVATE_TARGETS` je jen pro vyvoj),
- **nedostanou pristupove udaje**,
- timeout je hlidany pres `AbortSignal`, takze prerusi cekani na sit.
Co to **neresi**: skript s `while (true)` timeout nezastavi. Runner vrati chybu,
ale smycka bezi dal a blokuje hlavni vlakno. U nasich skriptu je to prijatelne,
u zakaznickych ne - ti musi bezet v izolovanem enginu ve vlastnim vlakne.
Podrobnosti v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md), sekce
o skriptech.
## Napojeni do katalogu
Skript se domeri do katalogu konektoru jako akce s `implementation: 'script'`
a `scriptId`. Kdyz nese ID operace, ktera uz v katalogu je, **skript vyhrava** -
staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
Prekryv drzi `src/data/connectors.ts` (`setScriptActions`, `actionsFor`).
Je to zamerne tam, protoze vsechno ostatni se uz pta pres `findOperation`.
Tim se skripty naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem
kroku videt, i v sablonach - bez toho, aby se to psalo trikrat.
V portalu jsou operace se skriptem oznacene ikonou v katalogu konektoru.
## API
| Metoda | Cesta | Kdo smi |
| ------ | ----------------------------------------- | ---------------- |
| GET | `/api/dashboard/scripts` | prihlaseny |
| GET | `/api/dashboard/scripts/:id` | prihlaseny |
| PUT | `/api/dashboard/scripts/:id` | spravce platformy |
| POST | `/api/dashboard/scripts/:id/test` | spravce platformy |
| POST | `/api/dashboard/scripts/reload` | spravce platformy |
Cteni smi kazdy prihlaseny - builder potrebuje vedet, co skript umi. Uprava meni
chovani vseho, co skript pouziva, takze to neni pravo vedle prava zakladat tickety.
**Ukladani je bezpecne proti rozbiti.** Nejdriv se kod zapise do docasneho
souboru, ten se nacte a overi, a az pak prepise puvodni. Rozbita uprava se
neulozi a skript, ktery fungoval, funguje dal. Co presne nesedi, prijde
v `issues`.
**Zkusebni spusteni vola opravdovou sluzbu.** Vystavena faktura opravdu vznikne.
Zamerne: test, ktery volani predstira, nerekne nic o tom, jestli skript funguje.
Portal na to upozornuje nad tlacitkem.
## Ukazkove skripty pro iDoklad
Postavene proti skutecnemu API sluzby na `https://services.csbot.cz/apps/idoklad`.
Kazdy ukazuje jiny vzor, at je z ceho vychazet.
| Skript | Vzor |
| --------------------------------- | --------------------------------------------- |
| `idoklad.get-issued-invoice` | jedno volani a prevod odpovedi |
| `idoklad.find-issued-invoice` | predvalidace: nenalezeno **neni** chyba |
| `idoklad.find-contact` | vlastni kontrola vstupu (aspon jedno z dvojice) |
| `idoklad.create-issued-invoice` | dve volani, vzor z `/default` a prepis jen znamych poli |
| `idoklad.register-payment` | akce, ktera meni stav, plus idempotence |
| `idoklad.send-invoice-email` | odpoved nic nevraci, vystup se sklada ze vstupu |
### Proc se u zakladani bere vzor z `/default`
iDoklad u faktury vyzaduje pole, ktera nikdo rucne vyplnovat nechce
(`documentSerialNumber`, `isEet`, `isIncomeTax`). Skript proto nejdriv vezme
predvyplneny vzor z `GET /issued-invoices/default` a prepise v nem **jen to,
cemu rozumime**.
Kdyby se telo skladalo od nuly, rozbila by ho kazda zmena povinnych poli na
strane iDokladu.
### Idempotence
Kazde volani nese hlavicku `Idempotency-Key` s hodnotou `ctx.idempotencyKey`.
Klic je pro tentyz krok **stabilni pres vsechny pokusy**, takze druhy pokus
po timeoutu nevystavi druhou fakturu. Klic za pokus by byl k nicemu, o tom to
cele je.
## Jak pridat skript
1. Zkopirovat `scripts/_sablona.js` na `<konektor>.<operace>.js`.
2. Srovnat `manifest.id` s nazvem souboru.
3. Vyplnit `inputs` a `outputs`.
4. Napsat `run`.
5. Kdyz konektor jeste neni v `src/data/connectors.ts`, pridat ho.
6. Kdyz potrebuje pristupove udaje, pridat zaznam do `authSpecs`
v `src/scripts/connections.ts`.
Katalog, builder i stranka skriptu si ho vezmou samy. Nic se nerestartuje.
## Co chybi
| Chybi | Poznamka |
| ---------------------------- | --------------------------------------------------- |
| Napojeni za firmu | zatim jedno na konektor z environment variables |
| Skripty od zakazniku | potrebuji sandbox a vlastni vlakno, viz vyse |
| Verzovani skriptu | uprava prepise soubor, historie je jen v gitu |
| Vykonavani ze stromu | runner je hotovy, ale runtime automatizaci neni |
| Skripty jako spoustece | zatim jen akce, spoustec potrebuje runtime |
| Metriky pro widgety | manifest to zatim nezna, viz bod 4 navrhu |
| Ulozeni uprav mimo git | portal zapisuje do souboru v containeru, redeploy je vrati |
+64
View File
@@ -2,6 +2,70 @@
Nejnovejsi nahore.
## 2026-08-12 - skripty konektoru
Naprogramovana vykonna cast konektoru. Popis je
v [11-skripty-konektoru.md](11-skripty-konektoru.md).
### Pridano
- `scripts/` se skripty konektoru. Jeden soubor nese manifest (vstupni a vystupni
parametry) i kod. Obycejny JavaScript, aby se nemusel prekladat.
- Hot reload podle casu zmeny souboru. Uprava v portalu i rucni uprava souboru
se projevi bez restartu.
- Kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany.
Chybejici povinny vystup je chyba skriptu, ne uzivatele.
- `ctx` predavany skriptu: `http` nad adresou napojeni, `util`, `log`, `config`,
`idempotencyKey`, `fail` a `retry`. Skript nedostane pristupove udaje.
- Rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku, vzdy
vraci vysledek vcetne `retryable`.
- Redakce tajnych hodnot pred zapisem do logu.
- Napojeni z environment variables (`src/scripts/connections.ts`) vcetne iDokladu.
- Sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby
`services.csbot.cz/apps/idoklad`, kazdy na jiny vzor.
- Stranka `/dashboard/skripty`: seznam, manifest, editor, zkusebni spusteni.
Formular testu se sklada z manifestu, nepise se pro kazdy skript.
- Endpointy `/api/dashboard/scripts`, `/:id`, `PUT /:id`, `/:id/test` a `/reload`.
Vse ve Swaggeru.
### Zmeneno
- Katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji
prekryvem v `src/data/connectors.ts`, takze se naraz objevi ve validaci stromu,
ve vypoctu scope i v sablonach. Pri stejnem ID operace vyhrava skript.
- `ConnectorOperation` ma `implementation` a `scriptId`. Katalog v portalu operace
se skriptem oznacuje ikonou.
- `ApiError` na klientovi nese cele telo odpovedi a umi z nej vytahnout `issues`.
- Dockerfile kopiruje `scripts/` do vysledneho image.
### Vedome neudelano
Skripty bezi v procesu serveru, ne v sandboxu. Jsou nase a prosly gitem.
Zakaznicke skripty budou potrebovat izolovany engine ve vlastnim vlakne, duvod
je v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
Ulozeni z portalu zapisuje do souboru v containeru. Bez trvaleho svazku ho
redeploy vrati na verzi z gitu.
## 2026-08-12 - navrhy
Pridany [09-navrh-rozsireni.md](09-navrh-rozsireni.md)
a [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
09 popisuje datove modely: akce navazane na typ nebo tag ticketu s telem jako
operaci, vlastnim stromem nebo skriptem, typy a tagy ticketu, role a prava jako
data misto unionu, zalozky a zpristupneni konektoru za firmu, konektory rozdelene
na definici, zpristupneni a napojeni, cekaci krok, sablony zprav, vlastni widgety
se seskupovanim a prevod na Postgres. Soucasti je kontrola navrhu proti celemu
prikladu se dvema firmami jednoho cloveka.
10 popisuje vykonnou cast: cestu udalosti od webhooku pres inbox a dispatcher
k workeru, frontu v Postgresu se `SKIP LOCKED`, davkovy odber, spravedlnost mezi
klienty, idempotenci, retence a rozpocet na 150 klientu ve dvou scenarich objemu.
Nic z toho neni naprogramovane, oba dokumenty jsou navrh k rozhodnuti.
Kod se nemenil.
## 2026-08-03
Tickety predelane na plnohodnotny konektor. Prestavaji byt polozkou v seznamu